pi-bro 0.18.1 → 0.19.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to pi-bro are documented here.
4
4
 
5
+ ## [0.19.1] - 2026-09-30
6
+
7
+ ### Fixed
8
+
9
+ - Always enforce Bro's restrictive content policy on generated HTML, including when model output contains a permissive policy or CSP-looking comment (#69).
10
+ - Isolate smoke-test temporary artifacts and block real backend CLIs during offline tests. Order settings mutations by RPC acknowledgements instead of fixed sleeps, and support spaces in settings-test checkout paths (#70).
11
+
12
+ ## [0.19.0] - 2026-09-25
13
+
14
+ ### Added
15
+
16
+ - Support for OpenAI Codex CLI (`codex`) across all features: explain, show, BTW, and advisor. Explain and show run under a read-only sandbox with ephemeral session handling; BTW continues natively via `resume` across both conversation-only and full-permission modes; advisor runs in the workspace with sandbox bypassed. Supports seeded models `gpt-5.5` and `gpt-5.4` plus custom model IDs, and reasoning efforts `low`, `medium`, `high`, `xhigh`.
17
+ - Support for Meta Muse Code (`muse`) across all features: explain, show, BTW, and advisor. Explain and show run in a scratch directory with approval, non-shell write, and shell disabled; BTW continues natively via `--session-id` across `/mode` switches; advisor runs in the workspace with `--yolo`. Supports seeded models `muse-spark-1.3-contributor` and `muse-spark-1.3` plus custom model IDs, and reasoning efforts `minimal`, `low`, `medium`, `high`, `xhigh`, `max` (omitted for model default).
18
+ - Configuration modal support in `/bro config` for Codex and Muse, including atomic model selection, custom model ID inputs, and dynamic reasoning effort dropdowns.
19
+ - Doctor diagnostic checks for Codex (version and `codex login status` authentication probe) and Muse (version probe).
20
+
5
21
  ## [0.18.1] - 2026-09-30
6
22
 
7
23
  ### Fixed
package/README.md CHANGED
@@ -5,13 +5,16 @@ plain-language explanation — or open a separate side conversation with
5
5
  `/bro btw` — without adding anything to your main agent's context.
6
6
 
7
7
  `pi-bro` is an extension for [Earendil Pi](https://github.com/earendil-works/pi).
8
+ Experimental [PiG 0.3.0 compatibility](docs/pig-compatibility.md) uses the same npm package and requires Node.js. See the compatibility notes for verified coverage and the PiG RPC tool-exclusion limitation.
8
9
  It opens explanations in a separate modal and runs them through a CLI backend
9
10
  you already have installed and signed in to. Explain, show, BTW, and the
10
- advisor all work on all three backends:
11
+ advisor all work across five backends:
11
12
 
12
13
  - [Google Antigravity CLI](https://antigravity.google/docs/cli-install) (`agy`) — the default for new settings
13
14
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
14
15
  - Grok Build (`grok`)
16
+ - [Codex CLI](https://github.com/openai/codex) (`codex`)
17
+ - Meta Muse Code (`muse`)
15
18
 
16
19
  You only need the backend(s) you select; Agy is the default, not a requirement.
17
20
 
@@ -25,7 +28,7 @@ for the default). Then install Bro:
25
28
  pi install npm:pi-bro
26
29
  ```
27
30
 
28
- Restart Pi or run `/reload`. If you use Claude Code or Grok instead of Agy,
31
+ Restart Pi or run `/reload`. If you use Claude Code, Grok, Codex, or Muse instead of Agy,
29
32
  choose it in `/bro config` (for everything, or per feature). Then try:
30
33
 
31
34
  ```text
@@ -686,6 +689,9 @@ feature. Failed checks explain what to fix.
686
689
  - **Claude Code**: installed version and configured authentication.
687
690
  - **Grok**: installed version only; authentication and connectivity are not
688
691
  verified.
692
+ - **Codex**: installed version and configured authentication.
693
+ - **Muse**: installed version only; authentication and connectivity are not
694
+ verified.
689
695
 
690
696
  Doctor never sends source text or runs a model completion. A successful check
691
697
  confirms the setup but cannot guarantee that a later provider request will
@@ -729,7 +735,7 @@ Bro reads the file again before each request, so manual edits apply next time.
729
735
  Omitted overrides inherit `default`.
730
736
  - `effort` must be one the backend offers (`default` omits it and lets the
731
737
  model decide). Agy selections are normalized against Agy's installed model
732
- catalog. Claude and Grok accept seeded or custom model IDs; a model/effort
738
+ catalog. Claude, Grok, Codex, and Muse accept seeded or custom model IDs; a model/effort
733
739
  combination the account does not support fails with the backend's own error
734
740
  instead of silently falling back.
735
741
  - `mode` is `brief`, `balanced` (the default), or `faithful`. `showTurns` is
@@ -751,19 +757,27 @@ data under their own settings and policies.
751
757
  | **Agy** | Temporary directory, Agy sandbox | Temporary directory, Agy sandbox | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
752
758
  | **Claude Code** | Scratch directory, tools/MCP/skills disabled, no session persistence | Workspace, tools disabled | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
753
759
  | **Grok** | Temporary directory, **prompt instruction only** | Workspace, **prompt instruction only** | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
760
+ | **Codex** | Scratch directory, read-only sandbox, ephemeral session | Workspace, read-only sandbox | Workspace, approvals and sandbox bypassed | Fresh workspace process, approvals and sandbox bypassed |
761
+ | **Muse** | Scratch directory, approval/write/shell disabled, no session log | Workspace, approval/write/shell disabled | Workspace, permissions bypassed (`--yolo`) | Fresh workspace process, permissions bypassed (`--yolo`) |
754
762
 
755
763
  - Grok always runs with its sandbox off and permissions bypassed; its tools,
756
764
  hooks, skills, plugins, and MCP may remain available. "Answer only from the
757
765
  supplied context" is a request, not an enforced restriction.
766
+ - Codex's read-only sandbox is not a tool-free mode: workspace reads and
767
+ sandboxed commands remain available. Bro does not disable configured MCP
768
+ servers or web search; their access is governed by Codex configuration.
769
+ - Muse's restricted mode disables write and shell tools and approval prompts,
770
+ not all tools: workspace reads and web tools remain available without approval.
771
+ It is not a tool-free or network-isolated mode.
758
772
  - Claude's restrictions are tool/configuration settings (with safe mode), not
759
773
  an OS sandbox; built-in and managed Claude behavior can remain. Running a
760
774
  permission-bypassing mode as root may be rejected by Claude.
761
775
  - BTW continues natively: Agy by conversation ID, Claude and Grok by
762
- `--resume` of the same session, including across `/mode` switches. An Agy
763
- conversation stays bound to the workspace it started in, so after a `/mode`
764
- switch — or whenever a thread has turns but no native session ID — Bro starts
765
- a fresh native session seeded with the main-session context and every
766
- earlier turn.
776
+ `--resume`, Codex by `resume`, and Muse by `--session-id` of the same session,
777
+ including across `/mode` switches. An Agy conversation stays bound to the
778
+ workspace it started in, so after a `/mode` switch — or whenever a thread
779
+ has turns but no native session ID — Bro starts a fresh native session
780
+ seeded with the main-session context and every earlier turn.
767
781
  - Cancellation, deadlines, and invalid output streams stop the backend's
768
782
  process group on POSIX, escalating after a five-second grace period. On
769
783
  Windows only the direct child is targeted. Detached shell work or external
package/backend.ts CHANGED
@@ -15,10 +15,14 @@ export type BackendAccess = "restricted" | "workspace-full";
15
15
  export type AgySelection = { model: string; effort?: "low" | "medium" | "high" };
16
16
  export const CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
17
17
  export const GROK_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
18
+ export const CODEX_EFFORTS = ["low", "medium", "high", "xhigh"] as const;
19
+ export const MUSE_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
18
20
  export type BackendSelection =
19
21
  | ({ backend?: "agy" } & AgySelection)
20
22
  | { backend: "claude"; model: string; effort?: (typeof CLAUDE_EFFORTS)[number] }
21
- | { backend: "grok"; model: string; effort?: (typeof GROK_EFFORTS)[number] };
23
+ | { backend: "grok"; model: string; effort?: (typeof GROK_EFFORTS)[number] }
24
+ | { backend: "codex"; model: string; effort?: (typeof CODEX_EFFORTS)[number] }
25
+ | { backend: "muse"; model: string; effort?: (typeof MUSE_EFFORTS)[number] };
22
26
  export type BackendContinuation = { id: string };
23
27
  export type BackendProgress = { kind: "text"; text: string } | { kind: "activity"; label: string; timestamp: number };
24
28
  export type BackendOnProgress = (progress: BackendProgress) => void;
@@ -73,7 +77,11 @@ function processStartMessage(processError: NodeJS.ErrnoException, cli = "Agy"):
73
77
  ? "Agy could not start. Make sure Agy is installed and on PATH, then run `/bro doctor`."
74
78
  : cli === "Grok"
75
79
  ? "Grok could not start. Make sure `grok` is installed and on PATH, then run `/bro doctor`."
76
- : "Claude Code could not start. Make sure `claude` is installed and on PATH, then run `/bro doctor`.";
80
+ : cli === "Codex"
81
+ ? "Codex could not start. Make sure `codex` is installed and on PATH, then run `/bro doctor`."
82
+ : cli === "Muse"
83
+ ? "Muse could not start. Make sure `muse` is installed and on PATH, then run `/bro doctor`."
84
+ : "Claude Code could not start. Make sure `claude` is installed and on PATH, then run `/bro doctor`.";
77
85
  }
78
86
  return `${cli} could not start: ${processError.message}\n\nRun \`/bro doctor\` for setup help.`;
79
87
  }
@@ -495,7 +503,7 @@ async function executeAdvisorStdin(
495
503
 
496
504
  // Every backend supports every feature. Grok's "restricted" access is a prompt instruction, not
497
505
  // enforcement (see GROK_RESTRICTED_PREFIX); Claude's restricted btw runs tool-less.
498
- export function backendSupports(_backend: "agy" | "claude" | "grok", _feature: BackendFeature): boolean {
506
+ export function backendSupports(_backend: "agy" | "claude" | "grok" | "codex" | "muse", _feature: BackendFeature): boolean {
499
507
  return true;
500
508
  }
501
509
 
@@ -964,6 +972,458 @@ async function executeGrok(
964
972
  }
965
973
  }
966
974
 
975
+ type CodexEvent = {
976
+ type?: unknown;
977
+ thread_id?: unknown;
978
+ item?: {
979
+ id?: unknown;
980
+ type?: unknown;
981
+ text?: unknown;
982
+ message?: unknown;
983
+ content?: unknown;
984
+ command?: unknown;
985
+ cmd?: unknown;
986
+ path?: unknown;
987
+ pattern?: unknown;
988
+ query?: unknown;
989
+ };
990
+ error?: { message?: unknown };
991
+ message?: unknown;
992
+ };
993
+
994
+ function extractCodexItemText(item: CodexEvent["item"]): string | undefined {
995
+ if (!item) return undefined;
996
+ if (typeof item.text === "string") return item.text;
997
+ if (typeof item.message === "string") return item.message;
998
+ if (typeof item.content === "string") return item.content;
999
+ return undefined;
1000
+ }
1001
+
1002
+ function codexActivityFromEvent(event: CodexEvent): string | undefined {
1003
+ const item = event.item;
1004
+ if (!item || item.type === "reasoning") return undefined;
1005
+ if (event.type === "item.started" || event.type === "item.completed") {
1006
+ if (item.type !== "agent_message") {
1007
+ const itemType = typeof item.type === "string" ? item.type : "item";
1008
+ const candidate = [item.command, item.cmd, item.path, item.pattern, item.query]
1009
+ .find((v) => typeof v === "string" && v.trim());
1010
+ const preview = typeof candidate === "string" ? candidate.replace(/\s+/g, " ").trim() : "";
1011
+ return `${itemType}${preview ? ` ${preview}` : ""}`;
1012
+ }
1013
+ if (item.type === "agent_message") {
1014
+ const text = extractCodexItemText(item);
1015
+ return text?.split("\n").find((line) => line.trim())?.trim();
1016
+ }
1017
+ }
1018
+ return undefined;
1019
+ }
1020
+
1021
+ async function executeCodex(
1022
+ request: BackendRequest,
1023
+ selection: { model: string; effort?: string },
1024
+ signal: AbortSignal,
1025
+ onProgress: BackendOnProgress | undefined,
1026
+ killEscalationMs: number,
1027
+ deadlineMsOverride: number | undefined,
1028
+ ): Promise<BackendOutcome> {
1029
+ const isAdvisor = request.feature === "advisor";
1030
+ const isBtw = request.feature === "btw";
1031
+ const full = request.access === "workspace-full";
1032
+ const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
1033
+ const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1034
+ const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1035
+ const emptyTextMessage = isAdvisor ? "Codex returned no advice." : isBtw ? "Codex returned no answer for the side question." : "Codex returned no final explanation.";
1036
+
1037
+ if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1038
+ const runDirectory = isAdvisor || isBtw ? undefined : await mkdtemp(join(tmpdir(), "pi-bro-"));
1039
+ if (signal.aborted) {
1040
+ if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
1041
+ return { status: "cancelled", message: "Canceled." };
1042
+ }
1043
+
1044
+ try {
1045
+ const permissionArgs = full ? ["--dangerously-bypass-approvals-and-sandbox"] : ["--sandbox", "read-only"];
1046
+ const resume = isBtw && request.continuation;
1047
+ const args = [
1048
+ "exec",
1049
+ ...permissionArgs,
1050
+ ...(resume ? ["resume"] : []),
1051
+ "--json",
1052
+ "--skip-git-repo-check",
1053
+ ...(!isBtw ? ["--ephemeral"] : []),
1054
+ "--model",
1055
+ selection.model,
1056
+ ...(selection.effort ? ["-c", `model_reasoning_effort="${selection.effort}"`] : []),
1057
+ ...(resume ? [request.continuation!.id, "-"] : ["--", "-"]),
1058
+ ];
1059
+
1060
+ const child = spawn("codex", args, {
1061
+ cwd: runDirectory ?? request.cwd,
1062
+ stdio: ["pipe", "pipe", "pipe"],
1063
+ windowsHide: true,
1064
+ detached: process.platform !== "win32",
1065
+ });
1066
+
1067
+ const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
1068
+ let processError: Error | undefined;
1069
+ let stderr = "";
1070
+ let partial = "";
1071
+ let threadId: string | undefined;
1072
+ let final: string | undefined;
1073
+ let sawTerminal = false;
1074
+ let terminalError: string | undefined;
1075
+ let protocolError: string | undefined;
1076
+ let stdoutBuffer = "";
1077
+
1078
+ child.stderr?.setEncoding("utf8");
1079
+ child.stderr?.on("data", (chunk: string) => {
1080
+ stderr += chunk;
1081
+ });
1082
+ child.once("error", (error) => {
1083
+ processError = error;
1084
+ });
1085
+ child.stdin?.on("error", () => {
1086
+ // codex exiting before reading stdin is reported through the close/error path.
1087
+ });
1088
+ child.stdin?.end(request.prompt);
1089
+
1090
+ const handleLine = (line: string) => {
1091
+ if (!line.trim() || attempt.causeOf()) return;
1092
+ let event: CodexEvent;
1093
+ try {
1094
+ event = JSON.parse(line) as CodexEvent;
1095
+ } catch {
1096
+ throw new Error("Codex emitted invalid JSON output.");
1097
+ }
1098
+ if (event.type === "thread.started" && typeof event.thread_id === "string") {
1099
+ threadId = event.thread_id;
1100
+ }
1101
+ if (event.type === "error") {
1102
+ terminalError ??= `Codex error: ${typeof event.message === "string" && event.message.trim() ? event.message.trim() : "unknown error"}`;
1103
+ return;
1104
+ }
1105
+ if (event.type === "turn.failed") {
1106
+ sawTerminal = true;
1107
+ const err = event.error as { message?: unknown } | undefined;
1108
+ terminalError ??= `Codex failed: ${typeof err?.message === "string" && err.message.trim() ? err.message.trim() : "turn failed"}`;
1109
+ return;
1110
+ }
1111
+ if (event.type === "turn.completed") {
1112
+ sawTerminal = true;
1113
+ return;
1114
+ }
1115
+ const item = event.item;
1116
+ if (isAdvisor && (event.type === "item.started" || event.type === "item.completed")) {
1117
+ const activity = codexActivityFromEvent(event);
1118
+ if (activity) onProgress?.({ kind: "activity", label: activity, timestamp: Date.now() });
1119
+ }
1120
+ if (event.type === "item.completed" && item?.type === "agent_message") {
1121
+ const text = extractCodexItemText(item);
1122
+ if (text !== undefined) {
1123
+ final = text;
1124
+ if (!isAdvisor) {
1125
+ partial += `${partial ? "\n\n" : ""}${text}`;
1126
+ onProgress?.({ kind: "text", text: partial });
1127
+ }
1128
+ }
1129
+ }
1130
+ };
1131
+
1132
+ child.stdout?.setEncoding("utf8");
1133
+ child.stdout?.on("data", (chunk: string) => {
1134
+ stdoutBuffer += chunk;
1135
+ const parts = stdoutBuffer.split(/\r?\n/);
1136
+ stdoutBuffer = parts.pop() ?? "";
1137
+ if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
1138
+ protocolError ??= `Codex emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
1139
+ stdoutBuffer = "";
1140
+ attempt.stop("protocol");
1141
+ return;
1142
+ }
1143
+ for (const line of parts) {
1144
+ try {
1145
+ handleLine(line);
1146
+ } catch (error) {
1147
+ protocolError ??= errorMessage(error);
1148
+ attempt.stop("protocol");
1149
+ return;
1150
+ }
1151
+ }
1152
+ });
1153
+
1154
+ const { code, exitSignal } = await attempt.closed;
1155
+ attempt.dispose();
1156
+ if (stdoutBuffer.trim()) {
1157
+ try {
1158
+ handleLine(stdoutBuffer);
1159
+ } catch (error) {
1160
+ protocolError ??= errorMessage(error);
1161
+ }
1162
+ }
1163
+
1164
+ const partialText = partial ? { partialText: partial } : {};
1165
+ const cause = attempt.causeOf();
1166
+ if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", ...partialText };
1167
+ if (cause === "timeout") return { status: "timeout", message: `Codex timed out ${timeoutVerb}. Run \`/bro doctor\` for setup help.`, ...partialText };
1168
+ if (protocolError) return { status: "failure", message: withDoctor(protocolError), ...partialText };
1169
+ if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException, "Codex") };
1170
+ if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal, "Codex"), ...partialText };
1171
+ if (terminalError) return { status: "failure", message: withDoctor(terminalError), ...partialText };
1172
+ if (code !== 0) {
1173
+ const detail = stderr.trim();
1174
+ return {
1175
+ status: "failure",
1176
+ message: detail
1177
+ ? `Codex could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`
1178
+ : `Codex could not ${action}. Make sure \`codex\` is installed and signed in, then run \`/bro doctor\`.`,
1179
+ ...partialText,
1180
+ };
1181
+ }
1182
+ if (!sawTerminal) {
1183
+ return { status: "failure", message: withDoctor(`Codex exited without a terminal result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), ...partialText };
1184
+ }
1185
+ const text = final?.trim();
1186
+ if (!text) return { status: "failure", message: withDoctor(emptyTextMessage), ...partialText };
1187
+ if (!isBtw) return { status: "success", text };
1188
+ if (!threadId || (request.continuation && threadId !== request.continuation.id)) {
1189
+ return {
1190
+ status: "failure",
1191
+ message: withDoctor(`Codex reported an inconsistent session id (expected ${request.continuation?.id ?? "one id"}, got ${threadId ?? "none"}).`),
1192
+ ...partialText,
1193
+ };
1194
+ }
1195
+ return { status: "success", text, continuation: { id: threadId } };
1196
+ } finally {
1197
+ if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
1198
+ }
1199
+ }
1200
+
1201
+ type MuseEnvelope = {
1202
+ schema_version?: unknown;
1203
+ payload_type?: unknown;
1204
+ stream?: { kind?: unknown; id?: unknown };
1205
+ payload?: {
1206
+ kind?: unknown;
1207
+ command_id?: unknown;
1208
+ run_stream?: { kind?: unknown; id?: unknown };
1209
+ text?: unknown;
1210
+ reason?: unknown;
1211
+ correlation_facts?: { tool_name?: unknown };
1212
+ event?: { message?: unknown };
1213
+ command?: unknown;
1214
+ path?: unknown;
1215
+ file_path?: unknown;
1216
+ query?: unknown;
1217
+ };
1218
+ };
1219
+
1220
+ async function executeMuse(
1221
+ request: BackendRequest,
1222
+ selection: { model: string; effort?: string },
1223
+ signal: AbortSignal,
1224
+ onProgress: BackendOnProgress | undefined,
1225
+ killEscalationMs: number,
1226
+ deadlineMsOverride: number | undefined,
1227
+ ): Promise<BackendOutcome> {
1228
+ const isAdvisor = request.feature === "advisor";
1229
+ const isBtw = request.feature === "btw";
1230
+ const full = request.access === "workspace-full";
1231
+ const deadlineMs = deadlineMsOverride ?? (full ? 610_000 : isBtw ? 130_000 : 125_000);
1232
+ const action = isAdvisor ? "complete the advisor consultation" : isBtw ? "answer the side question" : "simplify the response";
1233
+ const timeoutVerb = isAdvisor ? "during the advisor consultation" : isBtw ? "during the side conversation" : "while simplifying the response";
1234
+ const emptyTextMessage = isAdvisor ? "Muse returned no advice." : isBtw ? "Muse returned no answer for the side question." : "Muse returned no final explanation.";
1235
+
1236
+ if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1237
+ const promptDirectory = await mkdtemp(join(tmpdir(), "pi-bro-muse-"));
1238
+ let runDirectory: string | undefined;
1239
+
1240
+ try {
1241
+ if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1242
+ const promptFile = join(promptDirectory, "prompt.txt");
1243
+ await writeFile(promptFile, request.prompt, { encoding: "utf8", mode: 0o600 });
1244
+ if (!isAdvisor && !isBtw) runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1245
+ if (signal.aborted) return { status: "cancelled", message: "Canceled." };
1246
+ const workspace = runDirectory ?? request.cwd!;
1247
+
1248
+ const args = [
1249
+ "exec",
1250
+ "--json",
1251
+ "--provider", "meta",
1252
+ "--workspace", workspace,
1253
+ ...(!isBtw ? ["--no-session-log"] : []),
1254
+ ...(full ? ["--yolo"] : ["--disable-approval", "--disable-write", "--disable-shell"]),
1255
+ ...(isBtw && request.continuation ? ["--session-id", request.continuation.id] : []),
1256
+ "--model", selection.model,
1257
+ ...(selection.effort ? ["--reasoning-effort", selection.effort] : []),
1258
+ "--prompt-file", promptFile,
1259
+ ];
1260
+
1261
+ const child = spawn("muse", args, {
1262
+ cwd: workspace,
1263
+ stdio: ["ignore", "pipe", "pipe"],
1264
+ windowsHide: true,
1265
+ detached: process.platform !== "win32",
1266
+ });
1267
+
1268
+ const attempt = beginAttempt(child, signal, deadlineMs, killEscalationMs);
1269
+ let processError: Error | undefined;
1270
+ let stderr = "";
1271
+ let partial = "";
1272
+ let rootCommandId: string | undefined;
1273
+ let rootRunId: string | undefined;
1274
+ let sessionId: string | undefined;
1275
+ let final: string | undefined;
1276
+ let sawTerminal = false;
1277
+ let terminalError: string | undefined;
1278
+ let protocolError: string | undefined;
1279
+ let stdoutBuffer = "";
1280
+
1281
+ child.stderr?.setEncoding("utf8");
1282
+ child.stderr?.on("data", (chunk: string) => {
1283
+ stderr += chunk;
1284
+ });
1285
+ child.once("error", (error) => {
1286
+ processError = error;
1287
+ });
1288
+
1289
+ const handleLine = (line: string) => {
1290
+ if (!line.trim() || attempt.causeOf()) return;
1291
+ let envelope: MuseEnvelope;
1292
+ try {
1293
+ envelope = JSON.parse(line) as MuseEnvelope;
1294
+ } catch {
1295
+ throw new Error("Muse emitted invalid JSON output.");
1296
+ }
1297
+ const payload = envelope.payload;
1298
+ if (envelope.stream?.kind === "session" && typeof envelope.stream.id === "string") {
1299
+ if (!sessionId) sessionId = envelope.stream.id;
1300
+ }
1301
+ if (envelope.payload_type === "runtime.command.accepted") {
1302
+ if (!rootCommandId && typeof payload?.command_id === "string") {
1303
+ rootCommandId = payload.command_id;
1304
+ }
1305
+ return;
1306
+ }
1307
+ if (!rootRunId && (envelope.payload_type === "session.run.linked" || envelope.payload_type === "run.lifecycle.started")) {
1308
+ const runStream = payload?.run_stream;
1309
+ if (runStream?.kind === "run" && typeof runStream.id === "string") {
1310
+ if (rootCommandId === undefined || payload?.command_id === rootCommandId) {
1311
+ rootRunId = runStream.id;
1312
+ if (envelope.stream?.kind === "session" && typeof envelope.stream.id === "string") {
1313
+ sessionId = envelope.stream.id;
1314
+ }
1315
+ }
1316
+ }
1317
+ return;
1318
+ }
1319
+ const runStream = payload?.run_stream;
1320
+ const isRoot = rootRunId !== undefined && runStream?.kind === "run" && runStream.id === rootRunId;
1321
+
1322
+ if (isAdvisor) {
1323
+ if (envelope.payload_type === "tool.result") {
1324
+ const facts = payload?.correlation_facts as { tool_name?: unknown } | undefined;
1325
+ const toolName = typeof facts?.tool_name === "string" && facts.tool_name ? facts.tool_name : "tool";
1326
+ const candidate = [payload?.text, payload?.command, payload?.path, payload?.file_path, payload?.query]
1327
+ .find((v) => typeof v === "string" && v.trim());
1328
+ const preview = typeof candidate === "string" ? candidate.replace(/\s+/g, " ").trim() : "";
1329
+ const label = `${toolName}${preview ? ` ${preview}` : ""}`;
1330
+ onProgress?.({ kind: "activity", label, timestamp: Date.now() });
1331
+ } else if (envelope.payload_type === "task.lifecycle.status") {
1332
+ const event = payload?.event as { message?: unknown } | undefined;
1333
+ if (typeof event?.message === "string" && event.message.trim()) {
1334
+ onProgress?.({ kind: "activity", label: event.message.trim(), timestamp: Date.now() });
1335
+ }
1336
+ }
1337
+ } else if (isRoot && envelope.payload_type === "run.output.delta") {
1338
+ if (typeof payload?.text === "string" && payload.text) {
1339
+ partial += payload.text;
1340
+ onProgress?.({ kind: "text", text: partial });
1341
+ }
1342
+ }
1343
+
1344
+ if (!isRoot) return;
1345
+ if (envelope.payload_type === "run.terminal.completed") {
1346
+ sawTerminal = true;
1347
+ if (typeof payload?.text === "string") {
1348
+ final = payload.text;
1349
+ }
1350
+ } else if (envelope.payload_type === "run.terminal.failed") {
1351
+ sawTerminal = true;
1352
+ const reason = typeof payload?.reason === "string" && payload.reason.trim() ? payload.reason.trim() : "run did not complete";
1353
+ terminalError ??= `Muse failed: ${reason}`;
1354
+ }
1355
+ };
1356
+
1357
+ child.stdout?.setEncoding("utf8");
1358
+ child.stdout?.on("data", (chunk: string) => {
1359
+ stdoutBuffer += chunk;
1360
+ const parts = stdoutBuffer.split(/\r?\n/);
1361
+ stdoutBuffer = parts.pop() ?? "";
1362
+ if (stdoutBuffer.length > ADVISOR_MAX_STDOUT_LINE_CHARS || parts.some((line) => line.length > ADVISOR_MAX_STDOUT_LINE_CHARS)) {
1363
+ protocolError ??= `Muse emitted a stdout line over ${ADVISOR_MAX_STDOUT_LINE_CHARS} characters; the stream is unparseable.`;
1364
+ stdoutBuffer = "";
1365
+ attempt.stop("protocol");
1366
+ return;
1367
+ }
1368
+ for (const line of parts) {
1369
+ try {
1370
+ handleLine(line);
1371
+ } catch (error) {
1372
+ protocolError ??= errorMessage(error);
1373
+ attempt.stop("protocol");
1374
+ return;
1375
+ }
1376
+ }
1377
+ });
1378
+
1379
+ const { code, exitSignal } = await attempt.closed;
1380
+ attempt.dispose();
1381
+ if (stdoutBuffer.trim()) {
1382
+ try {
1383
+ handleLine(stdoutBuffer);
1384
+ } catch (error) {
1385
+ protocolError ??= errorMessage(error);
1386
+ }
1387
+ }
1388
+
1389
+ const partialText = partial ? { partialText: partial } : {};
1390
+ const cause = attempt.causeOf();
1391
+ if (cause === "cancelled") return { status: "cancelled", message: "Canceled.", ...partialText };
1392
+ if (cause === "timeout") return { status: "timeout", message: `Muse timed out ${timeoutVerb}. Run \`/bro doctor\` for setup help.`, ...partialText };
1393
+ if (protocolError) return { status: "failure", message: withDoctor(protocolError), ...partialText };
1394
+ if (processError) return { status: "failure", message: processStartMessage(processError as NodeJS.ErrnoException, "Muse") };
1395
+ if (exitSignal || code === null) return { status: "failure", message: unexpectedSignalMessage(exitSignal, "Muse"), ...partialText };
1396
+ if (terminalError) return { status: "failure", message: withDoctor(terminalError), ...partialText };
1397
+ if (code !== 0) {
1398
+ const detail = stderr.trim();
1399
+ return {
1400
+ status: "failure",
1401
+ message: detail
1402
+ ? `Muse could not ${action}: ${detail}\n\nRun \`/bro doctor\` for setup help.`
1403
+ : `Muse could not ${action}. Make sure \`muse\` is installed and signed in, then run \`/bro doctor\`.`,
1404
+ ...partialText,
1405
+ };
1406
+ }
1407
+ if (!sawTerminal) {
1408
+ return { status: "failure", message: withDoctor(`Muse exited without a terminal result event${stderr.trim() ? `: ${stderr.trim()}` : "."}`), ...partialText };
1409
+ }
1410
+ const text = final?.trim();
1411
+ if (!text) return { status: "failure", message: withDoctor(emptyTextMessage), ...partialText };
1412
+ if (!isBtw) return { status: "success", text };
1413
+ if (!sessionId || (request.continuation && sessionId !== request.continuation.id)) {
1414
+ return {
1415
+ status: "failure",
1416
+ message: withDoctor(`Muse reported an inconsistent session id (expected ${request.continuation?.id ?? "one id"}, got ${sessionId ?? "none"}).`),
1417
+ ...partialText,
1418
+ };
1419
+ }
1420
+ return { status: "success", text, continuation: { id: sessionId } };
1421
+ } finally {
1422
+ if (runDirectory) await rm(runDirectory, { recursive: true, force: true });
1423
+ await rm(promptDirectory, { recursive: true, force: true });
1424
+ }
1425
+ }
1426
+
967
1427
  // Single-attempt executor shared by all four features. Never retries (retries are feature-owned,
968
1428
  // e.g. advisor's 3-attempt backoff in bro.ts); never spawns a pre-aborted request; on cancellation,
969
1429
  // host deadline, or a protocol failure, stops the whole POSIX process group (SIGTERM, then SIGKILL
@@ -984,10 +1444,30 @@ export async function execute(
984
1444
  ) return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
985
1445
  const backend = (selection as { backend?: unknown }).backend;
986
1446
  // An explicit tag guard: a stale or corrupt runtime tag must fail, never fall through to Agy.
987
- if (backend !== undefined && backend !== "agy" && backend !== "claude" && backend !== "grok") {
988
- return { status: "failure", message: `Unknown backend ${JSON.stringify(backend)}: pick Agy, Claude or Grok in \`/bro config\`.` };
1447
+ if (backend !== undefined && backend !== "agy" && backend !== "claude" && backend !== "grok" && backend !== "codex" && backend !== "muse") {
1448
+ return { status: "failure", message: `Unknown backend ${JSON.stringify(backend)}: pick Agy, Claude, Grok, Codex or Muse in \`/bro config\`.` };
989
1449
  }
990
1450
  const killEscalationMs = options?.killEscalationMs ?? DEFAULT_KILL_ESCALATION_MS;
1451
+ if (selection.backend === "codex") {
1452
+ // Codex btw always runs (and resumes) in the caller's workspace, even restricted.
1453
+ if (request.feature === "btw" && !request.cwd?.trim()) {
1454
+ return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1455
+ }
1456
+ if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !CODEX_EFFORTS.includes(selection.effort))) {
1457
+ return { status: "failure", message: "Unsupported Codex selection: check the model and effort (low, medium, high or xhigh)." };
1458
+ }
1459
+ return executeCodex(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
1460
+ }
1461
+ if (selection.backend === "muse") {
1462
+ // Muse btw always runs (and resumes) in the caller's workspace, even restricted.
1463
+ if (request.feature === "btw" && !request.cwd?.trim()) {
1464
+ return { status: "failure", message: "Unsupported execution request: check feature access, workspace cwd and continuation." };
1465
+ }
1466
+ if (typeof selection.model !== "string" || !selection.model.trim() || (selection.effort !== undefined && !MUSE_EFFORTS.includes(selection.effort))) {
1467
+ return { status: "failure", message: "Unsupported Muse selection: check the model and effort (minimal, low, medium, high, xhigh or max)." };
1468
+ }
1469
+ return executeMuse(request, selection, signal, onProgress, killEscalationMs, options?.deadlineMs);
1470
+ }
991
1471
  if (selection.backend === "grok") {
992
1472
  // Grok btw always runs (and resumes) in the caller's workspace, even restricted.
993
1473
  if (request.feature === "btw" && !request.cwd?.trim()) {