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.
- package/dist/assets/index-BtT047dk.css +1 -0
- package/dist/assets/{index-CwoQdM0z.js → index-C6blGSq5.js} +1 -1
- package/dist/assets/index-fKXFpB_J.js +305 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +6 -2
- package/dist-server/drivers/agents-proxy.js +133 -11
- package/dist-server/index.js +3379 -1431
- package/dist-server/mcp-gate.js +28 -18
- package/dist-server/openmausbot.js +557 -325
- package/dist-server/pair-cli.js +557 -325
- package/dist-server/permission-proxy.js +119 -14
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/atomic.js +37 -1
- package/dist-server/server/auto-approve.js +15 -0
- package/dist-server/server/bot-overview.js +5 -1
- package/dist-server/server/chief-of-staff.js +17 -12
- package/dist-server/server/delegations.js +177 -63
- package/dist-server/server/drivers/acp/core.js +2 -1
- package/dist-server/server/drivers/acp/grok.js +12 -1
- package/dist-server/server/drivers/acp/hermes.js +21 -3
- package/dist-server/server/drivers/agents-proxy.js +113 -10
- package/dist-server/server/drivers/claude.js +91 -17
- package/dist-server/server/drivers/codex.js +15 -4
- package/dist-server/server/drivers/openai-chat.js +90 -67
- package/dist-server/server/drivers/openai-compat.js +9 -1
- package/dist-server/server/drivers/pi.js +18 -3
- package/dist-server/server/drivers/retry.js +4 -0
- package/dist-server/server/env-path.js +18 -17
- package/dist-server/server/index.js +1172 -302
- package/dist-server/server/message-db.js +21 -0
- package/dist-server/server/peer-roster.js +48 -9
- package/dist-server/server/permission-proxy.js +90 -15
- package/dist-server/server/procs.js +19 -3
- package/dist-server/server/room-handoffs.js +237 -0
- package/dist-server/server/section-context.js +64 -14
- package/dist-server/server/setup-mode.js +6 -7
- package/dist-server/server/store.js +266 -8
- package/dist-server/server/surface.js +16 -5
- package/dist-server/server/team-backup.js +17 -1
- package/dist-server/server/team-setup-requests.js +252 -0
- package/dist-server/server/thread-events.js +10 -1
- package/dist-server/server/turn-dispatch-guard.js +31 -3
- package/dist-server/server/workspace.js +13 -0
- package/dist-server/shared/approval-mode.js +7 -0
- package/dist-server/shared/ask-question.js +172 -0
- package/dist-server/shared/provider-safety.js +6 -0
- package/dist-server/shared/team-backup.js +1 -0
- package/dist-server/shared/team-setup.js +1 -0
- package/package.json +1 -1
- package/dist/assets/index-BRLHsQ5g.js +0 -298
- package/dist/assets/index-ByLQ7a3F.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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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: `
|
|
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
|
-
|
|
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") {
|
|
@@ -26,6 +26,7 @@ import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS }
|
|
|
26
26
|
import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
|
|
27
27
|
import { appendNative } from "./native.js";
|
|
28
28
|
import { SPAWNED_PROXIES } from "../proxy-paths.js";
|
|
29
|
+
import { ASK_USER_QUESTION_TOOL, askQuestionSummary, parseAskQuestions, questionChoices, } from "../../shared/ask-question.js";
|
|
29
30
|
/** Whether `claude` has been signed in.
|
|
30
31
|
*
|
|
31
32
|
* Credential storage is deliberately not inspected here. Claude Code uses the
|
|
@@ -150,6 +151,30 @@ function claudeEnvironment(model, source = process.env, configDir, instanceEnvir
|
|
|
150
151
|
function inheritsUserConfig(env) {
|
|
151
152
|
return env.OMB_CLAUDE_INHERIT_USER_CONFIG === "1";
|
|
152
153
|
}
|
|
154
|
+
/** Retain the selected CLI account's authentication without importing its
|
|
155
|
+
* hooks, permissions, MCP servers or personal instructions. Explicit OMB
|
|
156
|
+
* connections/local endpoints own their entire routing + credential pair. */
|
|
157
|
+
export function readClaudeAuthSettings(env, instanceEnvironment = {}) {
|
|
158
|
+
if (CLAUDE_ACCOUNT_ENV_KEYS.some((key) => instanceEnvironment[key]))
|
|
159
|
+
return {};
|
|
160
|
+
try {
|
|
161
|
+
const settings = JSON.parse(readFileSync(join(resolveClaudeConfigDir(undefined, env), "settings.json"), "utf8"));
|
|
162
|
+
const authEnv = {};
|
|
163
|
+
for (const key of CLAUDE_ACCOUNT_ENV_KEYS) {
|
|
164
|
+
if (!key.endsWith("_FILE_DESCRIPTOR") && typeof settings?.env?.[key] === "string") {
|
|
165
|
+
authEnv[key] = settings.env[key];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
...(Object.keys(authEnv).length ? { env: authEnv } : {}),
|
|
170
|
+
...(typeof settings?.apiKeyHelper === "string" && settings.apiKeyHelper.trim()
|
|
171
|
+
? { apiKeyHelper: settings.apiKeyHelper } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
153
178
|
/** MCP servers the bot's own project declares in `<cwd>/.mcp.json`.
|
|
154
179
|
*
|
|
155
180
|
* The CLI would find this file itself, but the harness launches it with
|
|
@@ -370,11 +395,20 @@ function systemEndedReply(kind) {
|
|
|
370
395
|
? { behavior: "answer", message: "OpenMausBot: the turn is ending — wrap up." }
|
|
371
396
|
: { behavior: "deny", message: "OpenMausBot: the turn ended" };
|
|
372
397
|
}
|
|
398
|
+
/** The structured questions behind an ask, when it is one. Claude's own
|
|
399
|
+
* AskUserQuestion carries them; everything else answers null and keeps the
|
|
400
|
+
* plain summary/choices card. */
|
|
401
|
+
function askQuestions(ask) {
|
|
402
|
+
return ask.tool === ASK_USER_QUESTION_TOOL ? parseAskQuestions(ask.input) : null;
|
|
403
|
+
}
|
|
373
404
|
/** One human-readable line for an ask — what the card subtitle shows. */
|
|
374
405
|
function askSummary(ask) {
|
|
406
|
+
const questions = askQuestions(ask);
|
|
407
|
+
if (questions)
|
|
408
|
+
return askQuestionSummary(questions).slice(0, 300);
|
|
375
409
|
return askInputSummary(ask.input) ?? ask.tool ?? "tool";
|
|
376
410
|
}
|
|
377
|
-
export function permissionSocketPath(threadId) {
|
|
411
|
+
export function permissionSocketPath(threadId, botId) {
|
|
378
412
|
// A readable prefix alone is not unique: ids that agree on their first
|
|
379
413
|
// characters ("t-perm-dup-1", "t-perm-dup-2") would share a socket. POSIX
|
|
380
414
|
// hides that — a new broker's listen replaces the socket FILE, so the name
|
|
@@ -384,8 +418,16 @@ export function permissionSocketPath(threadId) {
|
|
|
384
418
|
// id so distinct threads get distinct sockets; the tag stays at 8 chars
|
|
385
419
|
// total because the POSIX path already brushes the 104-byte sun_path
|
|
386
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;
|
|
387
429
|
const prefix = threadId.replace(/[^\w-]/g, "").slice(0, 4);
|
|
388
|
-
const digest = createHash("sha256").update(
|
|
430
|
+
const digest = createHash("sha256").update(key).digest("hex").slice(0, 4);
|
|
389
431
|
return brokerSocketPath(DATA_DIR, `${prefix}${digest}`);
|
|
390
432
|
}
|
|
391
433
|
/** Paths the broker may bind, tried in order. Windows named pipes are never
|
|
@@ -395,11 +437,11 @@ export function permissionSocketPath(threadId) {
|
|
|
395
437
|
* longer than its small `sun_path` limit; a deep test HOME or long username
|
|
396
438
|
* can otherwise make every approval silently unavailable. The proxy learns
|
|
397
439
|
* the actual bound path from its argv, so either fallback is transparent. */
|
|
398
|
-
export function brokerSocketCandidates(threadId) {
|
|
399
|
-
const base = permissionSocketPath(threadId);
|
|
440
|
+
export function brokerSocketCandidates(threadId, botId) {
|
|
441
|
+
const base = permissionSocketPath(threadId, botId);
|
|
400
442
|
if (process.platform !== "win32") {
|
|
401
443
|
const scope = createHash("sha256")
|
|
402
|
-
.update(`${DATA_DIR}\0${process.pid}\0${threadId}`)
|
|
444
|
+
.update(`${DATA_DIR}\0${process.pid}\0${botId ?? ""}\0${threadId}`)
|
|
403
445
|
.digest("hex")
|
|
404
446
|
.slice(0, 16);
|
|
405
447
|
return [base, join(tmpdir(), `omb-perm-${scope}.sock`)];
|
|
@@ -490,7 +532,10 @@ export async function createPermissionBroker(opts) {
|
|
|
490
532
|
// `always` rides to the proxy, which hands the CLI's own suggested
|
|
491
533
|
// permission rules back as updatedPermissions: Claude remembers
|
|
492
534
|
// the allow for the session, the harness remembers nothing.
|
|
493
|
-
|
|
535
|
+
// `source` travels with the answer too: a proxy that cannot tell
|
|
536
|
+
// the human's words from a timeout note would file the timeout
|
|
537
|
+
// note as the human's words.
|
|
538
|
+
conn.write(JSON.stringify({ t: "answer", id: askId, behavior, message, source, ...(always ? { always: true } : {}) }) + "\n");
|
|
494
539
|
}
|
|
495
540
|
catch { }
|
|
496
541
|
opts.onResolve({ ...ask, behavior, source });
|
|
@@ -829,8 +874,8 @@ export const ClaudeDriver = {
|
|
|
829
874
|
// retry bookkeeping lives PER THREAD, not per sendTurn call: a relaunch
|
|
830
875
|
// is a fresh sendTurn, and the attempt cap must survive across launches
|
|
831
876
|
const retryState = new Map();
|
|
832
|
-
const sendTurn = async (turn) => {
|
|
833
|
-
const { threadId } = turn;
|
|
877
|
+
const sendTurn = async (turn, logicalTurnId) => {
|
|
878
|
+
const { threadId, botId } = turn;
|
|
834
879
|
if (active.has(threadId))
|
|
835
880
|
throw new Error("a turn is already running on this thread");
|
|
836
881
|
// A bot-level mode is authoritative for this turn. In particular, an
|
|
@@ -850,7 +895,10 @@ export const ClaudeDriver = {
|
|
|
850
895
|
// Materialize before creating a broker or process. A missing/corrupt
|
|
851
896
|
// attachment must fail this call without leaving a live session behind.
|
|
852
897
|
const promptMsg = claudeUserMessage(turn.text, turn.images);
|
|
853
|
-
|
|
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();
|
|
854
902
|
const retryAbort = new AbortController();
|
|
855
903
|
const retry = retryState.get(threadId) ?? { attempt: 0, cancelled: false };
|
|
856
904
|
retry.cancelled = false;
|
|
@@ -1009,7 +1057,7 @@ export const ClaudeDriver = {
|
|
|
1009
1057
|
// Keep ask_user available even in Full access. Native bypass skips
|
|
1010
1058
|
// permission prompts, not questions requiring a person's answer.
|
|
1011
1059
|
let broker;
|
|
1012
|
-
const socketPath = permissionSocketPath(threadId);
|
|
1060
|
+
const socketPath = permissionSocketPath(threadId, botId);
|
|
1013
1061
|
if (permissionMode !== "bypassPermissions") {
|
|
1014
1062
|
args.push("--permission-prompt-tool", "mcp__ogb__approve");
|
|
1015
1063
|
}
|
|
@@ -1028,6 +1076,12 @@ export const ClaudeDriver = {
|
|
|
1028
1076
|
args.push("--allowedTools", allowed.join(","));
|
|
1029
1077
|
}
|
|
1030
1078
|
const env = environment(turnModel);
|
|
1079
|
+
const authSettings = isolated && !injected.injected
|
|
1080
|
+
? readClaudeAuthSettings(env, input.environment) : {};
|
|
1081
|
+
const authSettingsPath = mcpConfigPath && Object.keys(authSettings).length
|
|
1082
|
+
? join(dirname(mcpConfigPath), "auth-settings.json") : null;
|
|
1083
|
+
if (authSettingsPath)
|
|
1084
|
+
args.push("--settings", authSettingsPath);
|
|
1031
1085
|
// Our approvals and browser credentials expire at the user-turn
|
|
1032
1086
|
// boundary. Native background workers cannot outlive that boundary;
|
|
1033
1087
|
// parallel bot work must use the harness's durable delegate_bot path.
|
|
@@ -1035,7 +1089,7 @@ export const ClaudeDriver = {
|
|
|
1035
1089
|
const cwd = turn.cwd ?? homedir();
|
|
1036
1090
|
// Everything that shapes the process, minus session/turn-specific temp
|
|
1037
1091
|
// paths. Their contents are represented directly in the key instead.
|
|
1038
|
-
const privateFileFlags = new Set(["--mcp-config"]);
|
|
1092
|
+
const privateFileFlags = new Set(["--mcp-config", "--settings"]);
|
|
1039
1093
|
const keyArgs = args.filter((a, i) => !privateFileFlags.has(a) && !privateFileFlags.has(args[i - 1] ?? ""));
|
|
1040
1094
|
const argsKey = JSON.stringify({
|
|
1041
1095
|
args: keyArgs,
|
|
@@ -1047,6 +1101,11 @@ export const ClaudeDriver = {
|
|
|
1047
1101
|
model: injected.model ?? null,
|
|
1048
1102
|
base: env.ANTHROPIC_BASE_URL ?? null,
|
|
1049
1103
|
configDir: env.CLAUDE_CONFIG_DIR ?? null,
|
|
1104
|
+
// Rotating an account's key/helper must not reuse the old process.
|
|
1105
|
+
auth: createHash("sha256").update(JSON.stringify({
|
|
1106
|
+
settings: authSettings,
|
|
1107
|
+
env: Object.fromEntries(CLAUDE_ACCOUNT_ENV_KEYS.map((key) => [key, env[key]])),
|
|
1108
|
+
})).digest("hex"),
|
|
1050
1109
|
});
|
|
1051
1110
|
// Reuse the live process when it is idle, unchanged, and is the session
|
|
1052
1111
|
// the harness wants resumed. Anything else: close it and spawn fresh
|
|
@@ -1055,9 +1114,11 @@ export const ClaudeDriver = {
|
|
|
1055
1114
|
if (live && !live.turn && !live.closing && live.child.exitCode === null && live.argsKey === argsKey && (!sessionId || sessionId === live.sessionId)) {
|
|
1056
1115
|
if (live.idleTimer)
|
|
1057
1116
|
clearTimeout(live.idleTimer);
|
|
1058
|
-
live.turn = { turnId, settled: false, sawStreamDelta: false };
|
|
1117
|
+
live.turn = { turnId, input: turn, retryAbort, settled: false, sawStreamDelta: false };
|
|
1059
1118
|
active.set(threadId, { stop: () => {
|
|
1060
1119
|
closeSession(threadId, "interrupted");
|
|
1120
|
+
retry.cancelled = true;
|
|
1121
|
+
retryAbort.abort();
|
|
1061
1122
|
stopSession(live);
|
|
1062
1123
|
}, turnId, broker: live.broker });
|
|
1063
1124
|
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
@@ -1125,7 +1186,7 @@ export const ClaudeDriver = {
|
|
|
1125
1186
|
// event can scope approvals to real desktop-control tools only
|
|
1126
1187
|
const askTools = new Map();
|
|
1127
1188
|
broker = await createPermissionBroker({
|
|
1128
|
-
socketPaths: brokerSocketCandidates(threadId),
|
|
1189
|
+
socketPaths: brokerSocketCandidates(threadId, botId),
|
|
1129
1190
|
isActive: () => Boolean(sessions.get(threadId)?.turn),
|
|
1130
1191
|
onAsk: (ask) => {
|
|
1131
1192
|
const eventTurnId = sessions.get(threadId)?.turn?.turnId ?? turnId;
|
|
@@ -1137,6 +1198,7 @@ export const ClaudeDriver = {
|
|
|
1137
1198
|
const nativeReview = permissionMode === "auto" && nativeMode !== null
|
|
1138
1199
|
? nativeMode === "auto" ? "active" : "inactive"
|
|
1139
1200
|
: undefined;
|
|
1201
|
+
const questions = askQuestions(ask);
|
|
1140
1202
|
emit({
|
|
1141
1203
|
...base(threadId, eventTurnId),
|
|
1142
1204
|
type: "request.opened",
|
|
@@ -1151,7 +1213,12 @@ export const ClaudeDriver = {
|
|
|
1151
1213
|
approvalScope: typeof ask.tool === "string" && controlsHost && ask.tool.startsWith("mcp__computer")
|
|
1152
1214
|
? "local-computer"
|
|
1153
1215
|
: undefined,
|
|
1154
|
-
|
|
1216
|
+
questions: questions ?? undefined,
|
|
1217
|
+
// A structured ask still offers flat labels, for the phone
|
|
1218
|
+
// companions and any client that predates the question card.
|
|
1219
|
+
choices: questions
|
|
1220
|
+
? questionChoices(questions)
|
|
1221
|
+
: Array.isArray(ask.input?.choices) ? ask.input.choices.slice(0, 5) : undefined,
|
|
1155
1222
|
});
|
|
1156
1223
|
},
|
|
1157
1224
|
onResolve: (resolved) => {
|
|
@@ -1180,6 +1247,9 @@ export const ClaudeDriver = {
|
|
|
1180
1247
|
if (mcpConfigPath) {
|
|
1181
1248
|
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
|
|
1182
1249
|
}
|
|
1250
|
+
if (authSettingsPath) {
|
|
1251
|
+
writeFileSync(authSettingsPath, JSON.stringify(authSettings), { mode: 0o600 });
|
|
1252
|
+
}
|
|
1183
1253
|
if (sessionId)
|
|
1184
1254
|
args.push("--resume", sessionId);
|
|
1185
1255
|
else
|
|
@@ -1211,7 +1281,7 @@ export const ClaudeDriver = {
|
|
|
1211
1281
|
sessionId: sessionId ?? newSessionId,
|
|
1212
1282
|
sawInit: false,
|
|
1213
1283
|
nativePermissionMode: null,
|
|
1214
|
-
turn: { turnId, settled: false, sawStreamDelta: false },
|
|
1284
|
+
turn: { turnId, input: turn, retryAbort, settled: false, sawStreamDelta: false },
|
|
1215
1285
|
idleTimer: null,
|
|
1216
1286
|
closing: false,
|
|
1217
1287
|
stderr: "",
|
|
@@ -1415,6 +1485,10 @@ export const ClaudeDriver = {
|
|
|
1415
1485
|
// process that exited between turns (idle close, contract change)
|
|
1416
1486
|
// is just a session ending
|
|
1417
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 };
|
|
1418
1492
|
const message = `claude exited ${code} before result${session.stderr ? `: ${session.stderr.trim().slice(-300)}` : ""}`;
|
|
1419
1493
|
const verdict = classifyError({ exitCode: code, stderr: message });
|
|
1420
1494
|
if (!retry.cancelled &&
|
|
@@ -1474,7 +1548,7 @@ export const ClaudeDriver = {
|
|
|
1474
1548
|
active.delete(threadId);
|
|
1475
1549
|
try {
|
|
1476
1550
|
const cursor = session.sessionId ?? sessionId ?? undefined;
|
|
1477
|
-
await sendTurn({ ...turn, resumeCursor: cursor });
|
|
1551
|
+
await sendTurn({ ...turn, resumeCursor: cursor }, turnId);
|
|
1478
1552
|
}
|
|
1479
1553
|
catch (e) {
|
|
1480
1554
|
retryState.delete(threadId);
|
|
@@ -1541,7 +1615,7 @@ export const ClaudeDriver = {
|
|
|
1541
1615
|
void (async () => {
|
|
1542
1616
|
try {
|
|
1543
1617
|
// no cursor: a fresh session, carrying the rebuild
|
|
1544
|
-
await sendTurn({ ...turn, resumeCursor: undefined, recoveryText: undefined, text: recovery.text });
|
|
1618
|
+
await sendTurn({ ...turn, resumeCursor: undefined, recoveryText: undefined, text: recovery.text }, turnId);
|
|
1545
1619
|
}
|
|
1546
1620
|
catch (e) {
|
|
1547
1621
|
retryState.delete(threadId);
|
|
@@ -524,6 +524,7 @@ export const CodexDriver = {
|
|
|
524
524
|
const earlyNotifications = [];
|
|
525
525
|
const state = {
|
|
526
526
|
settled: false,
|
|
527
|
+
lastError: "",
|
|
527
528
|
lastText: "",
|
|
528
529
|
sawStreamDelta: false,
|
|
529
530
|
// codex reports token usage as a running THREAD total; the harness
|
|
@@ -878,7 +879,15 @@ export const CodexDriver = {
|
|
|
878
879
|
}
|
|
879
880
|
case "turn/completed": {
|
|
880
881
|
const t = p.turn ?? {};
|
|
881
|
-
|
|
882
|
+
const message = typeof t.error?.message === "string" ? t.error.message.slice(0, 400) : "";
|
|
883
|
+
if (t.status !== "completed" && message && message !== state.lastError) {
|
|
884
|
+
state.lastError = message;
|
|
885
|
+
emit({ ...base(threadId, turnId), type: "runtime.error", message,
|
|
886
|
+
...(classifyError({ text: message }).reason === "auth" ? { setup: true } : {}),
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
void settle(t.status === "completed", t.status === "completed" ? null :
|
|
890
|
+
(classifyError({ text: message || state.lastError }).reason === "provider_safety" ? "provider_safety" : (message || t.status || "failed")));
|
|
882
891
|
break;
|
|
883
892
|
}
|
|
884
893
|
case "error":
|
|
@@ -886,8 +895,10 @@ export const CodexDriver = {
|
|
|
886
895
|
// {error:{message}} — surface either (agentcal armor)
|
|
887
896
|
{
|
|
888
897
|
const message = p.message ?? p.error?.message;
|
|
889
|
-
if (message)
|
|
890
|
-
|
|
898
|
+
if (message) {
|
|
899
|
+
state.lastError = String(message).slice(0, 400);
|
|
900
|
+
emit({ ...base(threadId, turnId), type: "runtime.error", message: state.lastError });
|
|
901
|
+
}
|
|
891
902
|
}
|
|
892
903
|
break;
|
|
893
904
|
}
|
|
@@ -1145,7 +1156,7 @@ export const CodexDriver = {
|
|
|
1145
1156
|
message,
|
|
1146
1157
|
...(needsAuth ? { setup: true } : {}),
|
|
1147
1158
|
});
|
|
1148
|
-
await settle(false, needsAuth ? "auth_required" : "rpc_error");
|
|
1159
|
+
await settle(false, needsAuth ? "auth_required" : verdict.reason === "provider_safety" ? "provider_safety" : "rpc_error");
|
|
1149
1160
|
}
|
|
1150
1161
|
}
|
|
1151
1162
|
};
|