pi-bro 0.18.0 → 0.19.0

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,21 @@
2
2
 
3
3
  All notable changes to pi-bro are documented here.
4
4
 
5
+ ## [0.19.0] - 2026-09-25
6
+
7
+ ### Added
8
+
9
+ - 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`.
10
+ - 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).
11
+ - Configuration modal support in `/bro config` for Codex and Muse, including atomic model selection, custom model ID inputs, and dynamic reasoning effort dropdowns.
12
+ - Doctor diagnostic checks for Codex (version and `codex login status` authentication probe) and Muse (version probe).
13
+
14
+ ## [0.18.1] - 2026-09-30
15
+
16
+ ### Fixed
17
+
18
+ - Declare host-provided `typebox` and Pi packages as wildcard peer dependencies, keeping pinned copies only for development. This removes the host-dependency warning added in Pi 0.99.0 and avoids installing a separate runtime TypeBox copy.
19
+
5
20
  ## [0.18.0] - 2026-09-24
6
21
 
7
22
  ### Added
package/README.md CHANGED
@@ -7,11 +7,13 @@ plain-language explanation — or open a separate side conversation with
7
7
  `pi-bro` is an extension for [Earendil Pi](https://github.com/earendil-works/pi).
8
8
  It opens explanations in a separate modal and runs them through a CLI backend
9
9
  you already have installed and signed in to. Explain, show, BTW, and the
10
- advisor all work on all three backends:
10
+ advisor all work across five backends:
11
11
 
12
12
  - [Google Antigravity CLI](https://antigravity.google/docs/cli-install) (`agy`) — the default for new settings
13
13
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
14
14
  - Grok Build (`grok`)
15
+ - [Codex CLI](https://github.com/openai/codex) (`codex`)
16
+ - Meta Muse Code (`muse`)
15
17
 
16
18
  You only need the backend(s) you select; Agy is the default, not a requirement.
17
19
 
@@ -25,7 +27,7 @@ for the default). Then install Bro:
25
27
  pi install npm:pi-bro
26
28
  ```
27
29
 
28
- Restart Pi or run `/reload`. If you use Claude Code or Grok instead of Agy,
30
+ Restart Pi or run `/reload`. If you use Claude Code, Grok, Codex, or Muse instead of Agy,
29
31
  choose it in `/bro config` (for everything, or per feature). Then try:
30
32
 
31
33
  ```text
@@ -686,6 +688,9 @@ feature. Failed checks explain what to fix.
686
688
  - **Claude Code**: installed version and configured authentication.
687
689
  - **Grok**: installed version only; authentication and connectivity are not
688
690
  verified.
691
+ - **Codex**: installed version and configured authentication.
692
+ - **Muse**: installed version only; authentication and connectivity are not
693
+ verified.
689
694
 
690
695
  Doctor never sends source text or runs a model completion. A successful check
691
696
  confirms the setup but cannot guarantee that a later provider request will
@@ -729,7 +734,7 @@ Bro reads the file again before each request, so manual edits apply next time.
729
734
  Omitted overrides inherit `default`.
730
735
  - `effort` must be one the backend offers (`default` omits it and lets the
731
736
  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
737
+ catalog. Claude, Grok, Codex, and Muse accept seeded or custom model IDs; a model/effort
733
738
  combination the account does not support fails with the backend's own error
734
739
  instead of silently falling back.
735
740
  - `mode` is `brief`, `balanced` (the default), or `faithful`. `showTurns` is
@@ -751,19 +756,27 @@ data under their own settings and policies.
751
756
  | **Agy** | Temporary directory, Agy sandbox | Temporary directory, Agy sandbox | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
752
757
  | **Claude Code** | Scratch directory, tools/MCP/skills disabled, no session persistence | Workspace, tools disabled | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
753
758
  | **Grok** | Temporary directory, **prompt instruction only** | Workspace, **prompt instruction only** | Workspace, permissions bypassed | Fresh workspace process, permissions bypassed |
759
+ | **Codex** | Scratch directory, read-only sandbox, ephemeral session | Workspace, read-only sandbox | Workspace, approvals and sandbox bypassed | Fresh workspace process, approvals and sandbox bypassed |
760
+ | **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
761
 
755
762
  - Grok always runs with its sandbox off and permissions bypassed; its tools,
756
763
  hooks, skills, plugins, and MCP may remain available. "Answer only from the
757
764
  supplied context" is a request, not an enforced restriction.
765
+ - Codex's read-only sandbox is not a tool-free mode: workspace reads and
766
+ sandboxed commands remain available. Bro does not disable configured MCP
767
+ servers or web search; their access is governed by Codex configuration.
768
+ - Muse's restricted mode disables write and shell tools and approval prompts,
769
+ not all tools: workspace reads and web tools remain available without approval.
770
+ It is not a tool-free or network-isolated mode.
758
771
  - Claude's restrictions are tool/configuration settings (with safe mode), not
759
772
  an OS sandbox; built-in and managed Claude behavior can remain. Running a
760
773
  permission-bypassing mode as root may be rejected by Claude.
761
774
  - 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.
775
+ `--resume`, Codex by `resume`, and Muse by `--session-id` of the same session,
776
+ including across `/mode` switches. An Agy conversation stays bound to the
777
+ workspace it started in, so after a `/mode` switch — or whenever a thread
778
+ has turns but no native session ID — Bro starts a fresh native session
779
+ seeded with the main-session context and every earlier turn.
767
780
  - Cancellation, deadlines, and invalid output streams stop the backend's
768
781
  process group on POSIX, escalating after a five-second grace period. On
769
782
  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()) {
package/bro.ts CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  advisorFlagErrorHint,
24
24
  CLAUDE_EFFORTS,
25
25
  GROK_EFFORTS,
26
+ CODEX_EFFORTS,
27
+ MUSE_EFFORTS,
26
28
  backendSupports,
27
29
  execute as executeBackend,
28
30
  parseBtwAgyLine,
@@ -67,13 +69,15 @@ type BtwTurn = { question: string; answer: string };
67
69
  // thread; `sessionFull` is the access mode the native session last ran in.
68
70
  type BtwThread = { turns: BtwTurn[]; conversationId?: string; full: boolean; backend?: BackendName; model?: string; context?: string; sessionFull?: boolean };
69
71
  const EFFORTS = ["default", "low", "medium", "high"] as const;
70
- const BACKENDS = ["agy", "claude", "grok"] as const;
72
+ const BACKENDS = ["agy", "claude", "grok", "codex", "muse"] as const;
71
73
  type BackendName = (typeof BACKENDS)[number];
72
- type BroEffort = "default" | "low" | "medium" | "high" | "xhigh" | "max";
73
- type AgyEffort = Exclude<BroEffort, "default" | "xhigh" | "max">;
74
+ type BroEffort = "default" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
75
+ type AgyEffort = Exclude<BroEffort, "default" | "minimal" | "xhigh" | "max">;
74
76
  type ClaudeEffort = (typeof CLAUDE_EFFORTS)[number];
75
- export { GROK_EFFORTS };
77
+ export { GROK_EFFORTS, CODEX_EFFORTS, MUSE_EFFORTS };
76
78
  type GrokEffort = (typeof GROK_EFFORTS)[number];
79
+ type CodexEffort = (typeof CODEX_EFFORTS)[number];
80
+ type MuseEffort = (typeof MUSE_EFFORTS)[number];
77
81
  // "advisor" is a real capability (command, Agy invocation, Doctor check) like the other three;
78
82
  // see docs/plans/2026-09-19-bro-advisor-design.md. All four share one model/effort resolution,
79
83
  // override, and Doctor-check path via this single list -- there is no configuration-only tier.
@@ -99,6 +103,14 @@ export const GROK_MODELS = [
99
103
  { id: "grok-4.7", label: "Grok 4.7" },
100
104
  { id: "grok-4.7-build-fast", label: "Grok 4.7 Build Fast" },
101
105
  ] as const;
106
+ export const CODEX_MODELS = [
107
+ { id: "gpt-5.5", label: "GPT-5.5" },
108
+ { id: "gpt-5.4", label: "GPT-5.4" },
109
+ ] as const;
110
+ export const MUSE_MODELS = [
111
+ { id: "muse-spark-1.3-contributor", label: "Muse Spark 1.3 Contributor" },
112
+ { id: "muse-spark-1.3", label: "Muse Spark 1.3" },
113
+ ] as const;
102
114
  export { backendSupports as supportsBackend };
103
115
  type AgyModelFamily = {
104
116
  id: string;
@@ -508,7 +520,7 @@ export async function extractWebPage(input: string, signal?: AbortSignal): Promi
508
520
 
509
521
  function parseBackend(value: unknown, context: string): BackendName {
510
522
  if (typeof value !== "string" || !BACKENDS.some((backend) => backend === value)) {
511
- throw new Error(`${context} backend must be "agy", "claude", or "grok".`);
523
+ throw new Error(`${context} backend must be "agy", "claude", "grok", "codex", or "muse".`);
512
524
  }
513
525
  return value as BackendName;
514
526
  }
@@ -525,6 +537,14 @@ function isGrokEffort(effort: unknown): effort is GrokEffort | "default" {
525
537
  return effort === "default" || GROK_EFFORTS.some((item) => item === effort);
526
538
  }
527
539
 
540
+ function isCodexEffort(effort: unknown): effort is CodexEffort | "default" {
541
+ return effort === "default" || CODEX_EFFORTS.some((item) => item === effort);
542
+ }
543
+
544
+ function isMuseEffort(effort: unknown): effort is MuseEffort | "default" {
545
+ return effort === "default" || MUSE_EFFORTS.some((item) => item === effort);
546
+ }
547
+
528
548
  function parseModelEffortPair(value: unknown, context: string): ModelEffortPair {
529
549
  if (!isRecord(value) || typeof value.model !== "string" || !value.model.trim()) {
530
550
  throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
@@ -542,6 +562,18 @@ function parseModelEffortPair(value: unknown, context: string): ModelEffortPair
542
562
  }
543
563
  return { backend, model: value.model.trim(), effort: value.effort };
544
564
  }
565
+ if (backend === "codex") {
566
+ if (!isCodexEffort(value.effort)) {
567
+ throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".`);
568
+ }
569
+ return { backend, model: value.model.trim(), effort: value.effort };
570
+ }
571
+ if (backend === "muse") {
572
+ if (!isMuseEffort(value.effort)) {
573
+ throw new Error(`${context} must contain a model and effort set to "default", "minimal", "low", "medium", "high", "xhigh", or "max".`);
574
+ }
575
+ return { backend, model: value.model.trim(), effort: value.effort };
576
+ }
545
577
  if (!isAgyEffort(value.effort)) {
546
578
  throw new Error(`${context} must contain a model and effort set to "default", "low", "medium", or "high".`);
547
579
  }
@@ -590,14 +622,25 @@ export function parseBroSettings(value: unknown): BroSettings {
590
622
  throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
591
623
  }
592
624
  const backend = value.backend === undefined ? undefined : parseBackend(value.backend, "Settings");
593
- const effortOk = backend === "claude" ? isClaudeEffort(value.effort) : backend === "grok" ? isGrokEffort(value.effort) : isAgyEffort(value.effort);
625
+ const effortOk =
626
+ backend === "claude"
627
+ ? isClaudeEffort(value.effort)
628
+ : backend === "grok"
629
+ ? isGrokEffort(value.effort)
630
+ : backend === "codex"
631
+ ? isCodexEffort(value.effort)
632
+ : backend === "muse"
633
+ ? isMuseEffort(value.effort)
634
+ : isAgyEffort(value.effort);
594
635
  if (!effortOk) {
595
636
  throw new Error(
596
637
  backend === "claude"
597
638
  ? 'Settings must contain a model and effort set to "default", "low", "medium", "high", "xhigh", or "max".'
598
- : backend === "grok"
639
+ : backend === "grok" || backend === "codex"
599
640
  ? 'Settings must contain a model and effort set to "default", "low", "medium", "high", or "xhigh".'
600
- : 'Settings must contain a model and effort set to "default", "low", "medium", or "high".',
641
+ : backend === "muse"
642
+ ? 'Settings must contain a model and effort set to "default", "minimal", "low", "medium", "high", "xhigh", or "max".'
643
+ : 'Settings must contain a model and effort set to "default", "low", "medium", or "high".',
601
644
  );
602
645
  }
603
646
  const mode = value.mode === undefined ? DEFAULT_BRO_MODE : parseBroMode(value.mode);
@@ -649,8 +692,8 @@ export function resolveModelEffort(
649
692
  pair: ModelEffortPair,
650
693
  families: AgyModelFamily[],
651
694
  ): { pair: ModelEffortPair; family?: AgyModelFamily } {
652
- // Claude/Grok selections never resolve through the Agy catalog; they pass through untouched.
653
- if (pair.backend === "claude" || pair.backend === "grok") return { pair };
695
+ // Claude/Grok/Codex/Muse selections never resolve through the Agy catalog; they pass through untouched.
696
+ if (pair.backend === "claude" || pair.backend === "grok" || pair.backend === "codex" || pair.backend === "muse") return { pair };
654
697
  const family = families.find((item) => item.id === pair.model || item.variants.some((variant) => variant.id === pair.model));
655
698
  if (!family) return { pair };
656
699
  const variant = family.variants.find((item) => item.id === pair.model);
@@ -688,17 +731,41 @@ export function resolveGrokModel(input: string): string {
688
731
  return trimmed;
689
732
  }
690
733
 
734
+ // Codex seeds resolve to themselves; any other non-empty string passes through
735
+ // untouched as an explicit user-entered model ID.
736
+ export function resolveCodexModel(input: string): string {
737
+ const trimmed = input.trim();
738
+ if (!trimmed) throw new Error("Codex model must be a non-empty model ID (gpt-5.5, gpt-5.4, or an explicit model ID).");
739
+ return trimmed;
740
+ }
741
+
742
+ // Muse seeds resolve to themselves; any other non-empty string passes through
743
+ // untouched as an explicit user-entered model ID.
744
+ export function resolveMuseModel(input: string): string {
745
+ const trimmed = input.trim();
746
+ if (!trimmed) throw new Error("Muse model must be a non-empty model ID (muse-spark-1.3-contributor, muse-spark-1.3, or an explicit model ID).");
747
+ return trimmed;
748
+ }
749
+
691
750
  // Routes one capability's whole pair to its backend execution selection. Agy keeps the
692
- // existing model/effort split; Claude/Grok carry the model plus an optional effort
751
+ // existing model/effort split; Claude/Grok/Codex/Muse carry the model plus an optional effort
693
752
  // ("default" means the CLI's own default and is omitted). Throws for an effort the
694
753
  // resolved backend does not support instead of silently sending a mismatched pair.
695
754
  export function selectionForCapability(settings: BroSettings, capability: Capability): BackendSelection {
696
755
  const pair = capabilityPair(settings, capability);
697
756
  const backend = capabilityBackend(settings, capability);
698
- if (backend === "claude" || backend === "grok") {
699
- const valid = backend === "claude" ? isClaudeEffort(pair.effort) : isGrokEffort(pair.effort);
757
+ if (backend === "claude" || backend === "grok" || backend === "codex" || backend === "muse") {
758
+ const valid =
759
+ backend === "claude"
760
+ ? isClaudeEffort(pair.effort)
761
+ : backend === "grok"
762
+ ? isGrokEffort(pair.effort)
763
+ : backend === "codex"
764
+ ? isCodexEffort(pair.effort)
765
+ : isMuseEffort(pair.effort);
700
766
  if (!valid) {
701
- throw new Error(`\`${pair.effort}\` is not supported on the ${backend === "claude" ? "Claude" : "Grok"} backend. Run \`/bro config\` to fix this.`);
767
+ const label = backend === "claude" ? "Claude" : backend === "grok" ? "Grok" : backend === "codex" ? "Codex" : "Muse";
768
+ throw new Error(`\`${pair.effort}\` is not supported on the ${label} backend. Run \`/bro config\` to fix this.`);
702
769
  }
703
770
  return (
704
771
  pair.effort === "default"
@@ -970,6 +1037,67 @@ async function checkGrokVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<
970
1037
  }
971
1038
  }
972
1039
 
1040
+ async function checkCodexVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
1041
+ const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1042
+ try {
1043
+ const result = await pi.exec("codex", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
1044
+ if (signal.aborted) throw new Error("Canceled.");
1045
+ if (result.killed || result.code !== 0) {
1046
+ const detail = result.stderr.trim() || result.stdout.trim();
1047
+ throw new Error(
1048
+ detail
1049
+ ? `Codex could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
1050
+ : "Codex could not start. Make sure Codex is installed and on PATH, then run `/bro doctor`.",
1051
+ );
1052
+ }
1053
+ const version = result.stdout.trim() || result.stderr.trim();
1054
+ if (!version) throw new Error("Codex returned no version information. Update Codex, then run `/bro doctor` again.");
1055
+ return version;
1056
+ } finally {
1057
+ await rm(runDirectory, { recursive: true, force: true });
1058
+ }
1059
+ }
1060
+
1061
+ // Auth status only: a version/auth answer without a model request. A passing answer here
1062
+ // says the CLI starts and reports signed-in state -- it does not imply connectivity.
1063
+ async function checkCodexAuth(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
1064
+ const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1065
+ try {
1066
+ const result = await pi.exec("codex", ["login", "status"], { cwd: runDirectory, signal, timeout: 15_000 });
1067
+ if (signal.aborted) throw new Error("Canceled.");
1068
+ if (result.killed || result.code !== 0) {
1069
+ const detail = result.stderr.trim() || result.stdout.trim();
1070
+ throw new Error(detail ? `Codex auth status: ${detail}` : "Codex is not logged in. Run `codex login`.");
1071
+ }
1072
+ const status = result.stdout.trim() || result.stderr.trim();
1073
+ if (!status || !/logged in/i.test(status)) throw new Error("Codex is not logged in. Run `codex login`.");
1074
+ return "authentication configured (not a connectivity test)";
1075
+ } finally {
1076
+ await rm(runDirectory, { recursive: true, force: true });
1077
+ }
1078
+ }
1079
+
1080
+ async function checkMuseVersion(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
1081
+ const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
1082
+ try {
1083
+ const result = await pi.exec("muse", ["--version"], { cwd: runDirectory, signal, timeout: 10_000 });
1084
+ if (signal.aborted) throw new Error("Canceled.");
1085
+ if (result.killed || result.code !== 0) {
1086
+ const detail = result.stderr.trim() || result.stdout.trim();
1087
+ throw new Error(
1088
+ detail
1089
+ ? `Muse could not start: ${detail}\n\nRun \`/bro doctor\` for setup help.`
1090
+ : "Muse could not start. Make sure Muse is installed and on PATH, then run `/bro doctor`.",
1091
+ );
1092
+ }
1093
+ const version = result.stdout.trim() || result.stderr.trim();
1094
+ if (!version) throw new Error("Muse returned no version information. Update Muse, then run `/bro doctor` again.");
1095
+ return version;
1096
+ } finally {
1097
+ await rm(runDirectory, { recursive: true, force: true });
1098
+ }
1099
+ }
1100
+
973
1101
  async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
974
1102
  const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
975
1103
  try {
@@ -1016,11 +1144,13 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1016
1144
  fail("Prompt", error);
1017
1145
  }
1018
1146
 
1019
- // Probe only the backends some feature actually selects: a Claude/Grok-only setup never
1147
+ // Probe only the backends some feature actually selects: a Claude/Grok/Codex/Muse-only setup never
1020
1148
  // requires Agy to be installed, and vice versa.
1021
1149
  const agyInUse = !settings || capabilityBackend(settings, "explain") === "agy" || capabilityBackend(settings, "show") === "agy" || capabilityBackend(settings, "btw") === "agy" || capabilityBackend(settings, "advisor") === "agy" || (settings.backend ?? "agy") === "agy";
1022
1150
  const claudeInUse = !!settings && (capabilityBackend(settings, "explain") === "claude" || capabilityBackend(settings, "show") === "claude" || capabilityBackend(settings, "btw") === "claude" || capabilityBackend(settings, "advisor") === "claude" || settings.backend === "claude");
1023
1151
  const grokInUse = !!settings && (capabilityBackend(settings, "explain") === "grok" || capabilityBackend(settings, "show") === "grok" || capabilityBackend(settings, "btw") === "grok" || capabilityBackend(settings, "advisor") === "grok" || settings.backend === "grok");
1152
+ const codexInUse = !!settings && (capabilityBackend(settings, "explain") === "codex" || capabilityBackend(settings, "show") === "codex" || capabilityBackend(settings, "btw") === "codex" || capabilityBackend(settings, "advisor") === "codex" || settings.backend === "codex");
1153
+ const museInUse = !!settings && (capabilityBackend(settings, "explain") === "muse" || capabilityBackend(settings, "show") === "muse" || capabilityBackend(settings, "btw") === "muse" || capabilityBackend(settings, "advisor") === "muse" || settings.backend === "muse");
1024
1154
 
1025
1155
  let agyStarted = false;
1026
1156
  if (agyInUse) {
@@ -1087,6 +1217,38 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1087
1217
  pass("Grok", "not probed — no feature selects the Grok backend");
1088
1218
  }
1089
1219
 
1220
+ // Version and auth status only, never a model request.
1221
+ if (codexInUse) {
1222
+ try {
1223
+ pass("Codex", await checkCodexVersion(pi, signal));
1224
+ } catch (error) {
1225
+ if (signal.aborted) throw error;
1226
+ fail("Codex", error);
1227
+ }
1228
+ try {
1229
+ pass("Codex auth", await checkCodexAuth(pi, signal));
1230
+ } catch (error) {
1231
+ if (signal.aborted) throw error;
1232
+ fail("Codex auth", error);
1233
+ }
1234
+ } else {
1235
+ pass("Codex", "not probed — no feature selects the Codex backend");
1236
+ }
1237
+
1238
+ // Version only, never a model request. No Muse auth probe exists, so a passing
1239
+ // version says the CLI starts — it says nothing about auth or connectivity.
1240
+ if (museInUse) {
1241
+ try {
1242
+ pass("Muse", await checkMuseVersion(pi, signal));
1243
+ } catch (error) {
1244
+ if (signal.aborted) throw error;
1245
+ fail("Muse", error);
1246
+ }
1247
+ pass("Muse auth", "unverified — no auth probe exists (version does not imply auth or connectivity)");
1248
+ } else {
1249
+ pass("Muse", "not probed — no feature selects the Muse backend");
1250
+ }
1251
+
1090
1252
  if (settings) {
1091
1253
  const defaultBackend = settings.backend ?? "agy";
1092
1254
  if (defaultBackend === "claude") {
@@ -1107,6 +1269,24 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1107
1269
  } else {
1108
1270
  pass("Reasoning effort", settings.effort);
1109
1271
  }
1272
+ } else if (defaultBackend === "codex") {
1273
+ pass("Selected model", `codex \`${settings.model}\``);
1274
+ if (!isCodexEffort(settings.effort)) {
1275
+ fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
1276
+ } else if (settings.effort === "default") {
1277
+ pass("Reasoning effort", "built into the selected model");
1278
+ } else {
1279
+ pass("Reasoning effort", settings.effort);
1280
+ }
1281
+ } else if (defaultBackend === "muse") {
1282
+ pass("Selected model", `muse \`${settings.model}\``);
1283
+ if (!isMuseEffort(settings.effort)) {
1284
+ fail("Reasoning effort", `\`${settings.effort}\` is unsupported. Run \`/bro config\` to choose another.`);
1285
+ } else if (settings.effort === "default") {
1286
+ pass("Reasoning effort", "built into the selected model");
1287
+ } else {
1288
+ pass("Reasoning effort", settings.effort);
1289
+ }
1110
1290
  } else if (models) {
1111
1291
  const current = resolveCatalogSettings(settings, models);
1112
1292
  if (!current.family) {
@@ -1159,6 +1339,40 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1159
1339
  );
1160
1340
  continue;
1161
1341
  }
1342
+ if (backend === "codex") {
1343
+ if (!backendSupports("codex", capability)) {
1344
+ fail(label, `\`${capability}\` is not supported on the Codex backend for this capability. Run \`/bro config\` to give it another backend.`);
1345
+ continue;
1346
+ }
1347
+ if (!isCodexEffort(pair.effort)) {
1348
+ fail(label, `\`${pair.effort}\` is unsupported for codex \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1349
+ continue;
1350
+ }
1351
+ pass(
1352
+ label,
1353
+ override
1354
+ ? `override codex \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
1355
+ : `codex \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
1356
+ );
1357
+ continue;
1358
+ }
1359
+ if (backend === "muse") {
1360
+ if (!backendSupports("muse", capability)) {
1361
+ fail(label, `\`${capability}\` is not supported on the Muse backend for this capability. Run \`/bro config\` to give it another backend.`);
1362
+ continue;
1363
+ }
1364
+ if (!isMuseEffort(pair.effort)) {
1365
+ fail(label, `\`${pair.effort}\` is unsupported for muse \`${pair.model}\`. Run \`/bro config\` to fix this.`);
1366
+ continue;
1367
+ }
1368
+ pass(
1369
+ label,
1370
+ override
1371
+ ? `override muse \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`}`
1372
+ : `muse \`${pair.model}\`${pair.effort === "default" ? "" : ` (${pair.effort})`} · using the shared default`,
1373
+ );
1374
+ continue;
1375
+ }
1162
1376
  if (!models) continue;
1163
1377
  const resolved = resolveCapabilitySettings(settings, capability, models);
1164
1378
  if (!resolved.family) {
@@ -1193,13 +1407,17 @@ async function doctorReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, sign
1193
1407
  pass("Advisor compatibility", "Claude backend — no Agy version floor applies");
1194
1408
  } else if (settings && capabilityBackend(settings, "advisor") === "grok") {
1195
1409
  pass("Advisor compatibility", "Grok backend — no Agy version floor applies; auth/connectivity unverified (no probe)");
1410
+ } else if (settings && capabilityBackend(settings, "advisor") === "codex") {
1411
+ pass("Advisor compatibility", "Codex backend — no Agy version floor applies");
1412
+ } else if (settings && capabilityBackend(settings, "advisor") === "muse") {
1413
+ pass("Advisor compatibility", "Muse backend — no Agy version floor applies; auth/connectivity unverified (no probe)");
1196
1414
  } else if (agyVersion && advisorAgyCompatible(agyVersion)) pass("Advisor compatibility", ADVISOR_COMPATIBILITY);
1197
1415
  else if (agyVersion) fail("Advisor compatibility", `installed \`${agyVersion}\`; requires Agy >=1.1.15. Run \`agy update\`.`);
1198
1416
 
1199
1417
  return `# Bro doctor\n\n${lines.join("\n")}\n\n**${failed ? "Bro needs attention." : "Bro is ready."}**\n\n${
1200
1418
  failed
1201
1419
  ? "Fix the failed items, then press **R** to check again."
1202
- : "No assistant response was sent and no model turn was run. Version/auth checks do not imply connectivity (Grok auth is unverified — no probe exists)."
1420
+ : "No assistant response was sent and no model turn was run. Version/auth checks do not imply connectivity (Grok and Muse auth are unverified — no probe exists)."
1203
1421
  }`;
1204
1422
  }
1205
1423
 
@@ -1787,7 +2005,7 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
1787
2005
  return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
1788
2006
  }
1789
2007
  const latest = details.activity?.at(-1);
1790
- const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : "Agy";
2008
+ const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : details.backend === "codex" ? "Codex" : details.backend === "muse" ? "Muse" : "Agy";
1791
2009
  // "last reported" + freshness, never a claim about what the backend is doing right now and never
1792
2010
  // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
1793
2011
  const activityLabel = latest
@@ -1807,10 +2025,12 @@ function showTurnsValues(current: number): string[] {
1807
2025
  // falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
1808
2026
  // resolved family's supported efforts instead of silently showing it as if it were valid.
1809
2027
  function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamily }, backend: BackendName): string {
1810
- // Claude/Grok selections never touch the Agy catalog: the stored effort is valid exactly
2028
+ // Claude/Grok/Codex/Muse selections never touch the Agy catalog: the stored effort is valid exactly
1811
2029
  // when it is one of that backend's levels (or "default" for the CLI's own default).
1812
2030
  if (backend === "claude") return isClaudeEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1813
2031
  if (backend === "grok") return isGrokEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
2032
+ if (backend === "codex") return isCodexEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
2033
+ if (backend === "muse") return isMuseEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1814
2034
  if (!resolved.family) return "unavailable";
1815
2035
  const fixed = !resolved.family.efforts.length;
1816
2036
  const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
@@ -1818,7 +2038,7 @@ function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamil
1818
2038
  return fixed ? "fixed" : resolved.pair.effort;
1819
2039
  }
1820
2040
 
1821
- // Model-picker values that switch backend carry a "claude:"/"grok:" prefix so one
2041
+ // Model-picker values that switch backend carry a "claude:"/"grok:"/"codex:"/"muse:" prefix so one
1822
2042
  // atomic picker commit changes backend+model together -- cancelling the picker (Esc)
1823
2043
  // leaves both unchanged via the existing submenu-cancel path.
1824
2044
  const CLAUDE_OPTION_PREFIX = "claude:";
@@ -1833,11 +2053,27 @@ function parseGrokOption(value: string): string | undefined {
1833
2053
  ? value.slice(GROK_OPTION_PREFIX.length)
1834
2054
  : undefined;
1835
2055
  }
1836
- function parseBackendOption(value: string): { backend: "claude" | "grok"; id: string } | undefined {
2056
+ const CODEX_OPTION_PREFIX = "codex:";
2057
+ function parseCodexOption(value: string): string | undefined {
2058
+ return value.startsWith(CODEX_OPTION_PREFIX) && value.length > CODEX_OPTION_PREFIX.length
2059
+ ? value.slice(CODEX_OPTION_PREFIX.length)
2060
+ : undefined;
2061
+ }
2062
+ const MUSE_OPTION_PREFIX = "muse:";
2063
+ function parseMuseOption(value: string): string | undefined {
2064
+ return value.startsWith(MUSE_OPTION_PREFIX) && value.length > MUSE_OPTION_PREFIX.length
2065
+ ? value.slice(MUSE_OPTION_PREFIX.length)
2066
+ : undefined;
2067
+ }
2068
+ function parseBackendOption(value: string): { backend: "claude" | "grok" | "codex" | "muse"; id: string } | undefined {
1837
2069
  const claudeId = parseClaudeOption(value);
1838
2070
  if (claudeId !== undefined) return { backend: "claude", id: claudeId };
1839
2071
  const grokId = parseGrokOption(value);
1840
2072
  if (grokId !== undefined) return { backend: "grok", id: grokId };
2073
+ const codexId = parseCodexOption(value);
2074
+ if (codexId !== undefined) return { backend: "codex", id: codexId };
2075
+ const museId = parseMuseOption(value);
2076
+ if (museId !== undefined) return { backend: "muse", id: museId };
1841
2077
  return undefined;
1842
2078
  }
1843
2079
 
@@ -1877,7 +2113,7 @@ export function createConfigModal(
1877
2113
  value: family.id,
1878
2114
  label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
1879
2115
  })),
1880
- // Claude/Grok entries stay last so existing Agy keyboard navigation is unaffected.
2116
+ // Alternative CLI entries stay last so existing Agy keyboard navigation is unaffected.
1881
2117
  ...CLAUDE_MODELS.map((model) => ({
1882
2118
  value: `${CLAUDE_OPTION_PREFIX}${model.id}`,
1883
2119
  label: `${model.label} · claude`,
@@ -1886,13 +2122,33 @@ export function createConfigModal(
1886
2122
  value: `${GROK_OPTION_PREFIX}${model.id}`,
1887
2123
  label: `${model.label} · grok`,
1888
2124
  })),
2125
+ ...CODEX_MODELS.map((model) => ({
2126
+ value: `${CODEX_OPTION_PREFIX}${model.id}`,
2127
+ label: `${model.label} · codex`,
2128
+ })),
2129
+ ...MUSE_MODELS.map((model) => ({
2130
+ value: `${MUSE_OPTION_PREFIX}${model.id}`,
2131
+ label: `${model.label} · muse`,
2132
+ })),
1889
2133
  ];
1890
2134
  options.push({ value: "__claude_custom__", label: "Claude · custom model ID…" });
1891
2135
  options.push({ value: "__grok_custom__", label: "Grok · custom model ID…" });
2136
+ options.push({ value: "__codex_custom__", label: "Codex · custom model ID…" });
2137
+ options.push({ value: "__muse_custom__", label: "Muse · custom model ID…" });
1892
2138
  const input = new Input();
1893
- let enteringBackend: "claude" | "grok" | undefined;
2139
+ let enteringBackend: "claude" | "grok" | "codex" | "muse" | undefined;
1894
2140
  input.onSubmit = (value) => {
1895
- if (value.trim() && enteringBackend) pickerDone(`${enteringBackend === "claude" ? CLAUDE_OPTION_PREFIX : GROK_OPTION_PREFIX}${value.trim()}`);
2141
+ if (value.trim() && enteringBackend) {
2142
+ const prefix =
2143
+ enteringBackend === "claude"
2144
+ ? CLAUDE_OPTION_PREFIX
2145
+ : enteringBackend === "grok"
2146
+ ? GROK_OPTION_PREFIX
2147
+ : enteringBackend === "codex"
2148
+ ? CODEX_OPTION_PREFIX
2149
+ : MUSE_OPTION_PREFIX;
2150
+ pickerDone(`${prefix}${value.trim()}`);
2151
+ }
1896
2152
  };
1897
2153
  const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
1898
2154
  const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
@@ -1900,11 +2156,14 @@ export function createConfigModal(
1900
2156
  picker.onSelect = (item) => {
1901
2157
  if (item.value === "__claude_custom__") { enteringBackend = "claude"; tui.requestRender(); }
1902
2158
  else if (item.value === "__grok_custom__") { enteringBackend = "grok"; tui.requestRender(); }
2159
+ else if (item.value === "__codex_custom__") { enteringBackend = "codex"; tui.requestRender(); }
2160
+ else if (item.value === "__muse_custom__") { enteringBackend = "muse"; tui.requestRender(); }
1903
2161
  else pickerDone(item.value);
1904
2162
  };
1905
2163
  picker.onCancel = () => pickerDone();
2164
+ const customLabel = () => enteringBackend === "claude" ? "Claude" : enteringBackend === "grok" ? "Grok" : enteringBackend === "codex" ? "Codex" : "Muse";
1906
2165
  return {
1907
- render: (width: number) => enteringBackend ? [`${enteringBackend === "claude" ? "Claude" : "Grok"} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
2166
+ render: (width: number) => enteringBackend ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
1908
2167
  invalidate: () => { picker.invalidate(); input.invalidate(); },
1909
2168
  handleInput: (data: string) => {
1910
2169
  if (enteringBackend && matchesKey(data, "escape")) pickerDone();
@@ -1945,9 +2204,29 @@ export function createConfigModal(
1945
2204
  function refresh(): void {
1946
2205
  const defaultBackend = settings.backend ?? "agy";
1947
2206
  const def = resolveModelEffort({ backend: settings.backend, model: settings.model, effort: settings.effort }, families);
1948
- modelItem.currentValue = defaultBackend === "claude" ? `${CLAUDE_OPTION_PREFIX}${settings.model}` : defaultBackend === "grok" ? `${GROK_OPTION_PREFIX}${settings.model}` : (def.family?.id ?? settings.model);
2207
+ modelItem.currentValue =
2208
+ defaultBackend === "claude"
2209
+ ? `${CLAUDE_OPTION_PREFIX}${settings.model}`
2210
+ : defaultBackend === "grok"
2211
+ ? `${GROK_OPTION_PREFIX}${settings.model}`
2212
+ : defaultBackend === "codex"
2213
+ ? `${CODEX_OPTION_PREFIX}${settings.model}`
2214
+ : defaultBackend === "muse"
2215
+ ? `${MUSE_OPTION_PREFIX}${settings.model}`
2216
+ : (def.family?.id ?? settings.model);
1949
2217
  effortItem.currentValue = effortDisplay(def, defaultBackend);
1950
- effortItem.values = defaultBackend === "claude" ? ["default", ...CLAUDE_EFFORTS] : defaultBackend === "grok" ? ["default", ...GROK_EFFORTS] : def.family?.efforts.length ? [...def.family.efforts] : undefined;
2218
+ effortItem.values =
2219
+ defaultBackend === "claude"
2220
+ ? ["default", ...CLAUDE_EFFORTS]
2221
+ : defaultBackend === "grok"
2222
+ ? ["default", ...GROK_EFFORTS]
2223
+ : defaultBackend === "codex"
2224
+ ? ["default", ...CODEX_EFFORTS]
2225
+ : defaultBackend === "muse"
2226
+ ? ["default", ...MUSE_EFFORTS]
2227
+ : def.family?.efforts.length
2228
+ ? [...def.family.efforts]
2229
+ : undefined;
1951
2230
  modeItem.currentValue = settings.mode;
1952
2231
  showTurnsItem.currentValue = String(settings.showTurns);
1953
2232
  showTurnsItem.values = showTurnsValues(settings.showTurns);
@@ -1963,9 +2242,24 @@ export function createConfigModal(
1963
2242
  ? `${CLAUDE_OPTION_PREFIX}${override.model}`
1964
2243
  : backend === "grok"
1965
2244
  ? `${GROK_OPTION_PREFIX}${override.model}`
1966
- : (resolved.family?.id ?? override.model);
2245
+ : backend === "codex"
2246
+ ? `${CODEX_OPTION_PREFIX}${override.model}`
2247
+ : backend === "muse"
2248
+ ? `${MUSE_OPTION_PREFIX}${override.model}`
2249
+ : (resolved.family?.id ?? override.model);
1967
2250
  rows.effort.currentValue = backendSupports(backend, capability) ? effortDisplay(resolved, backend) : "unsupported backend";
1968
- rows.effort.values = backend === "claude" ? ["default", ...CLAUDE_EFFORTS] : backend === "grok" ? ["default", ...GROK_EFFORTS] : resolved.family?.efforts.length ? [...resolved.family.efforts] : undefined;
2251
+ rows.effort.values =
2252
+ backend === "claude"
2253
+ ? ["default", ...CLAUDE_EFFORTS]
2254
+ : backend === "grok"
2255
+ ? ["default", ...GROK_EFFORTS]
2256
+ : backend === "codex"
2257
+ ? ["default", ...CODEX_EFFORTS]
2258
+ : backend === "muse"
2259
+ ? ["default", ...MUSE_EFFORTS]
2260
+ : resolved.family?.efforts.length
2261
+ ? [...resolved.family.efforts]
2262
+ : undefined;
1969
2263
  }
1970
2264
  }
1971
2265
  refresh();
@@ -2025,9 +2319,15 @@ export function createConfigModal(
2025
2319
  if (switched.backend === "claude") {
2026
2320
  const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
2027
2321
  settings = { ...settings, backend: "claude", model: resolveClaudeModel(switched.id), effort };
2028
- } else {
2322
+ } else if (switched.backend === "grok") {
2029
2323
  const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
2030
2324
  settings = { ...settings, backend: "grok", model: resolveGrokModel(switched.id), effort };
2325
+ } else if (switched.backend === "codex") {
2326
+ const effort = settings.backend === "codex" && isCodexEffort(settings.effort) ? settings.effort : "default";
2327
+ settings = { ...settings, backend: "codex", model: resolveCodexModel(switched.id), effort };
2328
+ } else {
2329
+ const effort = settings.backend === "muse" && isMuseEffort(settings.effort) ? settings.effort : "default";
2330
+ settings = { ...settings, backend: "muse", model: resolveMuseModel(switched.id), effort };
2031
2331
  }
2032
2332
  } else {
2033
2333
  const family = findFamily(newValue);
@@ -2044,6 +2344,12 @@ export function createConfigModal(
2044
2344
  } else if ((settings.backend ?? "agy") === "grok") {
2045
2345
  if (!isGrokEffort(newValue)) return;
2046
2346
  settings = { ...settings, effort: newValue };
2347
+ } else if ((settings.backend ?? "agy") === "codex") {
2348
+ if (!isCodexEffort(newValue)) return;
2349
+ settings = { ...settings, effort: newValue };
2350
+ } else if ((settings.backend ?? "agy") === "muse") {
2351
+ if (!isMuseEffort(newValue)) return;
2352
+ settings = { ...settings, effort: newValue };
2047
2353
  } else {
2048
2354
  // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
2049
2355
  // capability-override effort-only edit below -- otherwise a shared default created
@@ -2076,12 +2382,24 @@ export function createConfigModal(
2076
2382
  model: resolveClaudeModel(switched.id),
2077
2383
  effort: capabilityBackend(settings, capability) === "claude" && isClaudeEffort(currentEffort) ? currentEffort : "default",
2078
2384
  });
2079
- } else {
2385
+ } else if (switched.backend === "grok") {
2080
2386
  settings = withCapabilityOverride(settings, capability, {
2081
2387
  backend: "grok",
2082
2388
  model: resolveGrokModel(switched.id),
2083
2389
  effort: capabilityBackend(settings, capability) === "grok" && isGrokEffort(currentEffort) ? currentEffort : "default",
2084
2390
  });
2391
+ } else if (switched.backend === "codex") {
2392
+ settings = withCapabilityOverride(settings, capability, {
2393
+ backend: "codex",
2394
+ model: resolveCodexModel(switched.id),
2395
+ effort: capabilityBackend(settings, capability) === "codex" && isCodexEffort(currentEffort) ? currentEffort : "default",
2396
+ });
2397
+ } else {
2398
+ settings = withCapabilityOverride(settings, capability, {
2399
+ backend: "muse",
2400
+ model: resolveMuseModel(switched.id),
2401
+ effort: capabilityBackend(settings, capability) === "muse" && isMuseEffort(currentEffort) ? currentEffort : "default",
2402
+ });
2085
2403
  }
2086
2404
  } else {
2087
2405
  const family = findFamily(newValue);
@@ -2104,6 +2422,16 @@ export function createConfigModal(
2104
2422
  const existing = capabilityOverride(settings, capability);
2105
2423
  const model = existing?.model ?? settings.model;
2106
2424
  settings = withCapabilityOverride(settings, capability, { backend: "grok", model, effort: newValue });
2425
+ } else if (capabilityBackend(settings, capability) === "codex") {
2426
+ if (!isCodexEffort(newValue)) return;
2427
+ const existing = capabilityOverride(settings, capability);
2428
+ const model = existing?.model ?? settings.model;
2429
+ settings = withCapabilityOverride(settings, capability, { backend: "codex", model, effort: newValue });
2430
+ } else if (capabilityBackend(settings, capability) === "muse") {
2431
+ if (!isMuseEffort(newValue)) return;
2432
+ const existing = capabilityOverride(settings, capability);
2433
+ const model = existing?.model ?? settings.model;
2434
+ settings = withCapabilityOverride(settings, capability, { backend: "muse", model, effort: newValue });
2107
2435
  } else {
2108
2436
  // Effort-only edit: pin the resolved family's canonical id, never whatever raw
2109
2437
  // string happens to sit in settings.model/override.model (which — for a shared
@@ -2173,7 +2501,7 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
2173
2501
  families = await listAgyModels(pi);
2174
2502
  } catch (error) {
2175
2503
  families = [];
2176
- ctx.ui.notify("Agy model catalog unavailable — showing Claude/Grok settings without Agy choices.", "warning");
2504
+ ctx.ui.notify("Agy model catalog unavailable — showing other backend settings without Agy choices.", "warning");
2177
2505
  }
2178
2506
  await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
2179
2507
  overlay: true,
@@ -2705,7 +3033,7 @@ export function toggleBtwMode(thread: BtwThread): void {
2705
3033
  thread.full = !thread.full;
2706
3034
  }
2707
3035
 
2708
- // Claude and Grok run btw in the workspace for both modes and resume one native session across
3036
+ // Claude, Grok, Codex, and Muse run btw in the workspace for both modes and resume one native session across
2709
3037
  // /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
2710
3038
  // dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
2711
3039
  // with the main-session context and the whole thread.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "An Earendil Pi extension that explains pasted text, assistant responses, local documents, public webpages, and recent session turns (as shapes) in a context-isolated window, opens a sandboxed side conversation with /bro btw, and provides a second-opinion advisor tool for executor agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,9 @@
31
31
  "btw",
32
32
  "side-conversation",
33
33
  "advisor",
34
- "second-opinion"
34
+ "second-opinion",
35
+ "codex",
36
+ "muse"
35
37
  ],
36
38
  "files": [
37
39
  "bro.ts",
@@ -50,7 +52,7 @@
50
52
  },
51
53
  "scripts": {
52
54
  "typecheck": "tsc --noEmit",
53
- "test": "npm run typecheck && node --test prompt.test.ts backend.test.ts claude.test.ts grok.test.ts settings.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
55
+ "test": "npm run typecheck && node --test prompt.test.ts backend.test.ts claude.test.ts grok.test.ts codex.test.ts muse.test.ts settings.test.ts benchmark/*.test.ts && sh ./smoke-test.sh",
54
56
  "benchmark:dry-run": "node benchmark/run.ts dry-run",
55
57
  "benchmark:run": "node benchmark/run.ts run",
56
58
  "benchmark:report": "node benchmark/run.ts report",
@@ -62,20 +64,21 @@
62
64
  ]
63
65
  },
64
66
  "peerDependencies": {
65
- "@earendil-works/pi-coding-agent": ">=0.84.2 <1",
66
- "@earendil-works/pi-tui": ">=0.84.2 <1"
67
+ "@earendil-works/pi-coding-agent": "*",
68
+ "@earendil-works/pi-tui": "*",
69
+ "typebox": "*"
67
70
  },
68
71
  "devDependencies": {
69
72
  "@earendil-works/pi-coding-agent": "0.84.2",
70
73
  "@earendil-works/pi-tui": "0.84.2",
71
74
  "@types/node": "^24.0.0",
75
+ "typebox": "1.3.7",
72
76
  "typescript": "6.0.2"
73
77
  },
74
78
  "dependencies": {
75
79
  "defuddle": "0.19.2",
76
80
  "linkedom": "0.18.13",
77
81
  "mammoth": "^1.12.1",
78
- "typebox": "1.3.7",
79
82
  "unpdf": "^1.8.1"
80
83
  }
81
84
  }