pi-bro 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bro.ts CHANGED
@@ -5,12 +5,12 @@ import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from
5
5
  import { request as httpRequest, type IncomingMessage } from "node:http";
6
6
  import { request as httpsRequest } from "node:https";
7
7
  import { BlockList, isIP } from "node:net";
8
- import { homedir, tmpdir } from "node:os";
8
+ import { tmpdir } from "node:os";
9
9
  import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
10
10
  import { stripVTControlCharacters } from "node:util";
11
11
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
12
12
  import { Container, Editor, Input, Markdown, SettingsList, SelectList, Text, matchesKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type Focusable, type SelectItem, type SettingItem, type TUI } from "@earendil-works/pi-tui";
13
- import { convertToLlm, copyToClipboard, getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
13
+ import { convertToLlm, copyToClipboard, getAgentDir, getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
14
14
  import { Defuddle } from "defuddle/node";
15
15
  import { parseHTML } from "linkedom";
16
16
  import mammoth from "mammoth";
@@ -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,
@@ -33,7 +35,7 @@ import {
33
35
 
34
36
  export { agyFailureMessage, agySelection, advisorFlagErrorHint, parseBtwAgyLine };
35
37
 
36
- const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
38
+ const AGENT_DIR = getAgentDir();
37
39
  const ENV_MODEL = process.env.PI_BRO_MODEL?.trim();
38
40
  const DEFAULT_MODEL = ENV_MODEL || "gemini-3.7-flash";
39
41
  const PROMPT_FILE = join(AGENT_DIR, "bro-prompt.md");
@@ -55,7 +57,7 @@ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
55
57
  type Theme = ExtensionCommandContext["ui"]["theme"];
56
58
  type TuiLike = {
57
59
  readonly mode: "regular" | "fullscreen";
58
- readonly terminal?: { write?: (data: string) => void };
60
+ readonly terminal?: { rows?: number; write?: (data: string) => void };
59
61
  requestRender(): void;
60
62
  };
61
63
  type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
@@ -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
 
@@ -1343,10 +1561,12 @@ export function showHtmlDirectory(): string {
1343
1561
 
1344
1562
  function withShowCsp(html: string): string {
1345
1563
  // Defense in depth: the prompt forbids external resources and scripts;
1346
- // a meta CSP blocks them anyway if the model slips.
1564
+ // a meta CSP blocks them anyway if the model slips. Always inserted: multiple
1565
+ // policies are enforced together, so a model-supplied CSP can only tighten it.
1566
+ // Skip only simple leading comments: browsers accept comment terminators that
1567
+ // a general regex misses. Ambiguous markup gets the policy prepended instead.
1347
1568
  const meta = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:;">';
1348
- if (/http-equiv=["']?Content-Security-Policy/i.test(html)) return html;
1349
- return html.replace(/^(\s*(?:<!doctype[^>]*>\s*)?)/i, `$1\n${meta}\n`);
1569
+ return html.replace(/^(\s*(?:<!--[^<>]*-->\s*)*(?:<!doctype[^>]*>\s*)?)/i, `$1\n${meta}\n`);
1350
1570
  }
1351
1571
 
1352
1572
  export async function writeShowHtml(html: string): Promise<string> {
@@ -1787,7 +2007,7 @@ export function advisorAttemptLabel(details: AdvisorToolDetails): string {
1787
2007
  return `Bro advisor · ${details.model ?? "unknown model"} · ${details.attempt} attempt${details.attempt === 1 ? "" : "s"} · ${Math.ceil((details.durationMs ?? details.elapsedMs) / 1_000)}s`;
1788
2008
  }
1789
2009
  const latest = details.activity?.at(-1);
1790
- const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : "Agy";
2010
+ const backendLabel = details.backend === "claude" ? "Claude" : details.backend === "grok" ? "Grok" : details.backend === "codex" ? "Codex" : details.backend === "muse" ? "Muse" : "Agy";
1791
2011
  // "last reported" + freshness, never a claim about what the backend is doing right now and never
1792
2012
  // "stalled" -- silence since lastActivityAt is not itself evidence of a stuck run.
1793
2013
  const activityLabel = latest
@@ -1807,10 +2027,12 @@ function showTurnsValues(current: number): string[] {
1807
2027
  // falsely healthy for an unavailable model), and flags a stored effort that isn't one of the
1808
2028
  // resolved family's supported efforts instead of silently showing it as if it were valid.
1809
2029
  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
2030
+ // Claude/Grok/Codex/Muse selections never touch the Agy catalog: the stored effort is valid exactly
1811
2031
  // when it is one of that backend's levels (or "default" for the CLI's own default).
1812
2032
  if (backend === "claude") return isClaudeEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1813
2033
  if (backend === "grok") return isGrokEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
2034
+ if (backend === "codex") return isCodexEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
2035
+ if (backend === "muse") return isMuseEffort(resolved.pair.effort) ? resolved.pair.effort : `${resolved.pair.effort} (unsupported)`;
1814
2036
  if (!resolved.family) return "unavailable";
1815
2037
  const fixed = !resolved.family.efforts.length;
1816
2038
  const valid = fixed ? resolved.pair.effort === "default" : resolved.family.efforts.includes(resolved.pair.effort as AgyEffort);
@@ -1818,7 +2040,7 @@ function effortDisplay(resolved: { pair: ModelEffortPair; family?: AgyModelFamil
1818
2040
  return fixed ? "fixed" : resolved.pair.effort;
1819
2041
  }
1820
2042
 
1821
- // Model-picker values that switch backend carry a "claude:"/"grok:" prefix so one
2043
+ // Model-picker values that switch backend carry a "claude:"/"grok:"/"codex:"/"muse:" prefix so one
1822
2044
  // atomic picker commit changes backend+model together -- cancelling the picker (Esc)
1823
2045
  // leaves both unchanged via the existing submenu-cancel path.
1824
2046
  const CLAUDE_OPTION_PREFIX = "claude:";
@@ -1833,11 +2055,27 @@ function parseGrokOption(value: string): string | undefined {
1833
2055
  ? value.slice(GROK_OPTION_PREFIX.length)
1834
2056
  : undefined;
1835
2057
  }
1836
- function parseBackendOption(value: string): { backend: "claude" | "grok"; id: string } | undefined {
2058
+ const CODEX_OPTION_PREFIX = "codex:";
2059
+ function parseCodexOption(value: string): string | undefined {
2060
+ return value.startsWith(CODEX_OPTION_PREFIX) && value.length > CODEX_OPTION_PREFIX.length
2061
+ ? value.slice(CODEX_OPTION_PREFIX.length)
2062
+ : undefined;
2063
+ }
2064
+ const MUSE_OPTION_PREFIX = "muse:";
2065
+ function parseMuseOption(value: string): string | undefined {
2066
+ return value.startsWith(MUSE_OPTION_PREFIX) && value.length > MUSE_OPTION_PREFIX.length
2067
+ ? value.slice(MUSE_OPTION_PREFIX.length)
2068
+ : undefined;
2069
+ }
2070
+ function parseBackendOption(value: string): { backend: "claude" | "grok" | "codex" | "muse"; id: string } | undefined {
1837
2071
  const claudeId = parseClaudeOption(value);
1838
2072
  if (claudeId !== undefined) return { backend: "claude", id: claudeId };
1839
2073
  const grokId = parseGrokOption(value);
1840
2074
  if (grokId !== undefined) return { backend: "grok", id: grokId };
2075
+ const codexId = parseCodexOption(value);
2076
+ if (codexId !== undefined) return { backend: "codex", id: codexId };
2077
+ const museId = parseMuseOption(value);
2078
+ if (museId !== undefined) return { backend: "muse", id: museId };
1841
2079
  return undefined;
1842
2080
  }
1843
2081
 
@@ -1877,7 +2115,7 @@ export function createConfigModal(
1877
2115
  value: family.id,
1878
2116
  label: `${family.label}${family.efforts.length ? "" : " · fixed effort"}`,
1879
2117
  })),
1880
- // Claude/Grok entries stay last so existing Agy keyboard navigation is unaffected.
2118
+ // Alternative CLI entries stay last so existing Agy keyboard navigation is unaffected.
1881
2119
  ...CLAUDE_MODELS.map((model) => ({
1882
2120
  value: `${CLAUDE_OPTION_PREFIX}${model.id}`,
1883
2121
  label: `${model.label} · claude`,
@@ -1886,13 +2124,33 @@ export function createConfigModal(
1886
2124
  value: `${GROK_OPTION_PREFIX}${model.id}`,
1887
2125
  label: `${model.label} · grok`,
1888
2126
  })),
2127
+ ...CODEX_MODELS.map((model) => ({
2128
+ value: `${CODEX_OPTION_PREFIX}${model.id}`,
2129
+ label: `${model.label} · codex`,
2130
+ })),
2131
+ ...MUSE_MODELS.map((model) => ({
2132
+ value: `${MUSE_OPTION_PREFIX}${model.id}`,
2133
+ label: `${model.label} · muse`,
2134
+ })),
1889
2135
  ];
1890
2136
  options.push({ value: "__claude_custom__", label: "Claude · custom model ID…" });
1891
2137
  options.push({ value: "__grok_custom__", label: "Grok · custom model ID…" });
2138
+ options.push({ value: "__codex_custom__", label: "Codex · custom model ID…" });
2139
+ options.push({ value: "__muse_custom__", label: "Muse · custom model ID…" });
1892
2140
  const input = new Input();
1893
- let enteringBackend: "claude" | "grok" | undefined;
2141
+ let enteringBackend: "claude" | "grok" | "codex" | "muse" | undefined;
1894
2142
  input.onSubmit = (value) => {
1895
- if (value.trim() && enteringBackend) pickerDone(`${enteringBackend === "claude" ? CLAUDE_OPTION_PREFIX : GROK_OPTION_PREFIX}${value.trim()}`);
2143
+ if (value.trim() && enteringBackend) {
2144
+ const prefix =
2145
+ enteringBackend === "claude"
2146
+ ? CLAUDE_OPTION_PREFIX
2147
+ : enteringBackend === "grok"
2148
+ ? GROK_OPTION_PREFIX
2149
+ : enteringBackend === "codex"
2150
+ ? CODEX_OPTION_PREFIX
2151
+ : MUSE_OPTION_PREFIX;
2152
+ pickerDone(`${prefix}${value.trim()}`);
2153
+ }
1896
2154
  };
1897
2155
  const picker = new SelectList(options, Math.min(options.length, 8), getSelectListTheme());
1898
2156
  const selectedIndex = capability && current === "Default" ? 0 : options.findIndex((option) => option.value === current);
@@ -1900,11 +2158,14 @@ export function createConfigModal(
1900
2158
  picker.onSelect = (item) => {
1901
2159
  if (item.value === "__claude_custom__") { enteringBackend = "claude"; tui.requestRender(); }
1902
2160
  else if (item.value === "__grok_custom__") { enteringBackend = "grok"; tui.requestRender(); }
2161
+ else if (item.value === "__codex_custom__") { enteringBackend = "codex"; tui.requestRender(); }
2162
+ else if (item.value === "__muse_custom__") { enteringBackend = "muse"; tui.requestRender(); }
1903
2163
  else pickerDone(item.value);
1904
2164
  };
1905
2165
  picker.onCancel = () => pickerDone();
2166
+ const customLabel = () => enteringBackend === "claude" ? "Claude" : enteringBackend === "grok" ? "Grok" : enteringBackend === "codex" ? "Codex" : "Muse";
1906
2167
  return {
1907
- render: (width: number) => enteringBackend ? [`${enteringBackend === "claude" ? "Claude" : "Grok"} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
2168
+ render: (width: number) => enteringBackend ? [`${customLabel()} model ID (Enter saves, Esc cancels)`, ...input.render(width)] : picker.render(width),
1908
2169
  invalidate: () => { picker.invalidate(); input.invalidate(); },
1909
2170
  handleInput: (data: string) => {
1910
2171
  if (enteringBackend && matchesKey(data, "escape")) pickerDone();
@@ -1945,9 +2206,29 @@ export function createConfigModal(
1945
2206
  function refresh(): void {
1946
2207
  const defaultBackend = settings.backend ?? "agy";
1947
2208
  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);
2209
+ modelItem.currentValue =
2210
+ defaultBackend === "claude"
2211
+ ? `${CLAUDE_OPTION_PREFIX}${settings.model}`
2212
+ : defaultBackend === "grok"
2213
+ ? `${GROK_OPTION_PREFIX}${settings.model}`
2214
+ : defaultBackend === "codex"
2215
+ ? `${CODEX_OPTION_PREFIX}${settings.model}`
2216
+ : defaultBackend === "muse"
2217
+ ? `${MUSE_OPTION_PREFIX}${settings.model}`
2218
+ : (def.family?.id ?? settings.model);
1949
2219
  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;
2220
+ effortItem.values =
2221
+ defaultBackend === "claude"
2222
+ ? ["default", ...CLAUDE_EFFORTS]
2223
+ : defaultBackend === "grok"
2224
+ ? ["default", ...GROK_EFFORTS]
2225
+ : defaultBackend === "codex"
2226
+ ? ["default", ...CODEX_EFFORTS]
2227
+ : defaultBackend === "muse"
2228
+ ? ["default", ...MUSE_EFFORTS]
2229
+ : def.family?.efforts.length
2230
+ ? [...def.family.efforts]
2231
+ : undefined;
1951
2232
  modeItem.currentValue = settings.mode;
1952
2233
  showTurnsItem.currentValue = String(settings.showTurns);
1953
2234
  showTurnsItem.values = showTurnsValues(settings.showTurns);
@@ -1963,9 +2244,24 @@ export function createConfigModal(
1963
2244
  ? `${CLAUDE_OPTION_PREFIX}${override.model}`
1964
2245
  : backend === "grok"
1965
2246
  ? `${GROK_OPTION_PREFIX}${override.model}`
1966
- : (resolved.family?.id ?? override.model);
2247
+ : backend === "codex"
2248
+ ? `${CODEX_OPTION_PREFIX}${override.model}`
2249
+ : backend === "muse"
2250
+ ? `${MUSE_OPTION_PREFIX}${override.model}`
2251
+ : (resolved.family?.id ?? override.model);
1967
2252
  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;
2253
+ rows.effort.values =
2254
+ backend === "claude"
2255
+ ? ["default", ...CLAUDE_EFFORTS]
2256
+ : backend === "grok"
2257
+ ? ["default", ...GROK_EFFORTS]
2258
+ : backend === "codex"
2259
+ ? ["default", ...CODEX_EFFORTS]
2260
+ : backend === "muse"
2261
+ ? ["default", ...MUSE_EFFORTS]
2262
+ : resolved.family?.efforts.length
2263
+ ? [...resolved.family.efforts]
2264
+ : undefined;
1969
2265
  }
1970
2266
  }
1971
2267
  refresh();
@@ -2025,9 +2321,15 @@ export function createConfigModal(
2025
2321
  if (switched.backend === "claude") {
2026
2322
  const effort = settings.backend === "claude" && isClaudeEffort(settings.effort) ? settings.effort : "default";
2027
2323
  settings = { ...settings, backend: "claude", model: resolveClaudeModel(switched.id), effort };
2028
- } else {
2324
+ } else if (switched.backend === "grok") {
2029
2325
  const effort = settings.backend === "grok" && isGrokEffort(settings.effort) ? settings.effort : "default";
2030
2326
  settings = { ...settings, backend: "grok", model: resolveGrokModel(switched.id), effort };
2327
+ } else if (switched.backend === "codex") {
2328
+ const effort = settings.backend === "codex" && isCodexEffort(settings.effort) ? settings.effort : "default";
2329
+ settings = { ...settings, backend: "codex", model: resolveCodexModel(switched.id), effort };
2330
+ } else {
2331
+ const effort = settings.backend === "muse" && isMuseEffort(settings.effort) ? settings.effort : "default";
2332
+ settings = { ...settings, backend: "muse", model: resolveMuseModel(switched.id), effort };
2031
2333
  }
2032
2334
  } else {
2033
2335
  const family = findFamily(newValue);
@@ -2044,6 +2346,12 @@ export function createConfigModal(
2044
2346
  } else if ((settings.backend ?? "agy") === "grok") {
2045
2347
  if (!isGrokEffort(newValue)) return;
2046
2348
  settings = { ...settings, effort: newValue };
2349
+ } else if ((settings.backend ?? "agy") === "codex") {
2350
+ if (!isCodexEffort(newValue)) return;
2351
+ settings = { ...settings, effort: newValue };
2352
+ } else if ((settings.backend ?? "agy") === "muse") {
2353
+ if (!isMuseEffort(newValue)) return;
2354
+ settings = { ...settings, effort: newValue };
2047
2355
  } else {
2048
2356
  // Effort-only edit: pin the resolved family's canonical id, same reasoning as the
2049
2357
  // capability-override effort-only edit below -- otherwise a shared default created
@@ -2076,12 +2384,24 @@ export function createConfigModal(
2076
2384
  model: resolveClaudeModel(switched.id),
2077
2385
  effort: capabilityBackend(settings, capability) === "claude" && isClaudeEffort(currentEffort) ? currentEffort : "default",
2078
2386
  });
2079
- } else {
2387
+ } else if (switched.backend === "grok") {
2080
2388
  settings = withCapabilityOverride(settings, capability, {
2081
2389
  backend: "grok",
2082
2390
  model: resolveGrokModel(switched.id),
2083
2391
  effort: capabilityBackend(settings, capability) === "grok" && isGrokEffort(currentEffort) ? currentEffort : "default",
2084
2392
  });
2393
+ } else if (switched.backend === "codex") {
2394
+ settings = withCapabilityOverride(settings, capability, {
2395
+ backend: "codex",
2396
+ model: resolveCodexModel(switched.id),
2397
+ effort: capabilityBackend(settings, capability) === "codex" && isCodexEffort(currentEffort) ? currentEffort : "default",
2398
+ });
2399
+ } else {
2400
+ settings = withCapabilityOverride(settings, capability, {
2401
+ backend: "muse",
2402
+ model: resolveMuseModel(switched.id),
2403
+ effort: capabilityBackend(settings, capability) === "muse" && isMuseEffort(currentEffort) ? currentEffort : "default",
2404
+ });
2085
2405
  }
2086
2406
  } else {
2087
2407
  const family = findFamily(newValue);
@@ -2104,6 +2424,16 @@ export function createConfigModal(
2104
2424
  const existing = capabilityOverride(settings, capability);
2105
2425
  const model = existing?.model ?? settings.model;
2106
2426
  settings = withCapabilityOverride(settings, capability, { backend: "grok", model, effort: newValue });
2427
+ } else if (capabilityBackend(settings, capability) === "codex") {
2428
+ if (!isCodexEffort(newValue)) return;
2429
+ const existing = capabilityOverride(settings, capability);
2430
+ const model = existing?.model ?? settings.model;
2431
+ settings = withCapabilityOverride(settings, capability, { backend: "codex", model, effort: newValue });
2432
+ } else if (capabilityBackend(settings, capability) === "muse") {
2433
+ if (!isMuseEffort(newValue)) return;
2434
+ const existing = capabilityOverride(settings, capability);
2435
+ const model = existing?.model ?? settings.model;
2436
+ settings = withCapabilityOverride(settings, capability, { backend: "muse", model, effort: newValue });
2107
2437
  } else {
2108
2438
  // Effort-only edit: pin the resolved family's canonical id, never whatever raw
2109
2439
  // string happens to sit in settings.model/override.model (which — for a shared
@@ -2173,7 +2503,7 @@ export async function showBroConfigModal(ctx: ExtensionCommandContext, pi: Exten
2173
2503
  families = await listAgyModels(pi);
2174
2504
  } catch (error) {
2175
2505
  families = [];
2176
- ctx.ui.notify("Agy model catalog unavailable — showing Claude/Grok settings without Agy choices.", "warning");
2506
+ ctx.ui.notify("Agy model catalog unavailable — showing other backend settings without Agy choices.", "warning");
2177
2507
  }
2178
2508
  await ctx.ui.custom<void>(createConfigModal(settings, families, writeSettings), {
2179
2509
  overlay: true,
@@ -2470,7 +2800,7 @@ class BroModal implements Focusable {
2470
2800
  render(width: number): string[] {
2471
2801
  const dialogWidth = Math.max(24, width);
2472
2802
  const innerWidth = Math.max(22, dialogWidth - 2);
2473
- const terminalRows = process.stdout.rows ?? 30;
2803
+ const terminalRows = this.tui.terminal?.rows ?? process.stdout.rows ?? 30;
2474
2804
  const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
2475
2805
  this.bodyHeight = Math.max(1, dialogHeight - 6);
2476
2806
 
@@ -2705,7 +3035,7 @@ export function toggleBtwMode(thread: BtwThread): void {
2705
3035
  thread.full = !thread.full;
2706
3036
  }
2707
3037
 
2708
- // Claude and Grok run btw in the workspace for both modes and resume one native session across
3038
+ // Claude, Grok, Codex, and Muse run btw in the workspace for both modes and resume one native session across
2709
3039
  // /mode switches. An Agy conversation stays bound to the workspace it started in (a sandbox scratch
2710
3040
  // dir vs. the repo), so an access change drops it; the next turn reseeds a fresh native session
2711
3041
  // with the main-session context and the whole thread.
@@ -2903,7 +3233,7 @@ class BtwModal implements Focusable {
2903
3233
  render(width: number): string[] {
2904
3234
  const dialogWidth = Math.max(24, width);
2905
3235
  const innerWidth = Math.max(22, dialogWidth - 2);
2906
- const terminalRows = process.stdout.rows ?? 30;
3236
+ const terminalRows = this.tui.terminal?.rows ?? process.stdout.rows ?? 30;
2907
3237
  const dialogHeight = Math.min(34, Math.max(8, Math.floor(terminalRows * 0.82)));
2908
3238
  this.bodyHeight = Math.max(1, dialogHeight - 7);
2909
3239