openmausbot 0.1.75 → 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 (39) hide show
  1. package/dist/assets/index-BtT047dk.css +1 -0
  2. package/dist/assets/{index-BTlyRwLl.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 +2602 -869
  8. package/dist-server/mcp-gate.js +28 -18
  9. package/dist-server/openmausbot.js +202 -120
  10. package/dist-server/pair-cli.js +202 -120
  11. package/dist-server/server/agent-tool-policy.js +1 -0
  12. package/dist-server/server/atomic.js +37 -1
  13. package/dist-server/server/bot-overview.js +5 -1
  14. package/dist-server/server/chief-of-staff.js +17 -12
  15. package/dist-server/server/delegations.js +177 -63
  16. package/dist-server/server/drivers/acp/hermes.js +21 -3
  17. package/dist-server/server/drivers/agents-proxy.js +113 -10
  18. package/dist-server/server/drivers/claude.js +31 -14
  19. package/dist-server/server/drivers/openai-chat.js +90 -67
  20. package/dist-server/server/drivers/openai-compat.js +9 -1
  21. package/dist-server/server/drivers/pi.js +18 -3
  22. package/dist-server/server/env-path.js +18 -17
  23. package/dist-server/server/index.js +1112 -312
  24. package/dist-server/server/message-db.js +21 -0
  25. package/dist-server/server/peer-roster.js +48 -9
  26. package/dist-server/server/procs.js +19 -3
  27. package/dist-server/server/room-handoffs.js +237 -0
  28. package/dist-server/server/section-context.js +64 -14
  29. package/dist-server/server/store.js +247 -8
  30. package/dist-server/server/surface.js +16 -5
  31. package/dist-server/server/team-backup.js +17 -1
  32. package/dist-server/server/team-setup-requests.js +252 -0
  33. package/dist-server/server/turn-dispatch-guard.js +31 -3
  34. package/dist-server/shared/approval-mode.js +7 -0
  35. package/dist-server/shared/team-backup.js +1 -0
  36. package/dist-server/shared/team-setup.js +1 -0
  37. package/package.json +1 -1
  38. package/dist/assets/index-CeMbvsgx.js +0 -305
  39. package/dist/assets/index-DH7Zax6L.css +0 -1
@@ -36,6 +36,7 @@
36
36
  import readline from "node:readline";
37
37
  import { CREDENTIAL_TARGETS, isCredentialTargetId } from "../../shared/credential-request.js";
38
38
  import { agentToolAnnotations } from "../agent-tool-policy.js";
39
+ import { peerName } from "../peer-roster.js";
39
40
  const HARNESS = process.env.OMB_HARNESS_URL ?? "http://127.0.0.1:8799";
40
41
  const BOT_ID = process.env.OMB_BOT_ID ?? "";
41
42
  const THREAD_ID = process.env.OMB_THREAD_ID ?? "";
@@ -342,14 +343,30 @@ const ROUTINE_FIELDS_SCHEMA = {
342
343
  },
343
344
  };
344
345
  const TOOLS = [
346
+ {
347
+ name: "list_room_targets",
348
+ description: "Discover actual OpenMausBot teammates and rooms in your allowed teams. Works in a normal bot conversation too; no room is required. Returns bot and room IDs, roles and working folders, never other conversations' history. Use these bots, not native coding helpers with similar names, when the user asks their team to work together.",
349
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
350
+ },
351
+ {
352
+ name: "coordinate_bots",
353
+ description: "Ask existing OpenMausBot teammates for advice or assign concrete work. From normal chat each assignment gets a separate recipient conversation; from a room it defaults to this room. Use group_id from list_room_targets for a specific room. Name 1-4 bot_ids: they receive only your brief and use their own model, tools and permissions. Busy bots queue. They can consult their specialists; all results return here and resume you automatically. Include exact file paths, constraints and what must be verified. After sending all assignments, END your turn; do not poll or wait. On return, resolve tradeoffs, verify the requested outcome and request concrete corrections if necessary before giving one final answer. Do not send acknowledgements as new work.",
354
+ inputSchema: { type: "object", additionalProperties: false, properties: {
355
+ group_id: { type: "string", description: "Optional destination room. Omit for this room, or separate recipient tasks when chatting directly." },
356
+ bot_ids: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 4, uniqueItems: true },
357
+ message: { type: "string", minLength: 1, maxLength: 4000, description: "Self-contained question or task for these teammates. Send separate requests when responsibilities differ." },
358
+ request_key: { type: "string", description: "A short unique assignment key. Reuse for an identical retry." },
359
+ rework: { type: "boolean", description: "True only for concrete additional work from someone who already completed a request." },
360
+ }, required: ["bot_ids", "message", "request_key"] },
361
+ },
345
362
  {
346
363
  name: "list_bots",
347
- description: "List the other bots (agents) in your OpenMausBot section, with their model and whether they're busy. Call this before delegate_bot or ask_bot to discover who's available. Use delegate_bot for assignments; use ask_bot only for a short consultation needed inline.",
364
+ description: "List the other bots (agents) you may contact in your own team and any additional teams the owner has explicitly allowed you to coordinate, with their team, model and current status. Call this to discover exact teammate IDs before assigning work or requesting advice through your available coordination tools.",
348
365
  inputSchema: { type: "object", properties: {} },
349
366
  },
350
367
  {
351
368
  name: "list_rooms",
352
- description: "List the shared rooms (team channels) you belong to, with the other members of each. Call this before post_to_room — it is the only place room ids come from. One-to-one bot channels are never listed (reach a single bot with ask_bot or delegate_bot). A room you are in but cannot post into — one containing someone outside your section — is named without an id, together with the reason, so you can tell the user why.",
369
+ description: "List the shared rooms (team channels) you belong to, with the other members of each. Call this before post_to_room. One-to-one bot channels are never listed; discover individual teammates with list_bots. A room you are in but cannot post into is named without an id, together with the reason, so you can tell the user why.",
353
370
  inputSchema: { type: "object", additionalProperties: false, properties: {} },
354
371
  },
355
372
  {
@@ -402,12 +419,12 @@ const TOOLS = [
402
419
  },
403
420
  {
404
421
  name: "list_threads",
405
- description: "See your own threads and the threads you opened on teammates, newest first: each with its bot, title, state (running, waiting on the person, queued, or idle), whether the person has unread there, and the delegation id if it was a handoff. Use it to check how the threads you started are going before reporting to the person; write a thread's title as #Title when you mention it. A teammate's other threads are never listed — only the ones you opened. This is a read: it starts nothing and changes nothing.",
422
+ description: "See your own threads and the threads you opened on teammates, newest first: each with its bot, title, state (running, waiting on the person, queued, idle, or closed), whether the person has unread there, and the delegation id if it was a handoff. Use it to check how the threads you started are going before reporting to the person; write a thread's title as #Title when you mention it. A teammate's other threads are never listed — only the ones you opened. This is a read: it starts nothing and changes nothing.",
406
423
  inputSchema: { type: "object", additionalProperties: false, properties: {} },
407
424
  },
408
425
  {
409
426
  name: "close_thread",
410
- description: "Mark a thread you opened (or one of your own) as finished once you have read its result: it goes idle in the person's sidebar with a note saying you closed it. Nothing is deleted — deleting stays the person's decision — and a thread that is still running cannot be closed; wait for it or leave it. Use the thread id from list_threads or from the start_thread result. If a close is refused, do not retry it.",
427
+ description: "Mark a thread you opened (or one of your own) as finished once you have read its result: it leaves the person's default sidebar list (still under all threads, with a note saying you closed it) and list_threads reports it as closed. Nothing is deleted — deleting stays the person's decision — and a thread that is still running cannot be closed; wait for it or leave it. Use the thread id from list_threads or from the start_thread result. If a close is refused, do not retry it.",
411
428
  inputSchema: {
412
429
  type: "object",
413
430
  additionalProperties: false,
@@ -456,6 +473,45 @@ const TOOLS = [
456
473
  required: ["name", "role", "instructions"],
457
474
  },
458
475
  },
476
+ {
477
+ name: "list_team_setup",
478
+ description: "Chief of Staff only: list authorized teams, teammate IDs, and exact engine/model choices for team setup. Call before proposing configuration; never invent model IDs. Existing thread models are independent of bot defaults.",
479
+ inputSchema: { type: "object", additionalProperties: false, properties: {} },
480
+ },
481
+ {
482
+ name: "propose_team_setup",
483
+ description: "Chief of Staff only: propose all requested specialist creation, profile/model configuration, and authorized team moves in ONE combined review card. Nothing changes until the user applies it. Use exact catalog engine/model IDs from list_team_setup. Combine all fields for each bot; use the same create key or botId to coalesce repeated entries. New teams must be named explicitly in newTeams and have a specialist in this plan; the card also asks to authorize your access to just those new teams. Existing unauthorized teams cannot be included. Models change bot defaults for groups/new threads; existing threads and execution permissions stay unchanged. After proposing, end your turn. The decision and structured result automatically resume you once; do not ask again, poll, or repeat the proposal.",
484
+ inputSchema: {
485
+ type: "object", additionalProperties: false,
486
+ properties: {
487
+ reason: { type: "string", minLength: 1, maxLength: 500 },
488
+ newTeams: { type: "array", maxItems: 8, items: { type: "string", minLength: 1, maxLength: 60 } },
489
+ operations: { type: "array", minItems: 1, maxItems: 24, items: {
490
+ type: "object", additionalProperties: false,
491
+ properties: {
492
+ action: { type: "string", enum: ["create", "update"] },
493
+ key: { type: "string", description: "For create: your stable short name for this new bot in this plan." },
494
+ botId: { type: "string", description: "For update: exact existing bot ID from list_team_setup." },
495
+ fields: { type: "object", additionalProperties: false, properties: {
496
+ name: { type: "string", maxLength: 100 }, title: { type: "string", maxLength: 200 },
497
+ description: { type: "string", maxLength: 4000 }, soul: { type: "string", description: "Standing instructions; required with name/title/modelSelection for every new bot." },
498
+ section: { type: "string", maxLength: 60, description: "Exact authorized existing team, or a team explicitly named in newTeams. Empty string means General." },
499
+ modelSelection: { type: "object", additionalProperties: false, properties: {
500
+ instanceId: { type: "string" }, model: { type: "string" }, effort: { type: "string" },
501
+ }, required: ["instanceId", "model"] },
502
+ } },
503
+ }, required: ["action", "fields"],
504
+ } },
505
+ }, required: ["reason", "operations"],
506
+ },
507
+ },
508
+ {
509
+ name: "propose_bot_deletion",
510
+ description: "Chief of Staff only: when the user explicitly asks to delete a named teammate, create a separate confirmation card for that exact bot. Deletion removes its conversations, memory, instructions and skills; generated project files remain. Running work and owned computers can block deletion. Never delete yourself, substitute an archive, or put deletion into a setup batch. End your turn after proposing; the decision and result resume you once.",
511
+ inputSchema: { type: "object", additionalProperties: false, properties: {
512
+ bot_id: { type: "string", minLength: 1 }, reason: { type: "string", minLength: 1, maxLength: 500 },
513
+ }, required: ["bot_id", "reason"] },
514
+ },
459
515
  {
460
516
  name: "create_room",
461
517
  description: "Create a room in your own section when the user asks for one (maximum four per turn). Chiefs only. Choose active peers from list_bots; you are included automatically as the default responder. This creates no turns or messages. Section moves stay with the user. If peer approval is enabled, ask the user to make the room change instead.",
@@ -694,9 +750,18 @@ const TOOLS = [
694
750
  return annotations ? { ...tool, annotations } : tool;
695
751
  });
696
752
  const SKILL_TOOL_NAMES = new Set(["skills_list", "skill_manage"]);
697
- const AVAILABLE_TOOLS = SKILL_AUTHORING_ENABLED
753
+ const AUTHORING_TOOLS = SKILL_AUTHORING_ENABLED
698
754
  ? TOOLS
699
755
  : TOOLS.filter((tool) => !SKILL_TOOL_NAMES.has(tool.name));
756
+ // One teamwork path in room turns; keep all unrelated integrations available.
757
+ // Ordinary direct chats use this same bounded coordinator. Goal-owned turns
758
+ // retain their independent loop and cannot start a second coordinator.
759
+ const ROOM_ONLY_TOOLS = new Set(["list_room_targets", "coordinate_bots"]);
760
+ const ROOM_REPLACED_TOOLS = new Set(["ask_bot", "delegate_bot", "check_delegation", "wait_delegation", "start_thread", "send_to_thread", "wait_thread"]);
761
+ const COORDINATING = process.env.OMB_ROOM_TURN === "1";
762
+ const AVAILABLE_TOOLS = COORDINATING
763
+ ? AUTHORING_TOOLS.filter(tool => !ROOM_REPLACED_TOOLS.has(tool.name))
764
+ : AUTHORING_TOOLS.filter(tool => !ROOM_ONLY_TOOLS.has(tool.name));
700
765
  const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
701
766
  const ok = (id, result) => send({ jsonrpc: "2.0", id, result });
702
767
  const rpcErr = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
@@ -799,18 +864,35 @@ function recallSpeaker(hit) {
799
864
  return hit.role === "user" ? "user" : "you";
800
865
  }
801
866
  async function callTool(name, args) {
867
+ if (name === "list_room_targets") {
868
+ const r = await api("/api/internal/room-targets");
869
+ return { text: JSON.stringify(r), ...(r.error ? { isError: true } : {}) };
870
+ }
871
+ if (name === "coordinate_bots") {
872
+ const r = await api("/api/internal/coordinate-bots", { method: "POST", body: JSON.stringify({
873
+ groupId: args.group_id, botIds: args.bot_ids, message: args.message,
874
+ requestKey: args.request_key, rework: args.rework,
875
+ }) });
876
+ return { text: JSON.stringify(r), ...(r.error ? { isError: true } : {}) };
877
+ }
802
878
  if (name === "list_bots") {
803
879
  const r = await api(`/api/internal/agents?self=${encodeURIComponent(BOT_ID)}`);
804
880
  const bots = r.bots ?? [];
805
881
  if (!bots.length)
806
- return { text: "No other bots in this section yet." };
882
+ return { text: "No other reachable bots yet." };
807
883
  const lines = bots.map((b) => {
808
884
  const role = b.title ? ` — ${b.title}` : "";
809
885
  const about = b.description ? ` (${String(b.description).slice(0, 120)})` : "";
810
- return `- ${b.name}${role}${about} [id: ${b.id}, model: ${b.model}${b.busy ? ", busy" : ""}]`;
886
+ // statusText is the server's own wording for what the teammate is
887
+ // doing; an older server only sends busy, so fall back to that.
888
+ const state = typeof b.statusText === "string"
889
+ ? (b.status === "available" ? "" : b.statusText)
890
+ : (b.busy ? "busy" : "");
891
+ const team = typeof b.section === "string" ? `, team: ${peerName(b.section) || "General"}` : "";
892
+ return `- ${b.name}${role}${about} [id: ${b.id}, model: ${b.model}${team}${state ? `, ${state}` : ""}]`;
811
893
  });
812
894
  return {
813
- text: `Other bots in your section:\n${lines.join("\n")}\n\nAssign work with delegate_bot. Use ask_bot only for a short answer you need inline.`,
895
+ text: `Reachable teammates:\n${lines.join("\n")}\n\n${COORDINATING ? "Use coordinate_bots for advice or concrete work, then end your turn. Busy teammates queue and results resume you automatically." : "Assign work with delegate_bot. Use ask_bot only for a short answer you need inline."}`,
814
896
  };
815
897
  }
816
898
  if (name === "list_rooms") {
@@ -943,7 +1025,16 @@ async function callTool(name, args) {
943
1025
  if (r.status === "done")
944
1026
  return { text: `${who} finished task ${taskId}:\n${String(r.result || "(no reply text)")}` };
945
1027
  if (r.status === "queued") {
946
- return { text: `Task ${taskId} is still queued — ${who} hasn't picked it up yet${waitMs ? ` after ${timeout}s` : ""}. Keep working and check again later.` };
1028
+ const why = r.targetStatus === "waiting-on-user"
1029
+ ? ` ${who} is waiting on the user, so it goes through after they answer.`
1030
+ : r.targetStatus === "working" ? ` ${who} is busy with other work.` : "";
1031
+ const expiresInMs = Number(r.expiresInMs);
1032
+ const expiry = !Number.isFinite(expiresInMs)
1033
+ ? ""
1034
+ : expiresInMs <= 0
1035
+ ? " It is past its 24-hour limit and will expire the next time it cannot be delivered."
1036
+ : ` It expires if not picked up within ${Math.ceil(expiresInMs / 3_600_000)} hour${Math.ceil(expiresInMs / 3_600_000) === 1 ? "" : "s"}.`;
1037
+ return { text: `Task ${taskId} is still queued — ${who} hasn't picked it up yet${waitMs ? ` after ${timeout}s` : ""}.${why}${expiry} Keep working and check again later.` };
947
1038
  }
948
1039
  if (r.status === "running") {
949
1040
  const elapsedMs = Number.isFinite(r.elapsedMs) ? Number(r.elapsedMs) : 0;
@@ -1041,6 +1132,18 @@ async function callTool(name, args) {
1041
1132
  text: `${opened}${timing}${approval} Its result will be delivered to this conversation automatically (delegation id: ${delegationId || "unknown"}). Acknowledge it, mention it to the person as #${threadTitle}, and finish your turn; do not check or wait for it in this turn.`,
1042
1133
  };
1043
1134
  }
1135
+ if (name === "list_team_setup") {
1136
+ return { text: JSON.stringify(await api("/api/internal/team-setup-catalog")) };
1137
+ }
1138
+ if (name === "propose_team_setup" || name === "propose_bot_deletion") {
1139
+ const deleting = name === "propose_bot_deletion";
1140
+ const result = await api(deleting ? "/api/internal/bot-deletion-requests" : "/api/internal/team-setup-requests", {
1141
+ method: "POST", body: JSON.stringify({ fromBotId: BOT_ID, fromThreadId: THREAD_ID,
1142
+ ...(deleting ? { targetBotId: args.bot_id, reason: args.reason } : { plan: args }),
1143
+ }),
1144
+ });
1145
+ return { text: `One review card is visible: ${String(result.title)}. Nothing has been applied. End this turn; the decision and structured result resume you automatically once. Do not ask again, poll, or repeat this proposal.` };
1146
+ }
1044
1147
  if (name === "create_bot") {
1045
1148
  const botName = String(args.name ?? "").trim();
1046
1149
  const role = String(args.role ?? "").trim();
@@ -1063,7 +1166,7 @@ async function callTool(name, args) {
1063
1166
  });
1064
1167
  createdThisTurn += 1;
1065
1168
  return {
1066
- text: `Created @${r.name ?? botName} in ${r.section ?? "General"} [id: ${r.id}]. Assign work with delegate_bot.`,
1169
+ text: `Created @${r.name ?? botName} in ${r.section ?? "General"} [id: ${r.id}]. Assign work with ${COORDINATING ? "coordinate_bots" : "delegate_bot"}.`,
1067
1170
  };
1068
1171
  }
1069
1172
  if (name === "create_room") {
@@ -408,7 +408,7 @@ function askSummary(ask) {
408
408
  return askQuestionSummary(questions).slice(0, 300);
409
409
  return askInputSummary(ask.input) ?? ask.tool ?? "tool";
410
410
  }
411
- export function permissionSocketPath(threadId) {
411
+ export function permissionSocketPath(threadId, botId) {
412
412
  // A readable prefix alone is not unique: ids that agree on their first
413
413
  // characters ("t-perm-dup-1", "t-perm-dup-2") would share a socket. POSIX
414
414
  // hides that — a new broker's listen replaces the socket FILE, so the name
@@ -418,8 +418,16 @@ export function permissionSocketPath(threadId) {
418
418
  // id so distinct threads get distinct sockets; the tag stays at 8 chars
419
419
  // total because the POSIX path already brushes the 104-byte sun_path
420
420
  // limit under deep tmp home dirs.
421
+ //
422
+ // botId is folded into the digest too (#1017): the driver's session/broker
423
+ // maps are a single process-wide table keyed on threadId alone, so a
424
+ // delegated child turn whose threadId ever coincides with its still-open
425
+ // parent's (or any other bot's) would otherwise collide on the exact same
426
+ // socket. Namespacing by bot makes that collision structurally impossible
427
+ // regardless of how two turns end up sharing a threadId.
428
+ const key = botId ? `${botId}\0${threadId}` : threadId;
421
429
  const prefix = threadId.replace(/[^\w-]/g, "").slice(0, 4);
422
- const digest = createHash("sha256").update(threadId).digest("hex").slice(0, 4);
430
+ const digest = createHash("sha256").update(key).digest("hex").slice(0, 4);
423
431
  return brokerSocketPath(DATA_DIR, `${prefix}${digest}`);
424
432
  }
425
433
  /** Paths the broker may bind, tried in order. Windows named pipes are never
@@ -429,11 +437,11 @@ export function permissionSocketPath(threadId) {
429
437
  * longer than its small `sun_path` limit; a deep test HOME or long username
430
438
  * can otherwise make every approval silently unavailable. The proxy learns
431
439
  * the actual bound path from its argv, so either fallback is transparent. */
432
- export function brokerSocketCandidates(threadId) {
433
- const base = permissionSocketPath(threadId);
440
+ export function brokerSocketCandidates(threadId, botId) {
441
+ const base = permissionSocketPath(threadId, botId);
434
442
  if (process.platform !== "win32") {
435
443
  const scope = createHash("sha256")
436
- .update(`${DATA_DIR}\0${process.pid}\0${threadId}`)
444
+ .update(`${DATA_DIR}\0${process.pid}\0${botId ?? ""}\0${threadId}`)
437
445
  .digest("hex")
438
446
  .slice(0, 16);
439
447
  return [base, join(tmpdir(), `omb-perm-${scope}.sock`)];
@@ -866,8 +874,8 @@ export const ClaudeDriver = {
866
874
  // retry bookkeeping lives PER THREAD, not per sendTurn call: a relaunch
867
875
  // is a fresh sendTurn, and the attempt cap must survive across launches
868
876
  const retryState = new Map();
869
- const sendTurn = async (turn) => {
870
- const { threadId } = turn;
877
+ const sendTurn = async (turn, logicalTurnId) => {
878
+ const { threadId, botId } = turn;
871
879
  if (active.has(threadId))
872
880
  throw new Error("a turn is already running on this thread");
873
881
  // A bot-level mode is authoritative for this turn. In particular, an
@@ -887,7 +895,10 @@ export const ClaudeDriver = {
887
895
  // Materialize before creating a broker or process. A missing/corrupt
888
896
  // attachment must fail this call without leaving a live session behind.
889
897
  const promptMsg = claudeUserMessage(turn.text, turn.images);
890
- const turnId = newId();
898
+ // Internal relaunches are still the turn acknowledged to the harness.
899
+ // A new user message gets a fresh id, but retry/recovery must not orphan
900
+ // its capability, coordination result or queued continuation ownership.
901
+ const turnId = logicalTurnId ?? newId();
891
902
  const retryAbort = new AbortController();
892
903
  const retry = retryState.get(threadId) ?? { attempt: 0, cancelled: false };
893
904
  retry.cancelled = false;
@@ -1046,7 +1057,7 @@ export const ClaudeDriver = {
1046
1057
  // Keep ask_user available even in Full access. Native bypass skips
1047
1058
  // permission prompts, not questions requiring a person's answer.
1048
1059
  let broker;
1049
- const socketPath = permissionSocketPath(threadId);
1060
+ const socketPath = permissionSocketPath(threadId, botId);
1050
1061
  if (permissionMode !== "bypassPermissions") {
1051
1062
  args.push("--permission-prompt-tool", "mcp__ogb__approve");
1052
1063
  }
@@ -1103,9 +1114,11 @@ export const ClaudeDriver = {
1103
1114
  if (live && !live.turn && !live.closing && live.child.exitCode === null && live.argsKey === argsKey && (!sessionId || sessionId === live.sessionId)) {
1104
1115
  if (live.idleTimer)
1105
1116
  clearTimeout(live.idleTimer);
1106
- live.turn = { turnId, settled: false, sawStreamDelta: false };
1117
+ live.turn = { turnId, input: turn, retryAbort, settled: false, sawStreamDelta: false };
1107
1118
  active.set(threadId, { stop: () => {
1108
1119
  closeSession(threadId, "interrupted");
1120
+ retry.cancelled = true;
1121
+ retryAbort.abort();
1109
1122
  stopSession(live);
1110
1123
  }, turnId, broker: live.broker });
1111
1124
  emit({ ...base(threadId, turnId), type: "turn.started" });
@@ -1173,7 +1186,7 @@ export const ClaudeDriver = {
1173
1186
  // event can scope approvals to real desktop-control tools only
1174
1187
  const askTools = new Map();
1175
1188
  broker = await createPermissionBroker({
1176
- socketPaths: brokerSocketCandidates(threadId),
1189
+ socketPaths: brokerSocketCandidates(threadId, botId),
1177
1190
  isActive: () => Boolean(sessions.get(threadId)?.turn),
1178
1191
  onAsk: (ask) => {
1179
1192
  const eventTurnId = sessions.get(threadId)?.turn?.turnId ?? turnId;
@@ -1268,7 +1281,7 @@ export const ClaudeDriver = {
1268
1281
  sessionId: sessionId ?? newSessionId,
1269
1282
  sawInit: false,
1270
1283
  nativePermissionMode: null,
1271
- turn: { turnId, settled: false, sawStreamDelta: false },
1284
+ turn: { turnId, input: turn, retryAbort, settled: false, sawStreamDelta: false },
1272
1285
  idleTimer: null,
1273
1286
  closing: false,
1274
1287
  stderr: "",
@@ -1472,6 +1485,10 @@ export const ClaudeDriver = {
1472
1485
  // process that exited between turns (idle close, contract change)
1473
1486
  // is just a session ending
1474
1487
  if (session.turn && !session.turn.settled) {
1488
+ // A retained process may be running a later user turn. Its close
1489
+ // handler must retry that request, not the process's first prompt.
1490
+ const { turnId, input: turn, retryAbort } = session.turn;
1491
+ const retry = retryState.get(threadId) ?? { attempt: 0, cancelled: false };
1475
1492
  const message = `claude exited ${code} before result${session.stderr ? `: ${session.stderr.trim().slice(-300)}` : ""}`;
1476
1493
  const verdict = classifyError({ exitCode: code, stderr: message });
1477
1494
  if (!retry.cancelled &&
@@ -1531,7 +1548,7 @@ export const ClaudeDriver = {
1531
1548
  active.delete(threadId);
1532
1549
  try {
1533
1550
  const cursor = session.sessionId ?? sessionId ?? undefined;
1534
- await sendTurn({ ...turn, resumeCursor: cursor });
1551
+ await sendTurn({ ...turn, resumeCursor: cursor }, turnId);
1535
1552
  }
1536
1553
  catch (e) {
1537
1554
  retryState.delete(threadId);
@@ -1598,7 +1615,7 @@ export const ClaudeDriver = {
1598
1615
  void (async () => {
1599
1616
  try {
1600
1617
  // no cursor: a fresh session, carrying the rebuild
1601
- await sendTurn({ ...turn, resumeCursor: undefined, recoveryText: undefined, text: recovery.text });
1618
+ await sendTurn({ ...turn, resumeCursor: undefined, recoveryText: undefined, text: recovery.text }, turnId);
1602
1619
  }
1603
1620
  catch (e) {
1604
1621
  retryState.delete(threadId);
@@ -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();