openmausbot 0.1.69 → 0.1.71

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 (66) hide show
  1. package/dist/assets/{index-BmHupgVJ.js → index-CJtyBonr.js} +1 -1
  2. package/dist/assets/index-CYLdYtYp.css +1 -0
  3. package/dist/assets/index-Di4ovwu5.js +289 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +6 -2
  6. package/dist-server/drivers/agents-proxy.js +305 -16
  7. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  8. package/dist-server/index.js +3141 -1124
  9. package/dist-server/local-computer-proxy.js +2 -0
  10. package/dist-server/local-computer.js +2 -1
  11. package/dist-server/mcp-gate.js +543 -0
  12. package/dist-server/mcp-server.js +38 -0
  13. package/dist-server/openmausbot.js +772 -260
  14. package/dist-server/pair-cli.js +772 -260
  15. package/dist-server/prepare-cloudflared.js +20 -3
  16. package/dist-server/proxy-paths.js +1 -0
  17. package/dist-server/server/agent-tool-policy.js +29 -0
  18. package/dist-server/server/bot-package.js +55 -2
  19. package/dist-server/server/browser-live.js +12 -7
  20. package/dist-server/server/config.js +40 -4
  21. package/dist-server/server/delegations.js +132 -74
  22. package/dist-server/server/drivers/acp/core.js +2 -0
  23. package/dist-server/server/drivers/acp/cursor.js +1 -1
  24. package/dist-server/server/drivers/acp/grok.js +5 -1
  25. package/dist-server/server/drivers/agents-proxy.js +324 -16
  26. package/dist-server/server/drivers/boxagent.js +1 -1
  27. package/dist-server/server/drivers/claude-login-auth.js +102 -9
  28. package/dist-server/server/drivers/claude.js +298 -21
  29. package/dist-server/server/drivers/codex.js +13 -4
  30. package/dist-server/server/drivers/pi.js +1 -1
  31. package/dist-server/server/engine-install.js +137 -0
  32. package/dist-server/server/env-path.js +12 -0
  33. package/dist-server/server/harness/bus.js +1 -1
  34. package/dist-server/server/harness/registry.js +31 -6
  35. package/dist-server/server/index.js +860 -215
  36. package/dist-server/server/local-computer.js +1 -1
  37. package/dist-server/server/mcp-gate-config.js +56 -0
  38. package/dist-server/server/mcp-gate.js +227 -0
  39. package/dist-server/server/mcp-trim.js +182 -0
  40. package/dist-server/server/memory-journal.js +440 -0
  41. package/dist-server/server/memory-store.js +308 -0
  42. package/dist-server/server/message-db.js +90 -3
  43. package/dist-server/server/package-export.js +25 -0
  44. package/dist-server/server/peer-approval.js +1 -1
  45. package/dist-server/server/peer-provenance.js +6 -2
  46. package/dist-server/server/proxy-paths.js +1 -0
  47. package/dist-server/server/redact.js +15 -0
  48. package/dist-server/server/resume-recovery.js +46 -0
  49. package/dist-server/server/routines.js +116 -55
  50. package/dist-server/server/screen-frame-gate.js +9 -6
  51. package/dist-server/server/skill-library.js +5 -5
  52. package/dist-server/server/steer-queue.js +20 -1
  53. package/dist-server/server/store.js +29 -7
  54. package/dist-server/server/system-prompt.js +14 -1
  55. package/dist-server/server/team-backup.js +53 -4
  56. package/dist-server/server/tool-summary.js +39 -0
  57. package/dist-server/server/tts/speech-text.js +3 -1
  58. package/dist-server/server/turn-context.js +18 -0
  59. package/dist-server/server/workspace.js +321 -22
  60. package/dist-server/shared/approval-mode.js +1 -1
  61. package/dist-server/shared/team-backup.js +14 -2
  62. package/dist-server/vps-container-mcp.js +6 -2
  63. package/package.json +1 -1
  64. package/skills/create-verification-skill/SKILL.md +2 -3
  65. package/dist/assets/index-COp5lMnv.js +0 -279
  66. package/dist/assets/index-Jk7TIeyW.css +0 -1
@@ -173,9 +173,25 @@ async function releaseBytes(asset) {
173
173
  const cached = cacheDirectory ? join(cacheDirectory, asset.name) : "";
174
174
  if (cached && existsSync(cached)) return readFileSync(cached);
175
175
  const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${asset.name}`;
176
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(12e4) });
177
- if (!response.ok) throw new Error(`could not download ${asset.name}: HTTP ${response.status}`);
178
- return Buffer.from(await response.arrayBuffer());
176
+ const retryableStatuses = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
177
+ for (let attempt = 1; attempt <= 3; attempt++) {
178
+ let failure;
179
+ try {
180
+ const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(12e4) });
181
+ if (response.ok) return Buffer.from(await response.arrayBuffer());
182
+ await response.body?.cancel().catch(() => {
183
+ });
184
+ failure = new Error(`could not download ${asset.name}: HTTP ${response.status}`);
185
+ if (!retryableStatuses.has(response.status)) throw failure;
186
+ } catch (error) {
187
+ if (!(error instanceof TypeError) && !["TimeoutError", "AbortError"].includes(error?.name)) throw error;
188
+ failure = error;
189
+ }
190
+ if (attempt === 3) throw failure;
191
+ const delay = 1e3 * 2 ** (attempt - 1);
192
+ console.warn(`cloudflared download attempt ${attempt}/3 failed: ${failure.message}; retrying in ${delay}ms`);
193
+ await new Promise((resolve) => setTimeout(resolve, delay));
194
+ }
179
195
  }
180
196
  async function stageTarget(root, target) {
181
197
  const asset = CLOUDFLARED_ASSETS[target];
@@ -251,6 +267,7 @@ export {
251
267
  executableTarget,
252
268
  parsePrepareCloudflaredArgs,
253
269
  prepareCloudflared,
270
+ releaseBytes,
254
271
  sha256,
255
272
  targetForCurrentHost,
256
273
  targetsForHost,
@@ -17,6 +17,7 @@ var SPAWNED_PROXIES = {
17
17
  agents: resolveProxy("drivers/agents-proxy"),
18
18
  dweb: resolveProxy("drivers/dweb-proxy"),
19
19
  connectors: resolveProxy("connector-proxy"),
20
+ mcpGate: resolveProxy("mcp-gate"),
20
21
  phone: resolveProxy("drivers/phone-proxy"),
21
22
  // Loaded by the external `pi` process via `-e`, not by this server — but
22
23
  // resolved through the same single source of truth so the packaged layout
@@ -0,0 +1,29 @@
1
+ /** Reviewed local reads exposed by the built-in agents MCP integration.
2
+ * This exact catalog supplies MCP tool metadata only; names, prefixes, or
3
+ * third-party annotations alone must never grant read access.
4
+ * Session recall keeps its own-bot scope and may record a deduplicated access
5
+ * disclosure in a room; it does not edit recalled messages or launch work.
6
+ */
7
+ export const READ_ONLY_AGENT_TOOL_NAMES = new Set([
8
+ "list_bots",
9
+ "list_rooms",
10
+ "list_threads",
11
+ "check_delegation",
12
+ "wait_delegation",
13
+ "session_search",
14
+ "session_read",
15
+ "list_routines",
16
+ "skills_list",
17
+ ]);
18
+ const READ_ONLY_ANNOTATIONS = Object.freeze({
19
+ readOnlyHint: true,
20
+ destructiveHint: false,
21
+ idempotentHint: true,
22
+ openWorldHint: false,
23
+ });
24
+ export function isReadOnlyAgentTool(name) {
25
+ return READ_ONLY_AGENT_TOOL_NAMES.has(name);
26
+ }
27
+ export function agentToolAnnotations(name) {
28
+ return isReadOnlyAgentTool(name) ? READ_ONLY_ANNOTATIONS : undefined;
29
+ }
@@ -1,10 +1,14 @@
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 { isSkillName, parseSkillMd, SKILL_FILE_MAX_BYTES } from "./skills.js";
4
5
  import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
5
6
  export const BOT_PACKAGE_FORMAT = "openmaus.package";
6
7
  export const BOT_PACKAGE_VERSION = 1;
7
8
  export const BOTMRR_MARKDOWN_VERSION = 1;
9
+ export const BOT_PACKAGE_SKILLS_VERSION = 1;
10
+ export const BOT_PACKAGE_MAX_SKILLS = 20;
11
+ const BOT_PACKAGE_MARKDOWN_MAX_BYTES = 1_000_000;
8
12
  const COLORS = [
9
13
  "green",
10
14
  "blue",
@@ -27,6 +31,24 @@ const key = requiredText(64).regex(/^[a-z0-9][a-z0-9_-]*$/, {
27
31
  message: "may only contain lowercase letters, numbers, - and _",
28
32
  });
29
33
  const MAX_DATE_MS = 8_640_000_000_000_000;
34
+ const skillName = requiredText(64).refine(isSkillName, {
35
+ message: "must be a lowercase skill name",
36
+ });
37
+ const portableSource = optionalText(2_000).refine((value) => value === undefined || !/^(?:[a-z]:[\\/]|[\\/]|\\\\|file:)/i.test(value), { message: "must not be an absolute path" });
38
+ const skillInstructions = z
39
+ .string({ error: "must be text" })
40
+ .min(1, { message: "is required" })
41
+ .max(SKILL_FILE_MAX_BYTES, { message: "is too long" })
42
+ .refine((value) => Buffer.byteLength(value, "utf8") <= SKILL_FILE_MAX_BYTES, { message: "is too large" })
43
+ .refine((value) => /^---\r?\n/.test(value), { message: "must start with SKILL.md frontmatter" });
44
+ const portableSkillSchema = z.object({
45
+ name: skillName,
46
+ description: requiredText(1_024),
47
+ source: portableSource,
48
+ license: optionalText(200),
49
+ compatibility: optionalText(200),
50
+ instructions: skillInstructions,
51
+ });
30
52
  const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
31
53
  const intervalWeekdays = z.array(z.number().int().min(0).max(6)).min(1).max(7).refine((weekdays) => new Set(weekdays).size === weekdays.length, "must contain unique weekdays");
32
54
  const intervalWindow = z.object({
@@ -113,6 +135,7 @@ const packageSchema = z.object({
113
135
  mascotBody: optionalText(40),
114
136
  }),
115
137
  playbooks: z.array(key).max(40).optional(),
138
+ skills: z.array(skillName).max(BOT_PACKAGE_MAX_SKILLS).optional(),
116
139
  })).min(1).max(200),
117
140
  chiefOfStaff: key.optional(),
118
141
  rooms: z.array(z.object({
@@ -144,6 +167,10 @@ const packageSchema = z.object({
144
167
  triggers: z.array(requiredText(100)).min(1).max(30),
145
168
  instructions: requiredText(24_000),
146
169
  })).max(80).optional(),
170
+ skills: z.object({
171
+ version: z.literal(BOT_PACKAGE_SKILLS_VERSION),
172
+ entries: z.array(portableSkillSchema).min(1).max(BOT_PACKAGE_MAX_SKILLS),
173
+ }).optional(),
147
174
  examples: z.array(z.object({
148
175
  title: requiredText(120),
149
176
  input: requiredText(4_000),
@@ -158,7 +185,7 @@ export function isBotPackage(value) {
158
185
  value.format === BOT_PACKAGE_FORMAT;
159
186
  }
160
187
  function markdownDocument(markdown) {
161
- if (Buffer.byteLength(markdown) > 1_000_000)
188
+ if (Buffer.byteLength(markdown) > BOT_PACKAGE_MARKDOWN_MAX_BYTES)
162
189
  throw new Error("The bot playbook is too large");
163
190
  const frontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
164
191
  if (!frontmatter)
@@ -206,6 +233,7 @@ export function parseBotPackage(value) {
206
233
  };
207
234
  const agents = unique(pkg.agents.map((agent) => agent.key), "agent");
208
235
  const playbooks = unique((pkg.playbooks ?? []).map((playbook) => playbook.key), "playbook");
236
+ const skills = unique((pkg.skills?.entries ?? []).map((skill) => skill.name), "skill");
209
237
  unique((pkg.rooms ?? []).map((room) => room.key), "room");
210
238
  unique((pkg.routines ?? []).map((routine) => routine.key), "routine");
211
239
  if (pkg.chiefOfStaff && !agents.has(pkg.chiefOfStaff)) {
@@ -216,6 +244,28 @@ export function parseBotPackage(value) {
216
244
  if (!playbooks.has(playbook))
217
245
  throw new Error(`Agent ${agent.key} references unknown playbook: ${playbook}`);
218
246
  }
247
+ const assignedSkills = unique(agent.skills ?? [], `skill in agent ${agent.key}`);
248
+ for (const skill of assignedSkills) {
249
+ if (!skills.has(skill))
250
+ throw new Error(`Agent ${agent.key} references unknown skill: ${skill}`);
251
+ }
252
+ }
253
+ const referencedSkills = new Set(pkg.agents.flatMap((agent) => agent.skills ?? []));
254
+ for (const skill of pkg.skills?.entries ?? []) {
255
+ const parsed = parseSkillMd(skill.instructions);
256
+ if ("error" in parsed)
257
+ throw new Error(`Skill ${skill.name} is invalid: ${parsed.error}`);
258
+ if (parsed.name !== skill.name)
259
+ throw new Error(`Skill ${skill.name} does not match its SKILL.md name`);
260
+ if (parsed.description !== skill.description)
261
+ throw new Error(`Skill ${skill.name} does not match its description`);
262
+ for (const field of ["license", "compatibility"]) {
263
+ if (skill[field] !== undefined && skill[field] !== parsed[field]) {
264
+ throw new Error(`Skill ${skill.name} does not match its ${field}`);
265
+ }
266
+ }
267
+ if (!referencedSkills.has(skill.name))
268
+ throw new Error(`Skill ${skill.name} is not referenced by an agent`);
219
269
  }
220
270
  for (const room of pkg.rooms ?? []) {
221
271
  const members = unique(room.members, `member in room ${room.key}`);
@@ -301,7 +351,10 @@ export function renderBotPackageMarkdown(document) {
301
351
  const connections = pkg.requirements.apps.length
302
352
  ? pkg.requirements.apps.map((app) => `- **${app.label}${app.optional ? " (optional)" : ""}:** ${app.reason}`).join("\n")
303
353
  : "- No connected apps are required.";
304
- return `---\n${frontmatter}\n---\n\n# ${pkg.name}\n\n${pkg.tagline}\n\n> **Give this file to your Chief of Staff.** It is the complete team blueprint. Any agent system can run it; OpenMausBot can also install it directly.\n\n## Activation\n\nYou are the Chief of Staff for this blueprint. Read the whole document before acting. Confirm the user's goal and any missing inputs, then create or delegate to the specialist roles below. Preserve their names, ownership, boundaries, shared-room rules, and playbooks. If your platform cannot literally spawn agents, perform the roles one at a time and keep their outputs clearly separated.\n\nNever request pasted passwords or secret keys. Use the platform's normal connection flow. Do not send messages, publish content, spend money, delete data, or enable a schedule without the user's explicit approval. All routines start paused.\n\n## Mission\n\n${pkg.summary}\n\n## Outcomes\n\n${list(pkg.outcomes)}\n\n## Connections\n\n${connections}\n\n## Team\n\n${agents}\n\n## Chief of Staff\n\nThe Chief of Staff role is \`${pkg.chiefOfStaff ?? pkg.agents[0].key}\`. This role owns delegation, synthesis, conflict resolution, and the final answer to the user.\n${rooms ? `\n## Shared rooms\n\n${rooms}\n` : ""}${routines ? `\n## Suggested routines\n\n${routines}\n` : ""}${playbooks ? `\n## Playbooks\n\n${playbooks}\n` : ""}${examples ? `\n## Example job\n\n${examples}\n` : ""}\n## Completion rule\n\nReturn one clear result to the user, distinguish evidence from inference, cite source links when the work uses external material, and state what still needs human approval or a connected app.\n`;
354
+ const markdown = `---\n${frontmatter}\n---\n\n# ${pkg.name}\n\n${pkg.tagline}\n\n> **Give this file to your Chief of Staff.** It is the complete team blueprint. Any agent system can run it; OpenMausBot can also install it directly.\n\n## Activation\n\nYou are the Chief of Staff for this blueprint. Read the whole document before acting. Confirm the user's goal and any missing inputs, then create or delegate to the specialist roles below. Preserve their names, ownership, boundaries, shared-room rules, and playbooks. If your platform cannot literally spawn agents, perform the roles one at a time and keep their outputs clearly separated.\n\nNever request pasted passwords or secret keys. Use the platform's normal connection flow. Do not send messages, publish content, spend money, delete data, or enable a schedule without the user's explicit approval. All routines start paused.\n\n## Mission\n\n${pkg.summary}\n\n## Outcomes\n\n${list(pkg.outcomes)}\n\n## Connections\n\n${connections}\n\n## Team\n\n${agents}\n\n## Chief of Staff\n\nThe Chief of Staff role is \`${pkg.chiefOfStaff ?? pkg.agents[0].key}\`. This role owns delegation, synthesis, conflict resolution, and the final answer to the user.\n${rooms ? `\n## Shared rooms\n\n${rooms}\n` : ""}${routines ? `\n## Suggested routines\n\n${routines}\n` : ""}${playbooks ? `\n## Playbooks\n\n${playbooks}\n` : ""}${examples ? `\n## Example job\n\n${examples}\n` : ""}\n## Completion rule\n\nReturn one clear result to the user, distinguish evidence from inference, cite source links when the work uses external material, and state what still needs human approval or a connected app.\n`;
355
+ if (Buffer.byteLength(markdown) > BOT_PACKAGE_MARKDOWN_MAX_BYTES)
356
+ throw new Error("The bot playbook is too large");
357
+ return markdown;
305
358
  }
306
359
  export function packageAgentAsMember(agent) {
307
360
  return {
@@ -7,6 +7,9 @@ const execute = promisify(execFile);
7
7
  const MAX_FRAME = 3 * 1024 * 1024;
8
8
  const MAX_BUFFER = 4 * 1024 * 1024;
9
9
  const HEARTBEAT_MS = 10_000;
10
+ // The native press resolver supplies the virtual key codes and Enter/Tab text
11
+ // that its raw input_keyboard relay omits. Keep unknown keys literal.
12
+ const DISCRETE_KEYS = new Set(["Backspace", "Enter", "Tab", "Escape", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"]);
10
13
  export class BrowserLiveError extends Error {
11
14
  status;
12
15
  constructor(message, status = 400) { super(message); this.name = "BrowserLiveError"; this.status = status; }
@@ -308,17 +311,20 @@ export class BrowserLive {
308
311
  throw new BrowserLiveError("The browser stream is not connected.", 409);
309
312
  let command;
310
313
  const { type, eventType, ...fields } = message;
314
+ const key = String(fields.code || fields.key);
311
315
  const shortcut = Number(fields.modifiers) > 0 && !["Alt", "Control", "Meta", "Shift"].includes(String(fields.key))
312
316
  && !(fields.modifiers === 8 && text(fields.key, 64) && [...fields.key].length === 1);
313
- if (type === "input_keyboard" && eventType === "keyUp" && shortcut)
314
- return; // press already releases the chord.
317
+ // press already released the key. Match raw held keys, not the modifiers
318
+ // on keyUp: they may have changed, or blur may flush releases with zero.
319
+ if (type === "input_keyboard" && eventType === "keyUp" && !viewer.pressedKeys.has(key))
320
+ return;
315
321
  if (type === "input_mouse")
316
322
  command = { action: "input_mouse", type: eventType, ...fields };
317
323
  else if (eventType === "char")
318
324
  command = { action: "keyboard", subaction: "insertText", text: fields.text };
319
- else if (eventType === "keyDown" && shortcut) {
320
- // The native input_keyboard handler omits CDP modifiers. Its press
321
- // action resolves chords and awaits the complete key-down/up pair.
325
+ else if (eventType === "keyDown" && (shortcut || DISCRETE_KEYS.has(String(fields.key)))) {
326
+ // press resolves virtual key codes, required text and modifiers, and
327
+ // acknowledges the complete key-down/up pair before hand-back.
322
328
  const modifiers = fields.modifiers;
323
329
  const chord = [[1, "Alt"], [2, "Control"], [4, "Meta"], [8, "Shift"]];
324
330
  command = { action: "press", key: [...chord.filter(([bit]) => modifiers & bit).map(([, key]) => key), fields.key].join("+") };
@@ -326,7 +332,6 @@ export class BrowserLive {
326
332
  else
327
333
  command = { action: "input_keyboard", type: eventType, key: fields.key,
328
334
  ...(fields.code === undefined ? {} : { code: fields.code }), ...(fields.text === undefined ? {} : { text: fields.text }) };
329
- const key = String(fields.code || fields.key);
330
335
  if (command.action === "input_keyboard" && eventType === "keyDown") {
331
336
  if (viewer.pressedKeys.size >= 64 && !viewer.pressedKeys.has(key))
332
337
  throw new BrowserLiveError("Too many keys are held. Restart the browser before continuing.", 409);
@@ -354,7 +359,7 @@ export class BrowserLive {
354
359
  }
355
360
  if (object(JSON.parse(Buffer.concat(chunks).toString("utf8")))?.success !== true)
356
361
  throw new Error("input failed");
357
- if (command.action === "input_keyboard" && eventType === "keyUp")
362
+ if (command.action === "press" || (command.action === "input_keyboard" && eventType === "keyUp"))
358
363
  viewer.pressedKeys.delete(key);
359
364
  if (type === "input_mouse" && eventType === "mouseReleased")
360
365
  viewer.pressedButtons.delete(String(fields.button));
@@ -195,9 +195,13 @@ const browserProfilesSchema = z.array(browserProfileSchema).max(20).superRefine(
195
195
  });
196
196
  });
197
197
  });
198
+ // Deliberately non-strict: an unknown flag (such as `skillRecorder`, the
199
+ // pre-rename name a stale client may still PATCH) is dropped as a no-op
200
+ // instead of failing the whole stored config or the request.
198
201
  const featureConfigSchema = z.object({
199
- /** Experimental desktop workflow recorder. Hidden unless explicitly enabled. */
200
- skillRecorder: z.boolean().optional(),
202
+ /** Bots may draft skills (skill_manage, /learn) for your review. Off unless
203
+ * explicitly enabled. */
204
+ skillAuthoring: z.boolean().optional(),
201
205
  /** Show each tool run in the transcript. Off unless explicitly enabled. */
202
206
  showToolCalls: z.boolean().optional(),
203
207
  /** Experimental built-in browser. Off until explicitly enabled; each bot
@@ -378,8 +382,8 @@ export function localVmMode(cfg) {
378
382
  export function localVmMaxInstances(cfg) {
379
383
  return cfg.localVm?.maxInstances ?? DEFAULT_LOCAL_VM_MAX_INSTANCES;
380
384
  }
381
- export function skillRecorderEnabled(cfg) {
382
- return cfg.features?.skillRecorder === true;
385
+ export function skillAuthoringEnabled(cfg) {
386
+ return cfg.features?.skillAuthoring === true;
383
387
  }
384
388
  export function showToolCallsEnabled(cfg) {
385
389
  return cfg.features?.showToolCalls === true;
@@ -407,6 +411,38 @@ export function ensureDirs() {
407
411
  }
408
412
  for (const dir of [DATA_DIR, EVENTS_DIR, NATIVE_DIR])
409
413
  mkdirSync(dir, { recursive: true });
414
+ migrateLegacyFeatureFlags();
415
+ }
416
+ /** `features.skillRecorder` became `features.skillAuthoring` when the Teach a
417
+ * skill recorder was removed. featureConfigSchema is non-strict, so
418
+ * parseStoredConfig would silently drop the old key (reading the opt-in as
419
+ * off) while saveConfig's raw section merge would carry it on disk forever.
420
+ * Rewrite it once, here, before the server's single loadConfig() at boot: an
421
+ * explicit `skillAuthoring` already on disk wins over the legacy key, and a
422
+ * file without the legacy key is left untouched. */
423
+ function migrateLegacyFeatureFlags() {
424
+ const p = join(DATA_DIR, "config.json");
425
+ try {
426
+ if (!existsSync(p))
427
+ return;
428
+ const disk = jsonObjectSchema.safeParse(parseJson(readFileSync(p, "utf8")));
429
+ if (!disk.success)
430
+ return;
431
+ const features = jsonObjectSchema.safeParse(disk.data.features);
432
+ if (!features.success || !Object.hasOwn(features.data, "skillRecorder"))
433
+ return;
434
+ const next = { ...features.data };
435
+ if (!Object.hasOwn(next, "skillAuthoring"))
436
+ next.skillAuthoring = next.skillRecorder === true;
437
+ delete next.skillRecorder;
438
+ writeFileAtomic(p, JSON.stringify({ ...disk.data, features: next }, null, 2), { mode: 0o600 });
439
+ console.log(`[config] features.skillRecorder renamed to features.skillAuthoring (${String(next.skillAuthoring)})`);
440
+ }
441
+ catch (error) {
442
+ // A readable but unwritable config would otherwise lose the opt-in on
443
+ // every boot with no trace: parseStoredConfig drops the legacy key.
444
+ console.error(`[config] could not rename features.skillRecorder: ${error instanceof Error ? error.message : String(error)}`);
445
+ }
410
446
  }
411
447
  export function loadConfig() {
412
448
  let cfg = {};
@@ -86,19 +86,26 @@ export function threadsWaitingOn(toBotId) {
86
86
  }
87
87
  /** Mark a target's observed busy period as finished and return the source
88
88
  * threads that should be retried. This makes retries count distinct busy
89
- * periods, not unrelated drain requests on the same source thread. */
90
- export function releaseDelegationsWaitingOn(toBotId) {
91
- const threads = threadsWaitingOn(toBotId);
92
- if (!threads.length)
93
- return threads;
94
- for (const threadId of threads) {
95
- for (const item of pendingDelegations.get(threadId) ?? []) {
96
- if (item.toBotId === toBotId)
97
- delete item.waitingOnBusy;
89
+ * periods, not unrelated drain requests on the same source thread.
90
+ * `only` narrows the release: a bot that is still busy in one thread has
91
+ * nevertheless freed a slot for the fresh-thread handoffs waiting on it,
92
+ * while its active-thread handoffs go on waiting for it to go idle. */
93
+ export function releaseDelegationsWaitingOn(toBotId, only) {
94
+ const released = [];
95
+ for (const [threadId, items] of pendingDelegations) {
96
+ let any = false;
97
+ for (const item of items) {
98
+ if (item.toBotId !== toBotId || item.waitingOnBusy !== true || (only && !only(item)))
99
+ continue;
100
+ delete item.waitingOnBusy;
101
+ any = true;
98
102
  }
103
+ if (any)
104
+ released.push(threadId);
99
105
  }
100
- savePending();
101
- return threads;
106
+ if (released.length)
107
+ savePending();
108
+ return released;
102
109
  }
103
110
  function savePending() {
104
111
  try {
@@ -140,6 +147,9 @@ export function _loadPending() {
140
147
  if (typeof item.originatingGroupId === "string" && item.originatingGroupId) {
141
148
  loaded.originatingGroupId = item.originatingGroupId;
142
149
  }
150
+ if (typeof item.targetThreadId === "string" && item.targetThreadId) {
151
+ loaded.targetThreadId = item.targetThreadId;
152
+ }
143
153
  return [loaded];
144
154
  });
145
155
  if (items.length)
@@ -195,6 +205,7 @@ export function pendingDelegationSnapshot() {
195
205
  sourceBotId: item.sourceBotId,
196
206
  toBotId: item.toBotId,
197
207
  ...(item.reason ? { reason: item.reason } : {}),
208
+ ...(item.targetThreadId ? { targetThreadId: item.targetThreadId } : {}),
198
209
  })));
199
210
  }
200
211
  /** How many handoffs one turn may queue. Small on purpose: this is the only
@@ -231,18 +242,27 @@ export function queueDelegation(bus, from, item, maxDepth, sourceThreadId = from
231
242
  list.push({ ...item, id, sourceBotId: from.id, attempts: 0, ...(groupId ? { originatingGroupId: groupId } : {}) });
232
243
  pendingDelegations.set(sourceThreadId, list);
233
244
  savePending();
234
- const label = `Delegated to @${target.name}${item.reason ? `: ${item.reason}` : ""}`;
235
245
  const sourceGroup = sourceThreadId ? bus.store.groupByThread(sourceThreadId) : undefined;
236
- bus.store.appendMessage(sourceThreadId, {
246
+ // A fresh-thread handoff is announced as the thread it opened, with a
247
+ // link to it; a classic one as the delegation it is.
248
+ const openedThread = item.targetThreadId ? bus.store.taskByThread(target.id, item.targetThreadId) : undefined;
249
+ const chip = {
237
250
  role: "bot",
238
251
  kind: "activity",
239
252
  // settled at birth: queueing is the whole act. Left open, the chip
240
253
  // would spin until the transcript is closed — the chat never patches it
241
- tool: { name: label, ok: true },
242
- ...(sourceGroup && !sourceGroup.dm
243
- ? { from: { botId: from.id, name: from.name, color: from.color } }
244
- : {}),
245
- });
254
+ tool: {
255
+ name: openedThread
256
+ ? `Opened thread #${openedThread.title} on ${target.name}`
257
+ : `Delegated to @${target.name}${item.reason ? `: ${item.reason}` : ""}`,
258
+ ok: true,
259
+ },
260
+ };
261
+ if (openedThread)
262
+ chip.threadRef = { botId: target.id, threadId: openedThread.threadId, title: openedThread.title };
263
+ if (sourceGroup && !sourceGroup.dm)
264
+ chip.from = { botId: from.id, name: from.name, color: from.color };
265
+ bus.store.appendMessage(sourceThreadId, chip);
246
266
  return { result: "ok", id };
247
267
  }
248
268
  /** Drain queued delegations for a source thread (called on its
@@ -418,35 +438,12 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
418
438
  if (dropIfUnreachable(bus, sender, target, sourceThreadId, item)) {
419
439
  return "settled";
420
440
  }
421
- if (target.busy) {
422
- if (item.waitingOnBusy)
423
- return "requeued";
424
- item.attempts += 1;
425
- item.waitingOnBusy = true;
426
- if (item.attempts < MAX_BUSY_ATTEMPTS) {
427
- savePending();
428
- bus.store.appendMessage(sourceThreadId, {
429
- role: "bot",
430
- kind: "activity",
431
- tool: { name: `Delegation to @${target.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
432
- });
433
- return "requeued";
434
- }
435
- recordDelegationReceipt({
436
- id: item.id,
437
- sourceThreadId,
438
- toBotId: target.id,
439
- toBotName: target.name,
440
- status: "busy_gave_up",
441
- result: `@${target.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
442
- });
443
- bus.store.appendMessage(sourceThreadId, {
444
- role: "bot",
445
- kind: "activity",
446
- tool: { name: `Delegation to @${target.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
447
- });
441
+ if (dropIfThreadGone(bus, target, sourceThreadId, item)) {
448
442
  return "settled";
449
443
  }
444
+ const held = holdWhileTargetBusy(bus, target, sourceThreadId, item);
445
+ if (held)
446
+ return held;
450
447
  if (item.waitingOnBusy) {
451
448
  delete item.waitingOnBusy;
452
449
  savePending();
@@ -494,35 +491,12 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
494
491
  if (dropIfUnreachable(bus, currentSender, current, sourceThreadId, item)) {
495
492
  return "settled";
496
493
  }
497
- if (current.busy) {
498
- if (item.waitingOnBusy)
499
- return "requeued";
500
- item.attempts += 1;
501
- item.waitingOnBusy = true;
502
- if (item.attempts < MAX_BUSY_ATTEMPTS) {
503
- savePending();
504
- bus.store.appendMessage(sourceThreadId, {
505
- role: "bot",
506
- kind: "activity",
507
- tool: { name: `Delegation to @${current.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
508
- });
509
- return "requeued";
510
- }
511
- recordDelegationReceipt({
512
- id: item.id,
513
- sourceThreadId,
514
- toBotId: current.id,
515
- toBotName: current.name,
516
- status: "busy_gave_up",
517
- result: `@${current.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
518
- });
519
- bus.store.appendMessage(sourceThreadId, {
520
- role: "bot",
521
- kind: "activity",
522
- tool: { name: `Delegation to @${current.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
523
- });
494
+ if (dropIfThreadGone(bus, current, sourceThreadId, item)) {
524
495
  return "settled";
525
496
  }
497
+ const heldAfterApproval = holdWhileTargetBusy(bus, current, sourceThreadId, item);
498
+ if (heldAfterApproval)
499
+ return heldAfterApproval;
526
500
  sender = currentSender;
527
501
  target = current;
528
502
  }
@@ -533,10 +507,94 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
533
507
  const channel = getOrCreateChannel(bus.store, sender, target, originatingGroup);
534
508
  mirrorExchange(bus, sender, target, item.message, channel, sourceThreadId);
535
509
  const reasonLine = item.reason ? `\n\n[Reason: ${item.reason}]` : "";
536
- const prefixed = `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
537
- await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id);
510
+ // A fresh thread's first line gets the shared peer-provenance note from
511
+ // the harness (which knows whether the opener was unattended); the
512
+ // classic handoff keeps the prefix it has always had.
513
+ const prefixed = item.targetThreadId
514
+ ? item.message
515
+ : `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
516
+ await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id, item.targetThreadId);
538
517
  return "dispatched";
539
518
  }
519
+ /** A busy target holds the handoff. What "busy" means depends on where the
520
+ * turn will run: a classic delegation lands in the target's active thread,
521
+ * so it waits for the bot to go idle, with bounded retries so a stuck peer
522
+ * cannot pin the handoff forever. A fresh-thread handoff needs only a free
523
+ * slot, and waits in line for one without a bound — it IS the line, the
524
+ * same one a person's message joins at that limit. Returns null when the
525
+ * target can take the turn now. */
526
+ function holdWhileTargetBusy(bus, target, sourceThreadId, item) {
527
+ if (item.targetThreadId) {
528
+ if (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
529
+ return null;
530
+ if (item.waitingOnBusy)
531
+ return "requeued";
532
+ item.waitingOnBusy = true;
533
+ savePending();
534
+ // one chip per wait, not one per retry: the line moves on every settle
535
+ if (item.attempts === 0) {
536
+ item.attempts = 1;
537
+ const title = bus.store.taskByThread(target.id, item.targetThreadId)?.title ?? "thread";
538
+ bus.store.appendMessage(sourceThreadId, {
539
+ role: "bot",
540
+ kind: "activity",
541
+ tool: { name: `Thread #${title} on @${target.name} waiting for a free slot` },
542
+ });
543
+ }
544
+ return "requeued";
545
+ }
546
+ if (!target.busy)
547
+ return null;
548
+ if (item.waitingOnBusy)
549
+ return "requeued";
550
+ item.attempts += 1;
551
+ item.waitingOnBusy = true;
552
+ if (item.attempts < MAX_BUSY_ATTEMPTS) {
553
+ savePending();
554
+ bus.store.appendMessage(sourceThreadId, {
555
+ role: "bot",
556
+ kind: "activity",
557
+ tool: { name: `Delegation to @${target.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
558
+ });
559
+ return "requeued";
560
+ }
561
+ recordDelegationReceipt({
562
+ id: item.id,
563
+ sourceThreadId,
564
+ toBotId: target.id,
565
+ toBotName: target.name,
566
+ status: "busy_gave_up",
567
+ result: `@${target.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
568
+ });
569
+ bus.store.appendMessage(sourceThreadId, {
570
+ role: "bot",
571
+ kind: "activity",
572
+ tool: { name: `Delegation to @${target.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
573
+ });
574
+ return "settled";
575
+ }
576
+ /** The thread a fresh-thread handoff was opened in may be deleted while the
577
+ * handoff waits. There is nowhere for the turn to run then, and running it
578
+ * in the target's active thread would put a bot's job in front of the
579
+ * person unasked. */
580
+ function dropIfThreadGone(bus, target, sourceThreadId, item) {
581
+ if (!item.targetThreadId || bus.store.taskByThread(target.id, item.targetThreadId))
582
+ return false;
583
+ recordDelegationReceipt({
584
+ id: item.id,
585
+ sourceThreadId,
586
+ toBotId: target.id,
587
+ toBotName: target.name,
588
+ status: "dropped",
589
+ result: `the thread opened on @${target.name} was deleted before it could start`,
590
+ });
591
+ bus.store.appendMessage(sourceThreadId, {
592
+ role: "bot",
593
+ kind: "activity",
594
+ tool: { name: `Thread on @${target.name} canceled — it was deleted before it could start`, ok: false },
595
+ });
596
+ return true;
597
+ }
540
598
  /** The source thread may be a bot's own task or a shared group where the
541
599
  * bot is a member. This is the same check the API gate uses for group turns. */
542
600
  function sourceThreadBelongsToBot(store, botId, threadId) {
@@ -35,6 +35,7 @@ import { supportsApprovalMode } from "../../../shared/approval-mode.js";
35
35
  // packaged server dir entirely. See server/proxy-paths.ts.
36
36
  const COMPUTER_PROXY_PATH = SPAWNED_PROXIES.computer;
37
37
  import { appendNative } from "../native.js";
38
+ import { commandSummary } from "../../tool-summary.js";
38
39
  import { SPAWNED_PROXIES } from "../../proxy-paths.js";
39
40
  const envOr = (key, fallback) => Number(process.env[key] ?? fallback);
40
41
  const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
@@ -568,6 +569,7 @@ export function createAcpDriver(support) {
568
569
  itemType: "tool",
569
570
  itemId: u.toolCallId,
570
571
  title: String(u.rawInput?.command ?? u.title ?? "tool").slice(0, 80),
572
+ summary: commandSummary(u.rawInput),
571
573
  });
572
574
  break;
573
575
  }
@@ -177,7 +177,7 @@ export function decodeCursorModelText(text) {
177
177
  const line = raw.trim();
178
178
  if (!line || line.startsWith("#") || /^available\s+models?\b/i.test(line) || /^models?\b/i.test(line))
179
179
  continue;
180
- const stripped = line.replace(/^[\s*•\-]+\s*/, "");
180
+ const stripped = line.replace(/^[\s*•-]+\s*/, "");
181
181
  const parts = stripped.split(/\s+[—–|:]\s+|\s+-\s+|\s{2,}/);
182
182
  const id = (parts[0] ?? "").trim();
183
183
  const rawLabel = parts.slice(1).join(" ").trim();
@@ -208,9 +208,13 @@ const support = {
208
208
  // and BEFORE `stdio` (`grok agent -m slug stdio`). Putting -m first is
209
209
  // accepted as a TUI option and then ignored, so ACP session/new keeps
210
210
  // [models].default (grok-4.6) and oMLX never sees a request.
211
+ // Auto selects Grok's native classifier; if its feature gate is disabled,
212
+ // residual requests still ask. Never replace it with bypassPermissions.
213
+ // Verified: grok 1.0.3 --help and xai-org/grok-build@37949780,
214
+ // crates/codegen/xai-grok-pager-bin/src/main.rs:1259-1273.
211
215
  spawnArgs: (config, turn) => [
212
216
  "--permission-mode",
213
- config.fullAuto ? "bypassPermissions" : "default",
217
+ config.fullAuto ? "bypassPermissions" : turn.approvalMode === "auto" ? "auto" : "default",
214
218
  "agent",
215
219
  ...(turn.model ? ["-m", turn.model] : []),
216
220
  // long form on purpose: `--effort` is documented as an alias, and an