openmausbot 0.1.80 → 0.1.82

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 (58) hide show
  1. package/README.md +6 -5
  2. package/dist/assets/{index-D6RZKWks.js → index-5HVwp5m2.js} +1 -1
  3. package/dist/assets/index-BLLsDK2F.js +309 -0
  4. package/dist/assets/index-CKysBq-V.css +1 -0
  5. package/dist/index.html +2 -2
  6. package/dist-server/container-mcp.js +41 -10
  7. package/dist-server/drivers/agents-proxy.js +50 -11
  8. package/dist-server/index.js +3525 -1296
  9. package/dist-server/openmausbot.js +412 -84
  10. package/dist-server/pair-cli.js +412 -84
  11. package/dist-server/server/box-delete-journal.js +471 -0
  12. package/dist-server/server/box.js +345 -75
  13. package/dist-server/server/cli-phone-setup.js +2 -2
  14. package/dist-server/server/cli.js +84 -12
  15. package/dist-server/server/config.js +48 -18
  16. package/dist-server/server/container-computer.js +7 -0
  17. package/dist-server/server/contracts.js +5 -0
  18. package/dist-server/server/default-model-selection.js +1 -0
  19. package/dist-server/server/drivers/acp/core.js +113 -19
  20. package/dist-server/server/drivers/acp/opencode-go.js +19 -0
  21. package/dist-server/server/drivers/agents-proxy.js +49 -11
  22. package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
  23. package/dist-server/server/drivers/claude.js +33 -2
  24. package/dist-server/server/drivers/codex.js +93 -8
  25. package/dist-server/server/drivers/native.js +6 -1
  26. package/dist-server/server/harness/bus.js +4 -0
  27. package/dist-server/server/harness/registry.js +1 -0
  28. package/dist-server/server/index.js +869 -226
  29. package/dist-server/server/local-vm-lease.js +4 -0
  30. package/dist-server/server/mcp-http.js +337 -0
  31. package/dist-server/server/mcp-probe.js +67 -18
  32. package/dist-server/server/mcp-registry.js +153 -58
  33. package/dist-server/server/member-turn.js +13 -0
  34. package/dist-server/server/message-db.js +74 -8
  35. package/dist-server/server/notify.js +8 -3
  36. package/dist-server/server/recall-disclosure.js +4 -0
  37. package/dist-server/server/recent-work.js +142 -0
  38. package/dist-server/server/request-auth.js +3 -1
  39. package/dist-server/server/routines.js +36 -1
  40. package/dist-server/server/screen-frame-gate.js +4 -94
  41. package/dist-server/server/sessions.js +22 -3
  42. package/dist-server/server/store.js +16 -1
  43. package/dist-server/server/surface.js +41 -19
  44. package/dist-server/server/team-setup-requests.js +1 -1
  45. package/dist-server/server/thread-log-rotation.js +104 -0
  46. package/dist-server/server/tts/fish.js +155 -0
  47. package/dist-server/server/tts/index.js +25 -3
  48. package/dist-server/server/turn-resources.js +7 -2
  49. package/dist-server/server/vps-computer.js +26 -4
  50. package/dist-server/server/vps-ssh.js +88 -0
  51. package/dist-server/server/workspace-backup.js +11 -0
  52. package/dist-server/server/workspace.js +2 -1
  53. package/dist-server/shared/credential-request.js +10 -0
  54. package/dist-server/shared/tool-surface.js +62 -0
  55. package/dist-server/vps-container-mcp.js +41 -10
  56. package/package.json +1 -1
  57. package/dist/assets/index-Cu8BkIfo.js +0 -305
  58. package/dist/assets/index-tfpeJAIG.css +0 -1
@@ -20,6 +20,11 @@ function routineRequestOwnerKey(owner) {
20
20
  }
21
21
  const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
22
22
  const CATCH_UP_MS = 12 * 60 * 60_000;
23
+ /** How long before a due routine the computer is asked to stay awake. */
24
+ const WAKE_HORIZON_MS = 60 * 60_000;
25
+ /** How long a run may sit deferred behind a busy target before the user
26
+ * hears about it once. Surfacing only; dispatch semantics stay unchanged. */
27
+ export const ROUTINE_DEFERRAL_NOTICE_MS = 30 * 60_000;
23
28
  const MAX_DATE_MS = 8_640_000_000_000_000;
24
29
  const LOCAL_DAY_MS = 24 * 60 * 60_000;
25
30
  const INTERVAL_RESTRICTION_SEARCH_MS = 9 * LOCAL_DAY_MS;
@@ -561,6 +566,21 @@ export class RoutineManager {
561
566
  listRoutines() {
562
567
  return this.routines.map(cloneRoutine);
563
568
  }
569
+ /** Whether this computer should stay awake for routines: a run is in
570
+ * flight, or an enabled routine is due within the horizon. The schedule
571
+ * runs inside this process — a sleeping computer runs nothing — so the
572
+ * desktop shell holds a power assertion while this says so, and releases
573
+ * it the rest of the time. */
574
+ wakeHold(horizonMs = WAKE_HORIZON_MS) {
575
+ const now = this.now();
576
+ if (this.runs.some((run) => ["queued", "running", "waiting"].includes(run.status)))
577
+ return { hold: true, reason: "running" };
578
+ const due = this.routines
579
+ .filter((routine) => routine.enabled && routine.nextRunAt != null && routine.nextRunAt <= now + horizonMs)
580
+ .map((routine) => routine.nextRunAt)
581
+ .sort((a, b) => a - b)[0];
582
+ return due === undefined ? { hold: false } : { hold: true, reason: "due", at: due };
583
+ }
564
584
  listRuns(from, to) {
565
585
  return this.runs
566
586
  .filter((r) => (from == null || r.scheduledFor >= from) && (to == null || r.scheduledFor <= to))
@@ -1151,8 +1171,23 @@ export class RoutineManager {
1151
1171
  }
1152
1172
  }
1153
1173
  const state = this.targetState(run);
1154
- if (state === "busy")
1174
+ if (state === "busy") {
1175
+ // A queued run behind a busy target is deferred, not silent. Stamp
1176
+ // the wait once so receipts and cards can say how long it has been
1177
+ // held; the run still dispatches the moment the target frees.
1178
+ if (run.deferredAt == null) {
1179
+ run.deferredAt = now;
1180
+ this.save();
1181
+ this.emitRun(run);
1182
+ }
1183
+ if (run.deferredNoticeAt == null && now - run.deferredAt >= ROUTINE_DEFERRAL_NOTICE_MS) {
1184
+ run.deferredNoticeAt = now;
1185
+ this.save();
1186
+ this.emitRun(run);
1187
+ this.options.onRunDeferred?.(cloneRun(run));
1188
+ }
1155
1189
  continue;
1190
+ }
1156
1191
  if (state === "missing") {
1157
1192
  this.failRun(run, this.missingTargetMessage(run.target));
1158
1193
  continue;
@@ -11,80 +11,10 @@
11
11
  // is what keeps its shell-only replies from re-picturing the same idle
12
12
  // desktop.
13
13
  import { createHash } from "node:crypto";
14
- /** Tools that change or show the screen, by their bare MCP names. One list
15
- * for every computer surface, because the poke site only has the name: the
16
- * cloud box and remote computers (server/computer-proxy.ts), the built-in
17
- * browser (server/drivers/browser-proxy.ts) and Cua Driver's own surface
18
- * for the local Mac, Local VM and VPS (docs/computer-use-integration.md).
19
- *
20
- * Deliberately NOT here: computer_exec (a shell — no screenshot unless
21
- * asked, and the ask is not visible at the poke site); the read-only text
22
- * tools (computer_status, browser_state, browser_snapshot, browser_read,
23
- * observation_metrics, start_session, get_*, list_*, check_permissions);
24
- * the waits (wait_for, wait_for_navigation, browser_wait_for — they change
25
- * nothing, and the action they follow already counted); and the takeover
26
- * pleas (computer_request_help, browser_request_takeover — whatever changed
27
- * during those was the person's doing, and captures are withheld under
28
- * their lease anyway). */
29
- const SCREEN_TOUCHING_TOOLS = new Set([
30
- // cloud box / remote computer
31
- "screenshot",
32
- "click",
33
- "type_text",
34
- "press_key",
35
- "scroll",
36
- "computer_batch",
37
- "open_url",
38
- "browser_click",
39
- "browser_fill",
40
- // built-in browser: the Electron surface's names, kept because a bot on an
41
- // older remote harness can still call them
42
- "browser_navigate",
43
- "browser_type",
44
- "browser_press",
45
- "browser_scroll",
46
- "browser_hover",
47
- "browser_drag",
48
- "browser_select_option",
49
- "browser_back",
50
- "browser_forward",
51
- "browser_screenshot",
52
- // built-in browser: agent-browser's names. The engine swap replaced the
53
- // Electron surface without renaming these here, so every browser turn
54
- // stopped counting as screen work and no page ever reached the transcript.
55
- // Read-only tools stay out by the same rule as everywhere else in this
56
- // list: agent_browser_snapshot, _read and _get_text return text, and the
57
- // waits change nothing the action before them did not already count.
58
- "agent_browser_open",
59
- "agent_browser_click",
60
- "agent_browser_fill",
61
- "agent_browser_type",
62
- "agent_browser_press",
63
- "agent_browser_select",
64
- "agent_browser_check",
65
- "agent_browser_screenshot",
66
- // Cua Driver (local Mac, Local VM, VPS)
67
- "double_click",
68
- "right_click",
69
- "drag",
70
- "hotkey",
71
- "move_cursor",
72
- "launch_app",
73
- "bring_to_front",
74
- "zoom",
75
- ]);
76
- // Keep legacy MCP namespaces accepted; desktop server__tool names follow
77
- // MCP_NAME in mcp-registry.ts (lowercase letters, digits, underscores, hyphens).
78
- const TOOL_NAMESPACE = /^(?:mcp__.+?|[a-z][a-z0-9_-]{0,31})__/;
79
- /** The same tool reaches the poke site as `mcp__computer__click`,
80
- * `computer__click`, bare `click`, or pi's `computer_click`.
81
- * A single-underscore server prefix is stripped
82
- * at most once, so pi's `computer_computer_exec` lands on `computer_exec`
83
- * — still a shell — and never on a bare `exec`. */
84
- export function screenTouchingTool(toolName) {
85
- const bare = toolName.toLowerCase().replace(TOOL_NAMESPACE, "");
86
- return SCREEN_TOUCHING_TOOLS.has(bare) || SCREEN_TOUCHING_TOOLS.has(bare.replace(/^(?:computer|browser)_/, ""));
87
- }
14
+ // Tool-name classification lives in shared/tool-surface.ts so the renderer
15
+ // reads a tool the same way the poller does.
16
+ import { screenSurfaceForTool, screenTouchingTool } from "../shared/tool-surface.js";
17
+ export { screenSurfaceForTool, screenTouchingTool };
88
18
  /** sha256 over the base64 frame — the same fingerprint the model-side
89
19
  * observation dedupe uses (server/computer-observation.ts). */
90
20
  export function screenFrameHash(png) {
@@ -96,23 +26,3 @@ export function screenFrameHash(png) {
96
26
  export function settledFrameIsNews(shownFrameHash, png) {
97
27
  return shownFrameHash === undefined || screenFrameHash(png) !== shownFrameHash;
98
28
  }
99
- /** Which surface a screen-touching tool acted on. A bot can hold a computer
100
- * and a browser at once, and they are different pictures: agent-browser runs
101
- * its own headless Chrome on the host, not inside the bot's desktop. Picking
102
- * by the tool is what keeps a browsed page from being illustrated with an
103
- * idle Local VM. */
104
- export function screenSurfaceForTool(toolName) {
105
- const name = toolName.toLowerCase();
106
- // The computer server's browser_click/fill act inside the desktop, not
107
- // in agent-browser. Keep the server identity before stripping prefixes.
108
- if (name.startsWith("mcp__computer__") || name.startsWith("computer_"))
109
- return "computer";
110
- if (name.startsWith("mcp__browser__") || name.startsWith("browser__"))
111
- return "browser";
112
- const bare = name.replace(TOOL_NAMESPACE, "");
113
- // Codex reports bare names. These two belong to computer-proxy; the
114
- // standalone browser now uses the unambiguous agent_browser_* names.
115
- if (bare === "browser_click" || bare === "browser_fill")
116
- return "computer";
117
- return bare.startsWith("agent_browser_") || bare.startsWith("browser_") ? "browser" : "computer";
118
- }
@@ -95,6 +95,18 @@ export function generatePairingCode() {
95
95
  }
96
96
  return code;
97
97
  }
98
+ /** The prefix that tells the two encodings apart on the wire. A credential is
99
+ * matched byte for byte; only a typed code is normalized. */
100
+ export const PAIRING_CREDENTIAL_PREFIX = "omb_pair_";
101
+ /** The same pairing window as a 256-bit secret, for a QR a native app scans
102
+ * rather than a code a person reads out. 9 + 43 characters: the Android
103
+ * companion checks that length exactly (android/core Connection.kt). */
104
+ export function generatePairingCredential() {
105
+ return `${PAIRING_CREDENTIAL_PREFIX}${randomBytes(32).toString("base64url")}`;
106
+ }
107
+ export function isPairingCredential(value) {
108
+ return value.startsWith(PAIRING_CREDENTIAL_PREFIX);
109
+ }
98
110
  /** Accept what a human typed: dashes, spaces, lowercase, lookalikes. */
99
111
  export function normalizePairingCode(input) {
100
112
  return input
@@ -245,17 +257,19 @@ export class SessionRegistry {
245
257
  this.prune();
246
258
  const now = this.now();
247
259
  const code = generatePairingCode();
260
+ const credential = generatePairingCredential();
248
261
  const scopes = input.scopes?.length ? [...new Set(input.scopes)] : [...SCOPES];
249
262
  const pairing = {
250
263
  id: randomUUID(),
251
264
  codeHash: sha256(code),
265
+ credentialHash: sha256(credential),
252
266
  scopes,
253
267
  label: (input.label ?? "").trim().slice(0, 80),
254
268
  createdAt: now,
255
269
  expiresAt: now + (input.ttlMs ?? PAIRING_CODE_TTL_MS),
256
270
  };
257
271
  this.pairings.push(pairing);
258
- return { id: pairing.id, code, expiresAt: pairing.expiresAt };
272
+ return { id: pairing.id, code, credential, expiresAt: pairing.expiresAt };
259
273
  }
260
274
  openPairings() {
261
275
  this.prune();
@@ -303,7 +317,12 @@ export class SessionRegistry {
303
317
  exchange(input) {
304
318
  this.prune();
305
319
  const now = this.now();
306
- const presented = sha256(normalizePairingCode(input.code));
320
+ // A credential is hashed as presented. Normalizing it would be actively
321
+ // unsafe: normalizePairingCode folds 0 to O and 1 to I, which both
322
+ // destroys a base64url secret and maps distinct secrets onto one digest.
323
+ const presented = isPairingCredential(input.code)
324
+ ? sha256(input.code)
325
+ : sha256(normalizePairingCode(input.code));
307
326
  const attemptId = typeof input.attemptId === "string" && /^[\w-]{8,64}$/.test(input.attemptId) ? input.attemptId : null;
308
327
  const replay = attemptId ? this.replays.find((r) => r.attemptId === attemptId && sameDigest(r.codeHash, presented)) : undefined;
309
328
  if (replay)
@@ -313,7 +332,7 @@ export class SessionRegistry {
313
332
  const seconds = Math.ceil(lock.retryAfterMs / 1000);
314
333
  return { ok: false, status: 429, error: `too many failed pairing attempts from your address; try again in ${seconds}s` };
315
334
  }
316
- const index = this.pairings.findIndex((p) => sameDigest(p.codeHash, presented));
335
+ const index = this.pairings.findIndex((p) => sameDigest(p.codeHash, presented) || sameDigest(p.credentialHash, presented));
317
336
  if (index < 0) {
318
337
  this.recordFailure(input.source);
319
338
  return { ok: false, status: 401, error: "pairing code is wrong or has expired; create a new one on the server" };
@@ -27,7 +27,7 @@ export function isProjectEmoji(value) {
27
27
  const TASK_PATCH_FIELDS = [
28
28
  "title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
29
29
  "unread", "rewound", "archivedAt", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
30
- "routineRunId",
30
+ "routineRunId", "surface",
31
31
  ];
32
32
  /** Everything the BOT authored is scrubbed of content-shaped secrets before
33
33
  * it is stored: its reply text, a tool title (an ACP engine's title can be
@@ -1296,6 +1296,21 @@ export class Store {
1296
1296
  this.emit({ type: "bot", botId: id });
1297
1297
  return bot;
1298
1298
  }
1299
+ /** Voice ids belong to one provider's catalog. Changing the workspace
1300
+ * provider invalidates every per-agent selection as one durable mutation,
1301
+ * before clients are told to pick replacement voices. */
1302
+ clearVoiceSelections() {
1303
+ const changed = this.bots.filter((bot) => bot.voice !== undefined && bot.voice !== "");
1304
+ if (!changed.length)
1305
+ return [];
1306
+ const next = this.bots.map((bot) => bot.voice === undefined || bot.voice === "" ? bot : { ...bot, voice: undefined });
1307
+ this.saveBots(next);
1308
+ for (const bot of changed) {
1309
+ delete bot.voice;
1310
+ this.emit({ type: "bot", botId: bot.id });
1311
+ }
1312
+ return changed;
1313
+ }
1299
1314
  /** Commit a validated profile change before publishing its fields. Unlike
1300
1315
  * runtime revocation, a failed user edit must leave the old profile intact. */
1301
1316
  patchBotProfile(id, patch) {
@@ -20,12 +20,15 @@ export function surfaceOfComputerKind(kind) {
20
20
  }
21
21
  const NO_BROWSER_NOTE = " This bot is set to work in the built-in browser, but the built-in browser is switched off in App Settings, so you have no browser and no computer this turn — say so instead of guessing.";
22
22
  const OFF_NOTE = " This bot's \"Works on\" setting is Off, so no computer and no built-in browser are mounted this turn: you cannot open a page, click, or type on any screen. If the user asks for something that needs one, tell them Works on is Off in this bot's settings — never claim you are opening a browser you do not have.";
23
- /** Decide what a turn mounts from the bot's destination, the task's pin
24
- * and what is actually reachable. Explicit choices are strict: Off mounts
25
- * neither surface, a computer destination keeps whatever browser the bot
26
- * has (the prompt tells the model which to use), a browser destination
27
- * mounts only the browser. Auto follows the task's pin while the pinned
28
- * place still exists, and otherwise re-resolves the way it always has. */
23
+ /** Decide what a turn mounts. One place per turn: a computer destination
24
+ * mounts only that computer (web work happens in its own browser), a browser
25
+ * destination mounts only the built-in browser, Off mounts nothing. A
26
+ * conversation pin — set by the person from the composer, or by the
27
+ * conversation's own first turn on Auto — wins over the bot's default, so a
28
+ * thread never changes place under someone; only Off overrides it. Auto
29
+ * without a pin leaves the computer choice to the dispatch, which then
30
+ * mounts the browser only when no computer was reached. A pin the turn
31
+ * cannot honour is retained and reported instead of being swapped silently. */
29
32
  export function resolveSurface(input) {
30
33
  const { destination, browserOn } = input;
31
34
  // Off is the whole answer: no computer and no browser. It used to withhold
@@ -38,25 +41,27 @@ export function resolveSurface(input) {
38
41
  if (destination === "off") {
39
42
  return { computer: "off", browser: false, pinned: null, clearPin: false, note: OFF_NOTE };
40
43
  }
44
+ const pin = input.pinnedSurface ?? null;
45
+ if (pin === "browser") {
46
+ if (browserOn)
47
+ return { computer: "off", browser: true, pinned: "browser", clearPin: false, note: "" };
48
+ // A missing browser is not permission to act on the host instead. Keep
49
+ // the chosen place so a retry or unrelated provider failure cannot move
50
+ // the task to a different signed-in computer.
51
+ return { computer: "off", browser: false, pinned: pin, clearPin: false,
52
+ note: " This conversation is pinned to the built-in browser, but its tools are unavailable this turn. No computer is mounted instead. Do not claim to have used it; a different place must be selected before acting there." };
53
+ }
54
+ if (pin)
55
+ return { computer: pin, browser: false, pinned: pin, clearPin: false, note: "" };
41
56
  if (destination === "browser") {
42
57
  return browserOn
43
58
  ? { computer: "off", browser: true, pinned: null, clearPin: false, note: "" }
44
59
  : { computer: "off", browser: false, pinned: null, clearPin: false, note: NO_BROWSER_NOTE };
45
60
  }
46
61
  if (destination !== undefined) {
47
- return { computer: destination, browser: browserOn, pinned: null, clearPin: false, note: "" };
48
- }
49
- const pin = input.pinnedSurface ?? null;
50
- if (pin === null) {
51
- return { computer: undefined, browser: browserOn, pinned: null, clearPin: false, note: "" };
52
- }
53
- const reachable = pin === "browser" ? browserOn : input.available?.[pin] === true;
54
- if (!reachable) {
55
- return { computer: undefined, browser: browserOn, pinned: null, clearPin: true, note: "" };
62
+ return { computer: destination, browser: false, pinned: null, clearPin: false, note: "" };
56
63
  }
57
- return pin === "browser"
58
- ? { computer: "off", browser: true, pinned: "browser", clearPin: false, note: "" }
59
- : { computer: pin, browser: false, pinned: pin, clearPin: false, note: "" };
64
+ return { computer: undefined, browser: browserOn, pinned: null, clearPin: false, note: "" };
60
65
  }
61
66
  /** How the prompt and the app name a surface. Deliberately the same words
62
67
  * the panel uses, so "tell them it is on the cloud computer" points at a
@@ -73,6 +78,10 @@ export function surfaceLabel(surface) {
73
78
  return "the built-in browser";
74
79
  }
75
80
  }
81
+ /** Said once per task, so the person hears the place before the first click
82
+ * lands there. Same words as the panel and the composer chip. */
83
+ const RESTATE_SENTENCE = " Before your first action on a screen or page in a task, say in one short sentence where you are working, using that same name.";
84
+ const SURFACE_AUTHORITY = " For browser and computer tasks, use OpenMausBot's mounted browser/computer tools first: inspect the target, perform the action, and verify its result before claiming success. Discover deferred tools by their server/name when needed. Do not substitute the provider's own desktop, a shell-launched browser, or another automation path for the selected OpenMausBot surface. Ordinary code and file tasks may still use their normal tools. Announcing an action is not performing it. A request naming another place does not move these tools: this computer is the user's host, Local VM is an isolated desktop, the cloud computer is remote, and the built-in browser is a separate browser. If the requested place differs from the mounted one, explain the mismatch and ask the user to change the conversation's computer selector; never act on a different computer or describe a host window as a VM.";
76
85
  /** The one paragraph that says where this turn's work happens. Assembled
77
86
  * from what was actually mounted, never from the setting, so the model is
78
87
  * only ever told about tools it can call. */
@@ -91,9 +100,22 @@ export function surfacePrompt(mounted, opts = {}) {
91
100
  text =
92
101
  " Everything you do on screen happens in the built-in browser tab; there is no desktop, file or shell computer this turn. If you need the user to sign in, tell them it is in the Browser tab of the Computer panel.";
93
102
  }
103
+ if (text)
104
+ text += RESTATE_SENTENCE + (opts.canSelect
105
+ ? SURFACE_AUTHORITY.replace("explain the mismatch and ask the user to change the conversation's computer selector", "inspect connected choices with select_computer and select the requested available place; on a pending result end this turn so OpenMausBot can reconnect the correct tools and continue the original request")
106
+ : SURFACE_AUTHORITY);
107
+ else if (!opts.note && !opts.canSelect) {
108
+ text = " No computer or built-in browser tools are mounted this turn. You cannot open apps, click, or inspect a screen through OpenMausBot. If asked for screen work, explain this and ask the user to choose and connect a computer in the Computer panel; do not claim to have opened or checked it.";
109
+ }
94
110
  if (opts.pinned) {
95
- text += ` This task has been running on ${surfaceLabel(opts.pinned)}; keep using it unless the user says otherwise.`;
111
+ text += ` This conversation is pinned to ${surfaceLabel(opts.pinned)}; changing places requires ${opts.canSelect ? "select_computer or " : ""}the conversation's computer selector, not a different tool name.`;
96
112
  }
113
+ if (opts.canSelect)
114
+ text += " For a screen task, use select_computer with no arguments when you need to inspect the actual available targets. Choose the requested place from its result; if the user left the place open, use a suitable available target or surface auto instead of asking them to operate the menu. Browser-only work can stay in Browser; when it needs desktop apps or capabilities the current Browser lacks, select an available Local VM without asking the user to switch it manually. Choose before taking actions, and do not repeat actions already completed if the task must continue elsewhere. OpenMausBot can start an existing configured computer and highlight the selected target. If the right tools are already mounted, use them directly. On a pending switch, end this turn: the original request resumes automatically with the new tools, and then you must carry out the task. Only ask for input for a genuine blocker, such as missing setup, required sign-in or an approval. Never silently replace an explicitly requested VM with the host desktop.";
115
+ if (opts.canSelect)
116
+ text += " If no suitable computer is running but its provider is configured, select_computer can provision one for this computer task; reuse existing resources first. Do not provision merely for ordinary chat or inspection.";
117
+ if (opts.canSelect && !computer && !mounted.browser)
118
+ text += " No computer or browser tools are mounted yet; select_computer is the way to connect them before screen work, not a reason to claim you already performed it.";
97
119
  return text + (opts.note ?? "");
98
120
  }
99
121
  /** Tool names that touch a screen, for engines that report bare names.
@@ -194,7 +194,7 @@ export class TeamSetupRequestService {
194
194
  const chief = this.chief(request.botId);
195
195
  const lines = [`Why: ${request.reason}`];
196
196
  if (request.deletion)
197
- lines.push(`Delete @${request.deletion.name} (${request.deletion.botId}).`, "Permanently removes this bot, all its conversations, memory, instructions, and skills. Generated project files remain. Active work or owned computers can block deletion.");
197
+ lines.push(`Delete @${request.deletion.name} (${request.deletion.botId}).`, "Permanently removes this bot, all its conversations, memory, instructions, skills, and any computer owned only by it. Generated project files and shared team computers remain. Active work or an unavailable provider can block deletion safely.");
198
198
  if (request.newTeams.length)
199
199
  lines.push(`Create teams: ${request.newTeams.map((name) => JSON.stringify(name)).join(", ")}.`, `Authorize @${chief.name} to coordinate and propose setup changes in these new teams.`);
200
200
  for (const operation of request.operations) {
@@ -0,0 +1,104 @@
1
+ // Per-thread log rotation — the size cap for the two NDJSON tees each
2
+ // thread grows: events/<threadId>.ndjson (the bus's canonical stream,
3
+ // server/harness/bus.ts) and native/<threadId>.ndjson (the provider-native
4
+ // tee, server/drivers/native.ts). Retention sweeps only reach threads that
5
+ // closed or archived; a thread that stays open grows both files without
6
+ // bound (#1280), and a busy thread can stay open for weeks.
7
+ //
8
+ // When a capped file outgrows its limit it is rewritten in place, keeping
9
+ // the newest half of its bytes and cutting only on a line boundary — the
10
+ // same recent tail readThreadEvents already serves. One filename per
11
+ // thread and log, before and after: every reader and every cleanup path
12
+ // (thread, bot and group delete; the retention sweep) already targets that
13
+ // one name, so rotation can never orphan a fragment. What rotates away is
14
+ // gone; the cap is opt-in and off by default.
15
+ import { closeSync, fstatSync, openSync, readSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
16
+ let capProvider = () => null;
17
+ /** Bound once at server boot: config.json is read per process (a change
18
+ * restarts the server, like every other hand-edited knob), so a binding
19
+ * made at startup stays current for the life of the process. */
20
+ export function bindThreadLogCapProvider(provider) {
21
+ capProvider = provider;
22
+ }
23
+ export function currentThreadLogCap() {
24
+ try {
25
+ return capProvider();
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Check a freshly appended log against the configured cap. Best effort and
32
+ * never throwing: a rotation that cannot complete leaves the file exactly
33
+ * as it was, and logging must never break the run it is observing. */
34
+ export function capThreadLog(file, maxBytes) {
35
+ if (typeof maxBytes !== "number" || !Number.isFinite(maxBytes) || maxBytes <= 0)
36
+ return;
37
+ let size;
38
+ try {
39
+ size = statSync(file).size;
40
+ }
41
+ catch {
42
+ return;
43
+ }
44
+ if (size <= maxBytes)
45
+ return;
46
+ trimToTail(file, Math.max(1, Math.floor(maxBytes / 2)));
47
+ }
48
+ function trimToTail(file, keepBytes) {
49
+ const staging = `${file}.trim`;
50
+ try {
51
+ const source = openSync(file, "r");
52
+ try {
53
+ const size = fstatSync(source).size;
54
+ const start = Math.max(0, size - keepBytes);
55
+ const window = Buffer.allocUnsafe(size - start);
56
+ // readSync may hand back fewer bytes than asked for, so fill the
57
+ // window in a loop. A zero count is an early EOF — the file shrank
58
+ // under us — and the original is left untouched.
59
+ let filled = 0;
60
+ while (filled < window.length) {
61
+ const count = readSync(source, window, filled, window.length - filled, start + filled);
62
+ if (count === 0)
63
+ return;
64
+ filled += count;
65
+ }
66
+ // The window starts mid-line unless a record happened to end exactly
67
+ // at `start`; drop everything up to the first newline so the rewritten
68
+ // file opens on a record boundary. A window without a usable newline
69
+ // is a single record bigger than the keep window — leave the file
70
+ // alone rather than corrupt the only copy.
71
+ const firstNewline = window.indexOf(0x0a);
72
+ if (firstNewline === -1 || firstNewline + 1 >= window.length)
73
+ return;
74
+ const target = openSync(staging, "w", 0o600);
75
+ try {
76
+ // writeSync can also stop short of the requested length; keep
77
+ // going until every remaining byte lands, and treat zero progress
78
+ // as a failure so the outer catch clears the staging file.
79
+ let written = firstNewline + 1;
80
+ while (written < window.length) {
81
+ const count = writeSync(target, window, written, window.length - written);
82
+ if (count === 0)
83
+ throw new Error(`no progress writing ${staging}`);
84
+ written += count;
85
+ }
86
+ }
87
+ finally {
88
+ closeSync(target);
89
+ }
90
+ }
91
+ finally {
92
+ closeSync(source);
93
+ }
94
+ renameSync(staging, file);
95
+ }
96
+ catch {
97
+ try {
98
+ rmSync(staging, { force: true });
99
+ }
100
+ catch {
101
+ /* an inert staging file is the worst case */
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,155 @@
1
+ // Fish Audio text to speech. The API key stays on the harness: the renderer
2
+ // receives only voice metadata and synthesized audio bytes.
3
+ const API = (process.env.OMB_FISH_AUDIO_API || "https://api.fish.audio").replace(/\/+$/, "");
4
+ const MODEL = "s2.1-pro";
5
+ const MAX_ERROR_LENGTH = 240;
6
+ const PAGE_SIZE = 100;
7
+ const MAX_OWNED_PAGES = 100;
8
+ async function safeBody(res) {
9
+ try {
10
+ const text = await res.text();
11
+ if (!text)
12
+ return null;
13
+ try {
14
+ return JSON.parse(text);
15
+ }
16
+ catch {
17
+ return { message: text };
18
+ }
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function serviceDetail(body) {
25
+ if (!body || typeof body !== "object")
26
+ return "";
27
+ const record = body;
28
+ const detail = record.detail;
29
+ const message = (typeof detail === "string" && detail) ||
30
+ (detail && typeof detail === "object" && typeof detail.message === "string"
31
+ ? detail.message
32
+ : "") ||
33
+ (typeof record.message === "string" && record.message) ||
34
+ (typeof record.error === "string" && record.error) ||
35
+ "";
36
+ return message.trim().slice(0, MAX_ERROR_LENGTH);
37
+ }
38
+ function errorMessage(status, action, body) {
39
+ const detail = serviceDetail(body);
40
+ if (status === 401 || status === 403) {
41
+ return "Fish Audio rejected that API key, or the key cannot access voice models.";
42
+ }
43
+ if (status === 402)
44
+ return detail || "Fish Audio says this account has insufficient balance.";
45
+ if (status === 429)
46
+ return detail || "Fish Audio is rate-limiting this account — wait a moment and try again.";
47
+ return detail ? `${action} failed: ${detail}` : `${action} failed (${status})`;
48
+ }
49
+ /** Verify the exact permission the voice picker needs. */
50
+ export async function verifyKey(key) {
51
+ try {
52
+ // The public catalog is intentionally readable without authentication,
53
+ // so it cannot prove a supplied key. `self=true` crosses the account
54
+ // boundary and returns 401 for an absent or invalid Bearer token.
55
+ const res = await fetch(`${API}/model?page_size=1&self=true`, {
56
+ headers: { authorization: `Bearer ${key}` },
57
+ signal: AbortSignal.timeout(20_000),
58
+ });
59
+ if (res.ok)
60
+ return { ok: true };
61
+ return { ok: false, message: errorMessage(res.status, "checking that key", await safeBody(res)) };
62
+ }
63
+ catch {
64
+ return { ok: false, message: "Couldn't reach Fish Audio to check that key — check your connection." };
65
+ }
66
+ }
67
+ async function modelPage(key, query) {
68
+ let response;
69
+ try {
70
+ response = await fetch(`${API}/model?${query}`, {
71
+ headers: { authorization: `Bearer ${key}` },
72
+ signal: AbortSignal.timeout(20_000),
73
+ });
74
+ }
75
+ catch {
76
+ throw new Error("Couldn't reach Fish Audio to list voices — check your connection.");
77
+ }
78
+ const body = await safeBody(response);
79
+ if (!response.ok)
80
+ throw new Error(errorMessage(response.status, "listing voices", body));
81
+ if (!body || typeof body !== "object")
82
+ return { items: [], hasMore: false };
83
+ const page = body;
84
+ return {
85
+ items: Array.isArray(page.items) ? page.items : [],
86
+ hasMore: page.has_more === true,
87
+ };
88
+ }
89
+ export async function listVoices(key) {
90
+ // Fish defaults to ten results and excludes private account voices from
91
+ // the public catalog. Load the popular public page and every page belonging
92
+ // to this account, so a user's 101st custom voice remains selectable.
93
+ const [publicPage, owned] = await Promise.all([
94
+ modelPage(key, `page_size=${PAGE_SIZE}&sort_by=task_count`),
95
+ (async () => {
96
+ const items = [];
97
+ for (let pageNumber = 1; pageNumber <= MAX_OWNED_PAGES; pageNumber += 1) {
98
+ const page = await modelPage(key, `page_size=${PAGE_SIZE}&self=true&sort_by=created_at&page_number=${pageNumber}`);
99
+ items.push(...page.items);
100
+ if (!page.hasMore)
101
+ return items;
102
+ }
103
+ throw new Error("Fish Audio returned too many voice pages. Narrow the account's voice library and try again.");
104
+ })(),
105
+ ]);
106
+ const items = [...publicPage.items, ...owned];
107
+ const voices = items.flatMap((item) => {
108
+ if (!item || typeof item !== "object")
109
+ return [];
110
+ const model = item;
111
+ // `/model` includes SVC models and account models that are still
112
+ // training or have failed. Only trained TTS models are valid
113
+ // `reference_id` values for `/v1/tts`.
114
+ if (model.type !== "tts" || model.state !== "trained")
115
+ return [];
116
+ const id = typeof model._id === "string" ? model._id.trim() : "";
117
+ if (!id)
118
+ return [];
119
+ const label = typeof model.title === "string" && model.title.trim() ? model.title.trim() : "Voice";
120
+ const description = typeof model.description === "string" && model.description.trim()
121
+ ? model.description.trim().slice(0, MAX_ERROR_LENGTH)
122
+ : undefined;
123
+ return [{ id, label, description }];
124
+ });
125
+ return [...new Map(voices.map((voice) => [voice.id, voice])).values()];
126
+ }
127
+ export async function synthesize(text, voiceId, key) {
128
+ let res;
129
+ try {
130
+ res = await fetch(`${API}/v1/tts`, {
131
+ method: "POST",
132
+ headers: {
133
+ authorization: `Bearer ${key}`,
134
+ "content-type": "application/json",
135
+ accept: "audio/mpeg",
136
+ model: MODEL,
137
+ },
138
+ body: JSON.stringify({
139
+ text,
140
+ reference_id: voiceId,
141
+ format: "mp3",
142
+ sample_rate: 44_100,
143
+ mp3_bitrate: 64,
144
+ latency: "normal",
145
+ }),
146
+ signal: AbortSignal.timeout(60_000),
147
+ });
148
+ }
149
+ catch {
150
+ throw new Error("Couldn't reach Fish Audio to generate speech — check your connection.");
151
+ }
152
+ if (!res.ok)
153
+ throw new Error(errorMessage(res.status, "speaking", await safeBody(res)));
154
+ return { bytes: new Uint8Array(await res.arrayBuffer()), mime: "audio/mpeg" };
155
+ }