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 +15 -0
- package/README.md +21 -8
- package/backend.ts +485 -5
- package/bro.ts +360 -32
- package/package.json +9 -6
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
|
|
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
|
|
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
|
|
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
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
:
|
|
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
|
|
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 "
|
|
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 =
|
|
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
|
-
:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
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 ? [`${
|
|
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 =
|
|
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 =
|
|
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
|
-
:
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
66
|
-
"@earendil-works/pi-tui": "
|
|
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
|
}
|