openmausbot 0.1.74 → 0.1.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/assets/index-BtT047dk.css +1 -0
  2. package/dist/assets/{index-CwoQdM0z.js → index-C6blGSq5.js} +1 -1
  3. package/dist/assets/index-fKXFpB_J.js +305 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -2
  6. package/dist-server/drivers/agents-proxy.js +133 -11
  7. package/dist-server/index.js +3379 -1431
  8. package/dist-server/mcp-gate.js +28 -18
  9. package/dist-server/openmausbot.js +557 -325
  10. package/dist-server/pair-cli.js +557 -325
  11. package/dist-server/permission-proxy.js +119 -14
  12. package/dist-server/server/agent-tool-policy.js +1 -0
  13. package/dist-server/server/atomic.js +37 -1
  14. package/dist-server/server/auto-approve.js +15 -0
  15. package/dist-server/server/bot-overview.js +5 -1
  16. package/dist-server/server/chief-of-staff.js +17 -12
  17. package/dist-server/server/delegations.js +177 -63
  18. package/dist-server/server/drivers/acp/core.js +2 -1
  19. package/dist-server/server/drivers/acp/grok.js +12 -1
  20. package/dist-server/server/drivers/acp/hermes.js +21 -3
  21. package/dist-server/server/drivers/agents-proxy.js +113 -10
  22. package/dist-server/server/drivers/claude.js +91 -17
  23. package/dist-server/server/drivers/codex.js +15 -4
  24. package/dist-server/server/drivers/openai-chat.js +90 -67
  25. package/dist-server/server/drivers/openai-compat.js +9 -1
  26. package/dist-server/server/drivers/pi.js +18 -3
  27. package/dist-server/server/drivers/retry.js +4 -0
  28. package/dist-server/server/env-path.js +18 -17
  29. package/dist-server/server/index.js +1172 -302
  30. package/dist-server/server/message-db.js +21 -0
  31. package/dist-server/server/peer-roster.js +48 -9
  32. package/dist-server/server/permission-proxy.js +90 -15
  33. package/dist-server/server/procs.js +19 -3
  34. package/dist-server/server/room-handoffs.js +237 -0
  35. package/dist-server/server/section-context.js +64 -14
  36. package/dist-server/server/setup-mode.js +6 -7
  37. package/dist-server/server/store.js +266 -8
  38. package/dist-server/server/surface.js +16 -5
  39. package/dist-server/server/team-backup.js +17 -1
  40. package/dist-server/server/team-setup-requests.js +252 -0
  41. package/dist-server/server/thread-events.js +10 -1
  42. package/dist-server/server/turn-dispatch-guard.js +31 -3
  43. package/dist-server/server/workspace.js +13 -0
  44. package/dist-server/shared/approval-mode.js +7 -0
  45. package/dist-server/shared/ask-question.js +172 -0
  46. package/dist-server/shared/provider-safety.js +6 -0
  47. package/dist-server/shared/team-backup.js +1 -0
  48. package/dist-server/shared/team-setup.js +1 -0
  49. package/package.json +1 -1
  50. package/dist/assets/index-BRLHsQ5g.js +0 -298
  51. package/dist/assets/index-ByLQ7a3F.css +0 -1
@@ -22,81 +22,104 @@ export function createOpenAIChatRuntime(options) {
22
22
  createdAt: new Date().toISOString(),
23
23
  });
24
24
  const complete = async (messages, model, stream, signal, onDelta) => {
25
- const timeout = AbortSignal.timeout(options.timeoutMs);
26
- const response = await fetch(`${options.apiUrl}/chat/completions`, {
27
- method: "POST",
28
- headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
29
- body: JSON.stringify(options.requestBody(model, messages, stream)),
30
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
31
- });
32
- if (!response.ok) {
33
- const body = await response.text().catch(() => "");
34
- throw new Error(`${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
35
- }
36
- if (!stream) {
37
- const json = await response.json();
38
- const message = json.choices?.[0]?.message;
39
- return {
40
- text: typeof message?.content === "string" ? message.content : "",
41
- reasoning: options.reasoning && typeof message?.reasoning_content === "string"
42
- ? message.reasoning_content
43
- : "",
44
- usage: usageFrom(json.usage),
45
- };
46
- }
47
- if (!response.body) {
48
- throw new Error(options.noBodyError ?? `${options.httpErrorLabel} returned no response body`);
49
- }
50
- let text = "";
51
- let reasoning = "";
52
- let usage = null;
53
- const reader = response.body.getReader();
54
- const decoder = new TextDecoder();
55
- let buffer = "";
25
+ // Idle timer that is renewed on every received chunk during streaming
26
+ const timeoutController = new AbortController();
27
+ let idleTimer = null;
28
+ const resetIdleTimer = () => {
29
+ if (idleTimer)
30
+ clearTimeout(idleTimer);
31
+ idleTimer = setTimeout(() => {
32
+ timeoutController.abort(new DOMException("Streaming idle timeout elapsed", "AbortError"));
33
+ }, options.timeoutMs);
34
+ };
35
+ resetIdleTimer();
56
36
  try {
57
- readLoop: for (;;) {
58
- const { done, value } = await reader.read();
59
- if (done)
60
- break;
61
- buffer += decoder.decode(value, { stream: true });
62
- let newline;
63
- while ((newline = buffer.indexOf("\n")) !== -1) {
64
- const line = buffer.slice(0, newline).trim();
65
- buffer = buffer.slice(newline + 1);
66
- if (!line.startsWith("data:"))
67
- continue;
68
- const data = line.slice(5).trim();
69
- if (data === "[DONE]")
70
- break readLoop;
71
- let chunk;
72
- try {
73
- chunk = JSON.parse(data);
74
- }
75
- catch {
76
- continue;
77
- }
78
- const delta = chunk.choices?.[0]?.delta;
79
- const reasoningDelta = options.reasoning && typeof delta?.reasoning_content === "string"
80
- ? delta.reasoning_content
81
- : "";
82
- const contentDelta = typeof delta?.content === "string" ? delta.content : "";
83
- if (reasoningDelta) {
84
- reasoning += reasoningDelta;
85
- onDelta?.(reasoningDelta, "reasoning_text");
37
+ const activeSignal = signal
38
+ ? AbortSignal.any([signal, timeoutController.signal])
39
+ : timeoutController.signal;
40
+ const response = await fetch(`${options.apiUrl}/chat/completions`, {
41
+ method: "POST",
42
+ headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
43
+ body: JSON.stringify(options.requestBody(model, messages, stream)),
44
+ signal: activeSignal,
45
+ });
46
+ if (!response.ok) {
47
+ const body = await response.text().catch(() => "");
48
+ throw new Error(`${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
49
+ }
50
+ if (!stream) {
51
+ const json = await response.json();
52
+ const message = json.choices?.[0]?.message;
53
+ return {
54
+ text: typeof message?.content === "string" ? message.content : "",
55
+ reasoning: options.reasoning && typeof message?.reasoning_content === "string"
56
+ ? message.reasoning_content
57
+ : "",
58
+ usage: usageFrom(json.usage),
59
+ };
60
+ }
61
+ if (!response.body) {
62
+ throw new Error(options.noBodyError ?? `${options.httpErrorLabel} returned no response body`);
63
+ }
64
+ let text = "";
65
+ let reasoning = "";
66
+ let usage = null;
67
+ const reader = response.body.getReader();
68
+ const decoder = new TextDecoder();
69
+ let buffer = "";
70
+ try {
71
+ readLoop: for (;;) {
72
+ const { done, value } = await reader.read();
73
+ if (done) {
74
+ if (buffer.trim() === "data: [DONE]")
75
+ break;
76
+ throw new DOMException("Stream ended before completion", "AbortError");
86
77
  }
87
- if (contentDelta) {
88
- text += contentDelta;
89
- onDelta?.(contentDelta, "assistant_text");
78
+ resetIdleTimer();
79
+ buffer += decoder.decode(value, { stream: true });
80
+ let newline;
81
+ while ((newline = buffer.indexOf("\n")) !== -1) {
82
+ const line = buffer.slice(0, newline).trim();
83
+ buffer = buffer.slice(newline + 1);
84
+ if (!line.startsWith("data:"))
85
+ continue;
86
+ const data = line.slice(5).trim();
87
+ if (data === "[DONE]")
88
+ break readLoop;
89
+ let chunk;
90
+ try {
91
+ chunk = JSON.parse(data);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ const delta = chunk.choices?.[0]?.delta;
97
+ const reasoningDelta = options.reasoning && typeof delta?.reasoning_content === "string"
98
+ ? delta.reasoning_content
99
+ : "";
100
+ const contentDelta = typeof delta?.content === "string" ? delta.content : "";
101
+ if (reasoningDelta) {
102
+ reasoning += reasoningDelta;
103
+ onDelta?.(reasoningDelta, "reasoning_text");
104
+ }
105
+ if (contentDelta) {
106
+ text += contentDelta;
107
+ onDelta?.(contentDelta, "assistant_text");
108
+ }
109
+ if (chunk.usage)
110
+ usage = usageFrom(chunk.usage);
90
111
  }
91
- if (chunk.usage)
92
- usage = usageFrom(chunk.usage);
93
112
  }
94
113
  }
114
+ finally {
115
+ await reader.cancel().catch(() => { });
116
+ }
117
+ return { text, reasoning, usage };
95
118
  }
96
119
  finally {
97
- await reader.cancel().catch(() => { });
120
+ if (idleTimer)
121
+ clearTimeout(idleTimer);
98
122
  }
99
- return { text, reasoning, usage };
100
123
  };
101
124
  const messagesFor = (turn) => [
102
125
  ...(turn.system ? [{ role: "system", content: turn.system }] : []),
@@ -1,5 +1,13 @@
1
1
  import { createOpenAIChatRuntime } from "./openai-chat.js";
2
2
  const DRIVER_KIND = "openai-compat";
3
+ const DEFAULT_IDLE_TIMEOUT_MS = 180_000;
4
+ const idleTimeoutMs = () => {
5
+ const raw = process.env.OPENMAUS_OPENAI_COMPAT_IDLE_TIMEOUT_MS;
6
+ if (!raw)
7
+ return DEFAULT_IDLE_TIMEOUT_MS;
8
+ const value = Number(raw);
9
+ return Number.isSafeInteger(value) && value >= 1_000 && value <= 2_147_483_647 ? value : DEFAULT_IDLE_TIMEOUT_MS;
10
+ };
3
11
  const DEFAULT_MODELS = {
4
12
  default: "meta-llama/llama-3.3-70b-instruct",
5
13
  options: [
@@ -127,7 +135,7 @@ export const OpenAICompatDriver = {
127
135
  httpErrorLabel: "upstream",
128
136
  missingKeyError: `no API key — set ${config.apiKeyEnv} or add it to the instance config`,
129
137
  unavailableReason: `no API key — set ${config.apiKeyEnv} or add it to the instance config`,
130
- timeoutMs: 120_000,
138
+ timeoutMs: idleTimeoutMs(),
131
139
  reasoning: true,
132
140
  billing: "metered",
133
141
  includeUsageInCompleted: true,
@@ -26,13 +26,14 @@ import { join } from "node:path";
26
26
  import { PROVIDER_CREDENTIAL_ENV, stripWorkspaceCredentialEnv } from "../config.js";
27
27
  import { computerProxyEnv } from "../container-computer.js";
28
28
  import { augmentedPath } from "../env-path.js";
29
- import { describeSpawnFailure, killCliTree, spawnCli } from "../procs.js";
29
+ import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
30
30
  import { SPAWNED_PROXIES } from "../proxy-paths.js";
31
31
  import { EFFORT_LEVELS, newEventId, newId } from "../contracts.js";
32
32
  import { decodeInjectId, encodeInjectId, hostApiKey, localHost, mergeLocalInject, } from "./local-inject.js";
33
33
  import { appendNative } from "./native.js";
34
34
  const DRIVER_KIND = "piAgent";
35
35
  const PI_ARGS = ["--mode", "rpc", "--no-session"];
36
+ const PI_MODEL_UPDATE_ARGS = ["update", "--models", "--no-approve"];
36
37
  const NODE_ENV_FLAG = { ELECTRON_RUN_AS_NODE: "1" };
37
38
  function readPiPromptImages(turn) {
38
39
  return (turn.images ?? []).map((image) => ({
@@ -317,6 +318,14 @@ export async function fetchPiModels(cli, env) {
317
318
  }
318
319
  });
319
320
  }
321
+ /** Refresh pi's provider-owned catalog cache. This is deliberately called
322
+ * only by the explicit model-picker refresh action, never during app startup.
323
+ * Failure is non-fatal: the caller still probes the last usable cache. */
324
+ export async function updatePiModelCatalog(cli, env) {
325
+ return new Promise((resolve) => {
326
+ execCli(cli, PI_MODEL_UPDATE_ARGS, { env, timeout: 60_000, maxBuffer: 1024 * 1024 }, (error) => resolve(!error));
327
+ });
328
+ }
320
329
  function decodeConfig(raw) {
321
330
  if (raw === null || raw === undefined)
322
331
  return { cli: "pi", fullAuto: false };
@@ -364,7 +373,7 @@ export const PiDriver = {
364
373
  const { instanceId, config } = input;
365
374
  const catalogEnv = piEnvironment({ ...process.env, ...input.environment });
366
375
  let models = EMPTY;
367
- const refreshModels = async () => {
376
+ const readModels = async () => {
368
377
  let base = models;
369
378
  try {
370
379
  const resolved = await fetchPiModels(config.cli, catalogEnv);
@@ -384,7 +393,13 @@ export const PiDriver = {
384
393
  models = base;
385
394
  }
386
395
  };
387
- await refreshModels();
396
+ const refreshModels = async () => {
397
+ await updatePiModelCatalog(config.cli, catalogEnv);
398
+ await readModels();
399
+ };
400
+ // Startup stays local and fast. Only the explicit Refresh button crosses
401
+ // pi's model-catalog network boundary.
402
+ await readModels();
388
403
  const listeners = new Set();
389
404
  // one active turn per thread
390
405
  const active = new Map();
@@ -1,3 +1,4 @@
1
+ import { isProviderSafetyBlock } from "../../shared/provider-safety.js";
1
2
  export const RETRY_MAX_ATTEMPTS = 3;
2
3
  /** Backoff schedule before attempt N (N is 1-based over retries): 1s / 3s / 8s. */
3
4
  export const BACKOFF_BASE_MS = [1_000, 3_000, 8_000];
@@ -42,6 +43,9 @@ const messageOf = (err) => {
42
43
  */
43
44
  export function classifyError(err) {
44
45
  const text = messageOf(err);
46
+ // A surrounding HTTP 5xx/429 must not replay a provider safety block.
47
+ if (isProviderSafetyBlock(text))
48
+ return { transient: false, reason: "provider_safety" };
45
49
  if (err && "exitCode" in err) {
46
50
  const { exitCode: code } = err;
47
51
  if (code !== null && code < 0)
@@ -207,8 +207,8 @@ function isFile(p) {
207
207
  }
208
208
  }
209
209
  /** PATHEXT-aware `which`. A path-ish cli is probed where it points. */
210
- function whichWin(cli) {
211
- const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
210
+ function whichWin(cli, env) {
211
+ const exts = ((env ?? process.env).PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
212
212
  // an extensionless name is not runnable on Windows, so PATHEXT wins over
213
213
  // the bare file — npm installs both `claude` (a sh script) and `claude.cmd`
214
214
  const probe = (base) => {
@@ -217,7 +217,8 @@ function whichWin(cli) {
217
217
  };
218
218
  if (/[\\/]/.test(cli) || /^[a-zA-Z]:/.test(cli))
219
219
  return probe(cli);
220
- for (const dir of augmentedPath().split(delimiter)) {
220
+ const path = env ? (env.PATH ?? env.Path ?? "") : augmentedPath();
221
+ for (const dir of path.split(delimiter)) {
221
222
  if (!dir)
222
223
  continue;
223
224
  const hit = probe(join(dir, cli));
@@ -229,20 +230,20 @@ function whichWin(cli) {
229
230
  /** node.exe to run a script with: the one npm's shim would pick, else PATH,
230
231
  * else this executable only when it really is Node. In a packaged app,
231
232
  * process.execPath is Electron and must never be mistaken for node.exe. */
232
- function nodeExe(near) {
233
+ function nodeExe(near, env) {
233
234
  const local = join(near, "node.exe");
234
235
  if (isFile(local))
235
236
  return local;
236
237
  // Ask for the executable explicitly so a custom PATHEXT ordering cannot
237
238
  // make a stray node.cmd hide the real node.exe beside it.
238
- const onPath = whichWin("node.exe");
239
+ const onPath = whichWin("node.exe", env);
239
240
  if (onPath && extname(onPath).toLowerCase() === ".exe")
240
241
  return onPath;
241
242
  return process.versions.electron ? null : process.execPath;
242
243
  }
243
244
  /** npm/pnpm .cmd shims all spell their target as "%dp0%\..." (or
244
245
  * "%~dp0\..."). Whatever of those exists on disk is what the shim runs. */
245
- function parseCmdShim(shim) {
246
+ function parseCmdShim(shim, env) {
246
247
  let text;
247
248
  try {
248
249
  text = readFileSync(shim, "utf8");
@@ -256,7 +257,7 @@ function parseCmdShim(shim) {
256
257
  .filter((p) => isFile(p) && basename(p).toLowerCase() !== "node.exe");
257
258
  const script = targets.find((p) => /\.[cm]?js$/i.test(p));
258
259
  if (script) {
259
- const node = nodeExe(dir);
260
+ const node = nodeExe(dir, env);
260
261
  if (node)
261
262
  return { command: node, args: [script] };
262
263
  }
@@ -265,7 +266,7 @@ function parseCmdShim(shim) {
265
266
  }
266
267
  /** `#!/usr/bin/env node` → `node <script>`. Only node: nothing else has a
267
268
  * meaningful Windows equivalent worth guessing at. */
268
- function parseNodeShebang(file) {
269
+ function parseNodeShebang(file, env) {
269
270
  let head = "";
270
271
  let fd = null;
271
272
  try {
@@ -289,7 +290,7 @@ function parseNodeShebang(file) {
289
290
  }
290
291
  if (!/^#!.*\bnode(\.exe)?\b/.test(head))
291
292
  return null;
292
- const node = nodeExe(dirname(file));
293
+ const node = nodeExe(dirname(file), env);
293
294
  return node ? { command: node, args: [file] } : null;
294
295
  }
295
296
  /**
@@ -297,31 +298,31 @@ function parseNodeShebang(file) {
297
298
  * everywhere but win32 — POSIX already resolves PATH and #! itself.
298
299
  */
299
300
  /** Resolve a single command word (no tokenizer) — the platform spawn rules. */
300
- function resolveWord(cli, args) {
301
+ function resolveWord(cli, args, env) {
301
302
  if (process.platform !== "win32")
302
303
  return { command: cli, args };
303
- const file = whichWin(cli);
304
+ const file = whichWin(cli, env);
304
305
  // not found: hand back the name so spawn reports its own ENOENT
305
306
  if (!file)
306
307
  return { command: cli, args };
307
308
  const ext = extname(file).toLowerCase();
308
309
  if (ext === ".cmd" || ext === ".bat") {
309
- const direct = parseCmdShim(file);
310
+ const direct = parseCmdShim(file, env);
310
311
  return direct ? { command: direct.command, args: [...direct.args, ...args] } : { command: file, args };
311
312
  }
312
313
  if (ext === ".exe" || ext === ".com")
313
314
  return { command: file, args };
314
- const viaNode = parseNodeShebang(file);
315
+ const viaNode = parseNodeShebang(file, env);
315
316
  return viaNode ? { command: viaNode.command, args: [...viaNode.args, ...args] } : { command: file, args };
316
317
  }
317
- export function resolveCliSpawn(cli, args) {
318
+ export function resolveCliSpawn(cli, args, env) {
318
319
  // An EXISTING FILE wins over the tokenizer: paths with spaces come from
319
320
  // our own candidate list unquoted ("/Applications/My Tools/claude"), and
320
321
  // splitting those would shred them. Only a bare word (no spaces) or an
321
322
  // explicitly-quoted/wrapper string reaches the split below.
322
323
  const trimmed = cli.trim();
323
324
  if (trimmed.includes(" ") && existsSync(trimmed))
324
- return resolveWord(trimmed, args);
325
+ return resolveWord(trimmed, args, env);
325
326
  // A `cli` value may carry fixed leading arguments — wrapper scripts like
326
327
  // `/usr/local/bin/ag claude agp` are one string in the Engines panel. ONE
327
328
  // tokenizer pass, then resolve the head directly (never re-tokenized: a
@@ -335,7 +336,7 @@ export function resolveCliSpawn(cli, args) {
335
336
  // empty input → hand the raw string to spawn so IT reports the ENOENT
336
337
  if (!head)
337
338
  return { command: cli, args };
338
- return resolveWord(head, [...fixed, ...args]);
339
+ return resolveWord(head, [...fixed, ...args], env);
339
340
  }
340
- return resolveWord(cli, args);
341
+ return resolveWord(cli, args, env);
341
342
  }