johns-harness 2026.9.14 → 2026.9.16

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/README.md CHANGED
@@ -55,7 +55,9 @@ Expired ChatGPT credentials trigger an owner notice with the `/login` recovery c
55
55
 
56
56
  <!-- JOHNNESS_PATCH_DEFAULTS_61 -->
57
57
  ### Batteries included
58
- The harness ships the current model catalog and the short aliases `opus`, `sonnet`, `fable`, `astra`, `sol`, `luna`, `terra`, and `gpt`, so `/model opus` works on a fresh install with no `models.providers` block. Channels you configure load without a `plugins.allow` list; only an explicit `enabled: false` or `plugins.deny` turns one off. Updating the harness updates the catalog and aliases for every agent. Image generation and editing use GPT Image 2 through the `image_generate` tool: `openai-codex/gpt-image-2` on the ChatGPT subscription first, `openai/gpt-image-2` on the API key second (`agents.defaults.imageGenerationModel`).
58
+ The harness ships the current model catalog and the short aliases `opus`, `sonnet`, `fable`, `astra`, `sol`, `luna`, `terra`, `gpt`, `flash`, `banana`, and `image`, so `/model opus` works on a fresh install with no `models.providers` block. Channels you configure load without a `plugins.allow` list; only an explicit `enabled: false` or `plugins.deny` turns one off. Updating the harness updates the catalog and aliases for every agent. Any `openai-codex/<model>` primary that hits its ChatGPT usage limit fails over to `openai/<model>` on the API key first, then to your configured fallbacks, with no config (the sibling is skipped when no OpenAI API key is present). Image generation and editing use GPT Image 2 through the `image_generate` tool: `openai-codex/gpt-image-2` on the ChatGPT subscription first, `openai/gpt-image-2` on the API key second (`agents.defaults.imageGenerationModel`). Nano Banana Pro (`google/gemini-3-pro-image-preview`) ships in the catalog as a selectable image model, reachable with `model: "nano-banana"` and its own `aspectRatio` option; the default stays GPT Image 2 because the subscription path costs nothing.
59
+
60
+ **The alias contract.** Every shipped alias points at the newest model in its line, so an install or an `/update` moves every agent forward with zero per-instance config. Your own `agents.defaults.models[*].alias` entries are still consulted first and still win. Every catalog bump must move the matching alias; the verifier fails the release if it does not.
59
61
 
60
62
  ### Updating from chat
61
63
  Send `/update` to install the newest `johns-harness` on the machine the gateway runs on and restart into it; `/update 2026.9.14` pins an exact version. Owner only. The reply names the current and target versions, a second `/update` while one is running is refused, and the first message after the restart confirms the running version. On a writable host the package is reinstalled into the existing global prefix. On an immutable image with a persistent volume (Fly), it is installed into `$JOHNNESS_STATE_DIR/runtime/johnness` and the entrypoint prefers that runtime when it is newer than the image: `exec node "$(node /opt/johnness/lib/node_modules/johns-harness/dist/runtime-path.js)" gateway run`. `JOHNNESS_UPDATE_PREFIX` overrides the target. On failure the reply carries the last lines of npm output and the one command that fixes it.
@@ -72,7 +74,7 @@ node johnness.mjs --help
72
74
 
73
75
  ```bash
74
76
  npm test # 767 tests against the installed dependency tree
75
- bash scripts/verify-patches.sh # 360 checks across every patch family
77
+ bash scripts/verify-patches.sh # 395 checks across every patch family
76
78
  ```
77
79
 
78
80
  ## Launch a swarm with one command
@@ -118,7 +120,7 @@ Coding agents today run as isolated processes. Two agents on the same repository
118
120
  - **Memory built for production.** Tiered recall that traces from consolidated reflections down to the verbatim record, surviving compaction and shared across the swarm. Installed and enabled by default.
119
121
  - **Production authority per commit.** Promotion is granted to an exact SHA, never to a branch name, so one worker's authorization never covers another worker's commits.
120
122
  - **An append-only action log.** Agents record every consequential action to `ACTION_LOG.md` and read it before acting.
121
- - **A fixed runtime surface.** The core does not grow on a schedule. New mechanisms enter only to solve a demonstrated production failure, one narrow, tested, reversible patch at a time: 62 patch families, each with a regression test and a rollback path.
123
+ - **A fixed runtime surface.** The core does not grow on a schedule. New mechanisms enter only to solve a demonstrated production failure, one narrow, tested, reversible patch at a time: 64 patch families, each with a regression test and a rollback path.
122
124
  - **Reliability with a definition.** Bounded retries, explicit failure states, exact-once continuation, context-pressure recovery, durable delivery, and supervised gateway recovery.
123
125
  - **Coding first, phone optional.** Direct the work at a high level from a chat channel while agents plan, implement, test, coordinate, and stage releases unattended.
124
126
  - **Local voice.** Optional native Whisper transcription of inbound audio, entirely on your machine. Off by default. [Setup](docs/NATIVE-WHISPER-MUSE.md).
@@ -185,7 +187,7 @@ The log is a [`RULES.md`](RULES.md) workspace convention enforced through the ac
185
187
 
186
188
  ## Patch families
187
189
 
188
- The runtime carries 62 patch families. Each is a production fix applied at the source with a regression test, a durable patch marker, and a rollback path. A verifier runs 360 checks against the package tree and installed dependencies, and 767 tests run against the installed dependency tree.
190
+ The runtime carries 64 patch families. Each is a production fix applied at the source with a regression test, a durable patch marker, and a rollback path. A verifier runs 395 checks against the package tree and installed dependencies, and 767 tests run against the installed dependency tree.
189
191
 
190
192
  Every family follows the same six steps: reproduce the failure, trace the exact runtime path, make the smallest source-level change that restores the invariant, add a regression test and a patch marker, run the verifier, and retain rollback artifacts. The full index: [`docs/PATCHES.md`](docs/PATCHES.md).
191
193
 
@@ -202,7 +204,7 @@ Every family follows the same six steps: reproduce the failure, trace the exact
202
204
  ## Documentation
203
205
 
204
206
  - [`docs/index.md`](docs/index.md): runtime docs, from gateway and channels to plugins, nodes, and CLI reference.
205
- - [`docs/PATCHES.md`](docs/PATCHES.md): the 62 patch families, one record each.
207
+ - [`docs/PATCHES.md`](docs/PATCHES.md): the 64 patch families, one record each.
206
208
  - [`docs/NATIVE-WHISPER-MUSE.md`](docs/NATIVE-WHISPER-MUSE.md): local Whisper transcription setup.
207
209
  - [`RULES.md`](RULES.md): the default operating rules, including Rule Zero on production authorization.
208
210
 
@@ -5111,6 +5111,22 @@ const DEFAULT_MODEL_ALIASES = {
5111
5111
  "gemini-flash": "google/gemini-3-flash-preview",
5112
5112
  "gemini-flash-lite": "google/gemini-3.1-flash-lite-preview"
5113
5113
  };
5114
+ // JOHNNESS_PATCH_NANO_BANANA_63: the shipped alias contract. Every alias resolves to the newest model
5115
+ // in its line that exists in the shipped catalog, so a fresh install or an /update moves
5116
+ // every agent forward with no per-instance config. This block carries only the aliases
5117
+ // family 63 moves or adds; the rest are already current in the literal above, and later
5118
+ // keys win. A user alias in agents.defaults.models[*].alias is consulted first and still
5119
+ // overrides. Adding a newer model to the catalog without moving its alias fails the
5120
+ // verifier (63.20, scripts/check-alias-contract.mjs).
5121
+ Object.assign(DEFAULT_MODEL_ALIASES, {
5122
+ gpt: "openai-codex/gpt-5.6-sol",
5123
+ flash: "google/gemini-3.8-flash",
5124
+ "gemini-flash": "google/gemini-3.8-flash",
5125
+ "gemini-flash-lite": "google/gemini-2.5-flash-lite",
5126
+ banana: "google/gemini-3-pro-image-preview",
5127
+ "nano-banana": "google/gemini-3-pro-image-preview",
5128
+ image: "openai-codex/gpt-image-2"
5129
+ });
5114
5130
  const DEFAULT_MODEL_COST = {
5115
5131
  input: 0,
5116
5132
  output: 0,
@@ -127,6 +127,7 @@ import JSON5 from "json5";
127
127
  import { inspect } from "node:util";
128
128
  import fs$1, { appendFile, mkdir } from "node:fs/promises";
129
129
  import { complete, completeSimple, createAssistantMessageEventStream, getApiProvider, registerApiProvider, streamSimple } from "@mariozechner/pi-ai";
130
+ import { getModel as getShippedCatalogModel } from "@mariozechner/pi-ai"; // JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64
130
131
  import crypto, { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
131
132
  import { CURRENT_SESSION_VERSION, DefaultResourceLoader, SessionManager, SettingsManager, codingTools, createAgentSession, createEditTool, createReadTool, createWriteTool, estimateTokens, generateSummary, readTool } from "@mariozechner/pi-coding-agent";
132
133
  import { EdgeTTS } from "node-edge-tts";
@@ -18322,7 +18323,12 @@ async function runEmbeddedPiAgent(params) {
18322
18323
  cfg: params.config,
18323
18324
  agentId: params.agentId,
18324
18325
  sessionKey: params.sessionKey
18325
- });
18326
+ }) || hasCodexApiSiblingFallback({
18327
+ provider,
18328
+ model: modelId,
18329
+ cfg: params.config,
18330
+ agentDir
18331
+ }); // JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64
18326
18332
  await ensureJohnnessModelsJson(params.config, agentDir);
18327
18333
  let modelResolveOverride;
18328
18334
  let legacyBeforeAgentStartResult;
@@ -46838,6 +46844,34 @@ function resolveImageFallbackCandidates(params) {
46838
46844
  for (const raw of imageFallbacks) addRaw(raw);
46839
46845
  return candidates;
46840
46846
  }
46847
+ /* JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64 (family 64): any openai-codex/<model> primary automatically fails
46848
+ over to openai/<model>, the same model id on the API-key provider, before any configured
46849
+ fallback. The sibling exists only when it is a real entry in the shipped catalog and the
46850
+ openai provider has credentials on this install (auth profile, OPENAI_API_KEY, or
46851
+ models.providers.openai.apiKey); otherwise nothing is added and behavior is unchanged.
46852
+ It is added through addExplicitCandidate, which bypasses the agents.defaults.models
46853
+ allowlist on purpose: the allowlist governs what a user may select, and this candidate is
46854
+ the model the user already selected, reached through a different billing path. */
46855
+ const CODEX_API_SIBLING_PROVIDER = "openai";
46856
+ function resolveCodexApiSiblingCandidate(params) {
46857
+ const provider = normalizeProviderId(String(params.provider ?? ""));
46858
+ const model = String(params.model ?? "").trim();
46859
+ if (provider !== "openai-codex" || !model) return null;
46860
+ try {
46861
+ if (!getShippedCatalogModel(CODEX_API_SIBLING_PROVIDER, model)) return null;
46862
+ const store = params.store ?? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
46863
+ if (resolveModelAuthMode(CODEX_API_SIBLING_PROVIDER, params.cfg, store) === "unknown") return null;
46864
+ } catch {
46865
+ return null;
46866
+ }
46867
+ return {
46868
+ provider: CODEX_API_SIBLING_PROVIDER,
46869
+ model
46870
+ };
46871
+ }
46872
+ function hasCodexApiSiblingFallback(params) {
46873
+ return resolveCodexApiSiblingCandidate(params) !== null;
46874
+ }
46841
46875
  function resolveFallbackCandidates(params) {
46842
46876
  const primary = params.cfg ? resolveConfiguredModelRef({
46843
46877
  cfg: params.cfg,
@@ -46857,6 +46891,14 @@ function resolveFallbackCandidates(params) {
46857
46891
  defaultProvider
46858
46892
  }));
46859
46893
  addExplicitCandidate(normalizedPrimary);
46894
+ const codexApiSibling = resolveCodexApiSiblingCandidate({
46895
+ provider: normalizedPrimary.provider,
46896
+ model: normalizedPrimary.model,
46897
+ cfg: params.cfg,
46898
+ agentDir: params.agentDir,
46899
+ store: params.authStore
46900
+ }); // JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64
46901
+ if (codexApiSibling) addExplicitCandidate(codexApiSibling);
46860
46902
  const modelFallbacks = (() => {
46861
46903
  if (params.fallbacksOverride !== void 0) return params.fallbacksOverride;
46862
46904
  const configuredFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model);
@@ -46932,13 +46974,15 @@ function resolveCooldownDecision(params) {
46932
46974
  };
46933
46975
  }
46934
46976
  async function runWithModelFallback(params) {
46977
+ const authStore = params.cfg ? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }) : null;
46935
46978
  const candidates = resolveFallbackCandidates({
46936
46979
  cfg: params.cfg,
46937
46980
  provider: params.provider,
46938
46981
  model: params.model,
46939
- fallbacksOverride: params.fallbacksOverride
46982
+ fallbacksOverride: params.fallbacksOverride,
46983
+ agentDir: params.agentDir, // JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64
46984
+ authStore: authStore ?? void 0
46940
46985
  });
46941
- const authStore = params.cfg ? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }) : null;
46942
46986
  const attempts = [];
46943
46987
  let lastError;
46944
46988
  const hasFallbackCandidates = candidates.length > 1;
@@ -1808,6 +1808,22 @@ const DEFAULT_MODEL_ALIASES = {
1808
1808
  "gemini-flash": "google/gemini-3-flash-preview",
1809
1809
  "gemini-flash-lite": "google/gemini-3.1-flash-lite-preview"
1810
1810
  };
1811
+ // JOHNNESS_PATCH_NANO_BANANA_63: the shipped alias contract. Every alias resolves to the newest model
1812
+ // in its line that exists in the shipped catalog, so a fresh install or an /update moves
1813
+ // every agent forward with no per-instance config. This block carries only the aliases
1814
+ // family 63 moves or adds; the rest are already current in the literal above, and later
1815
+ // keys win. A user alias in agents.defaults.models[*].alias is consulted first and still
1816
+ // overrides. Adding a newer model to the catalog without moving its alias fails the
1817
+ // verifier (63.20, scripts/check-alias-contract.mjs).
1818
+ Object.assign(DEFAULT_MODEL_ALIASES, {
1819
+ gpt: "openai-codex/gpt-5.6-sol",
1820
+ flash: "google/gemini-3.8-flash",
1821
+ "gemini-flash": "google/gemini-3.8-flash",
1822
+ "gemini-flash-lite": "google/gemini-2.5-flash-lite",
1823
+ banana: "google/gemini-3-pro-image-preview",
1824
+ "nano-banana": "google/gemini-3-pro-image-preview",
1825
+ image: "openai-codex/gpt-image-2"
1826
+ });
1811
1827
  const DEFAULT_MODEL_COST = {
1812
1828
  input: 0,
1813
1829
  output: 0,
@@ -4959,6 +4959,22 @@ const DEFAULT_MODEL_ALIASES = {
4959
4959
  "gemini-flash": "google/gemini-3-flash-preview",
4960
4960
  "gemini-flash-lite": "google/gemini-3.1-flash-lite-preview"
4961
4961
  };
4962
+ // JOHNNESS_PATCH_NANO_BANANA_63: the shipped alias contract. Every alias resolves to the newest model
4963
+ // in its line that exists in the shipped catalog, so a fresh install or an /update moves
4964
+ // every agent forward with no per-instance config. This block carries only the aliases
4965
+ // family 63 moves or adds; the rest are already current in the literal above, and later
4966
+ // keys win. A user alias in agents.defaults.models[*].alias is consulted first and still
4967
+ // overrides. Adding a newer model to the catalog without moving its alias fails the
4968
+ // verifier (63.20, scripts/check-alias-contract.mjs).
4969
+ Object.assign(DEFAULT_MODEL_ALIASES, {
4970
+ gpt: "openai-codex/gpt-5.6-sol",
4971
+ flash: "google/gemini-3.8-flash",
4972
+ "gemini-flash": "google/gemini-3.8-flash",
4973
+ "gemini-flash-lite": "google/gemini-2.5-flash-lite",
4974
+ banana: "google/gemini-3-pro-image-preview",
4975
+ "nano-banana": "google/gemini-3-pro-image-preview",
4976
+ image: "openai-codex/gpt-image-2"
4977
+ });
4962
4978
  const DEFAULT_MODEL_COST = {
4963
4979
  input: 0,
4964
4980
  output: 0,
@@ -1,3 +1,4 @@
1
+ // JOHNNESS_PATCH_NANO_BANANA_63
1
2
  // JOHNNESS_PATCH_GPT_IMAGE_62
2
3
  // First-class `image_generate` agent tool: OpenAI Images API (generations when no input
3
4
  // image, edits when one is given) with the model from `agents.defaults.imageGenerationModel`.
@@ -23,6 +24,35 @@ const SIZES = new Set(["1024x1024", "1536x1024", "1024x1536", "auto"]);
23
24
  const DEFAULT_MAX_INPUT_BYTES = 20 * 1024 * 1024;
24
25
  const JWT_CLAIM_PATH = "https://api.openai.com/auth";
25
26
 
27
+ // JOHNNESS_PATCH_NANO_BANANA_63: Nano Banana Pro (google/gemini-3-pro-image-preview) is a
28
+ // selectable image model, not a default. The default chain stays subscription first
29
+ // (openai-codex/gpt-image-2, then openai/gpt-image-2); Gemini is API-key billed, so it is
30
+ // reached only through the tool's `model` param or agents.defaults.imageGenerationModel.
31
+ export const GOOGLE_BASE = "https://generativelanguage.googleapis.com/v1beta";
32
+ export const NANO_BANANA_MODEL_ID = "gemini-3-pro-image-preview";
33
+ export const NANO_BANANA_REF = `google/${NANO_BANANA_MODEL_ID}`;
34
+ /** Aspect ratios Nano Banana Pro accepts. Ignored by the OpenAI providers, which use `size`. */
35
+ export const ASPECT_RATIOS = Object.freeze(["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]);
36
+ /** Bare names the `model` param accepts, resolved to catalog refs before provider routing. */
37
+ export const IMAGE_MODEL_ALIASES = Object.freeze({
38
+ "nano-banana": NANO_BANANA_REF,
39
+ banana: NANO_BANANA_REF,
40
+ "nano-banana-pro": NANO_BANANA_REF,
41
+ "gemini-3-pro-image-preview": NANO_BANANA_REF,
42
+ image: "openai-codex/gpt-image-2",
43
+ "gpt-image-2": "openai-codex/gpt-image-2",
44
+ });
45
+
46
+ /** Resolve an alias or a provider/model ref to a full provider/model ref. */
47
+ export function resolveImageModelRef(raw) {
48
+ const value = String(raw ?? "").trim();
49
+ if (!value) return null;
50
+ const alias = IMAGE_MODEL_ALIASES[value.toLowerCase()];
51
+ if (alias) return alias;
52
+ if (value.includes("/")) return value;
53
+ throw new Error(`Unknown image model "${value}". Use provider/model, or one of: ${Object.keys(IMAGE_MODEL_ALIASES).join(", ")}.`);
54
+ }
55
+
26
56
  export function coerceImageGenerationModelConfig(cfg) {
27
57
  const raw = cfg?.agents?.defaults?.imageGenerationModel;
28
58
  if (typeof raw === "string" && raw.trim()) return { primary: raw.trim(), fallbacks: [] };
@@ -56,7 +86,15 @@ export function codexAccountIdFromToken(token) {
56
86
  }
57
87
 
58
88
  /** Endpoint + headers for one provider. Same OAuth transport shape as openai-codex-responses. */
59
- export function buildProviderRequest({ provider, apiKey, edit, userAgent = "johnness" }) {
89
+ export function buildProviderRequest({ provider, apiKey, edit, userAgent = "johnness", model = NANO_BANANA_MODEL_ID }) {
90
+ if (provider === "google") {
91
+ // JOHNNESS_PATCH_NANO_BANANA_63: Gemini generateContent, API key in x-goog-api-key.
92
+ // One endpoint for both generations and edits; edits inline the inputs as parts.
93
+ return {
94
+ url: `${GOOGLE_BASE}/models/${model}:generateContent`,
95
+ headers: { "x-goog-api-key": apiKey, "content-type": "application/json", "User-Agent": userAgent },
96
+ };
97
+ }
60
98
  if (provider === "openai-codex") {
61
99
  const accountId = codexAccountIdFromToken(apiKey);
62
100
  if (!accountId) throw new ProviderUnsupported("codex token has no chatgpt_account_id");
@@ -92,10 +130,14 @@ function sanitizeName(input, fallback) {
92
130
  return base || fallback;
93
131
  }
94
132
 
95
- export function resolveOutputPath({ workspaceDir, output, index = 0, count = 1 }) {
133
+ // JOHNNESS_PATCH_NANO_BANANA_63: `ext` defaults to png, which is what the OpenAI Images API
134
+ // returns and what family 62 shipped. Nano Banana Pro answers with image/jpeg, so the caller
135
+ // passes the extension the returned bytes actually are rather than mislabeling a JPEG .png.
136
+ export function resolveOutputPath({ workspaceDir, output, index = 0, count = 1, ext = "png" }) {
96
137
  const root = path.resolve(workspaceDir);
97
138
  const mediaDir = path.join(root, "media", "generated");
98
139
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
140
+ const suffix = String(ext || "png").replace(/^\.+/, "").toLowerCase().replace(/[^a-z0-9]/g, "") || "png";
99
141
  let target;
100
142
  if (output && String(output).trim()) {
101
143
  const raw = String(output).trim();
@@ -103,13 +145,21 @@ export function resolveOutputPath({ workspaceDir, output, index = 0, count = 1 }
103
145
  const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw);
104
146
  const rel = path.relative(root, resolved);
105
147
  if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`output must stay inside the workspace: ${raw}`);
106
- target = count > 1 ? path.join(path.dirname(resolved), `${sanitizeName(path.basename(resolved), "image")}-${index + 1}.png`) : resolved.replace(/\.[a-z0-9]+$/i, "") + ".png";
148
+ target = count > 1 ? path.join(path.dirname(resolved), `${sanitizeName(path.basename(resolved), "image")}-${index + 1}.${suffix}`) : resolved.replace(/\.[a-z0-9]+$/i, "") + "." + suffix;
107
149
  } else {
108
- target = path.join(mediaDir, `gpt-image-${stamp}${count > 1 ? `-${index + 1}` : ""}.png`);
150
+ target = path.join(mediaDir, `gpt-image-${stamp}${count > 1 ? `-${index + 1}` : ""}.${suffix}`);
109
151
  }
110
152
  return target;
111
153
  }
112
154
 
155
+ /** File extension for the bytes a provider returned. JOHNNESS_PATCH_NANO_BANANA_63 */
156
+ export function extensionForMimeType(mimeType) {
157
+ const value = String(mimeType ?? "").toLowerCase();
158
+ if (value.includes("jpeg") || value.includes("jpg")) return "jpg";
159
+ if (value.includes("webp")) return "webp";
160
+ return "png";
161
+ }
162
+
113
163
  export function validateParams(args) {
114
164
  const record = args && typeof args === "object" ? args : {};
115
165
  const prompt = typeof record.prompt === "string" ? record.prompt.trim() : "";
@@ -125,12 +175,58 @@ export function validateParams(args) {
125
175
  const countRaw = typeof record.count === "number" ? Math.floor(record.count) : 1;
126
176
  if (!Number.isFinite(countRaw) || countRaw < 1 || countRaw > 4) throw new Error("count must be 1..4");
127
177
  const quality = typeof record.quality === "string" && ["low", "medium", "high", "auto"].includes(record.quality) ? record.quality : void 0;
128
- return { prompt, inputs, mask, size, count: countRaw, quality, output: typeof record.output === "string" ? record.output : void 0, model: typeof record.model === "string" && record.model.trim() ? record.model.trim() : void 0 };
178
+ // JOHNNESS_PATCH_NANO_BANANA_63: aliases resolve to catalog refs; aspectRatio is Gemini only.
179
+ const model = typeof record.model === "string" && record.model.trim() ? resolveImageModelRef(record.model) : void 0;
180
+ const aspectRatio = typeof record.aspectRatio === "string" && record.aspectRatio.trim() ? record.aspectRatio.trim() : void 0;
181
+ if (aspectRatio && !ASPECT_RATIOS.includes(aspectRatio)) throw new Error(`aspectRatio must be one of ${ASPECT_RATIOS.join(", ")}`);
182
+ return { prompt, inputs, mask, size, count: countRaw, quality, aspectRatio, output: typeof record.output === "string" ? record.output : void 0, model };
183
+ }
184
+
185
+ /**
186
+ * JOHNNESS_PATCH_NANO_BANANA_63: Gemini generateContent branch.
187
+ * Generations send the prompt alone; edits send the prompt plus one inlineData part per input
188
+ * image. `aspectRatio` rides generationConfig.imageConfig. Failure classification is shared with
189
+ * the OpenAI branches (isUnsupportedStatus) so fallback chaining behaves identically.
190
+ */
191
+ async function callGoogleProvider({ candidate, apiKey, params, images, fetchImpl, userAgent }) {
192
+ const edit = images.length > 0;
193
+ const { url, headers } = buildProviderRequest({ provider: candidate.provider, apiKey, edit, userAgent, model: candidate.model });
194
+ const parts = [{ text: params.prompt }];
195
+ for (const img of images) parts.push({ inlineData: { mimeType: img.mimeType, data: Buffer.from(img.buffer).toString("base64") } });
196
+ const generationConfig = { responseModalities: ["IMAGE"] };
197
+ if (params.aspectRatio) generationConfig.imageConfig = { aspectRatio: params.aspectRatio };
198
+ const res = await fetchImpl(url, { method: "POST", headers, body: JSON.stringify({ contents: [{ role: "user", parts }], generationConfig }) });
199
+ const text = await res.text();
200
+ if (!res.ok) {
201
+ if (isUnsupportedStatus(res.status, text)) throw new ProviderUnsupported(`${candidate.provider} ${res.status}: ${redact(text)}`, res.status);
202
+ throw new Error(`${candidate.provider} ${res.status}: ${redact(text)}`);
203
+ }
204
+ let json;
205
+ try {
206
+ json = JSON.parse(text);
207
+ } catch {
208
+ throw new Error(`${candidate.provider}: non-JSON response`);
209
+ }
210
+ const buffers = [];
211
+ let mimeType = "image/png";
212
+ for (const candidateOut of Array.isArray(json?.candidates) ? json.candidates : []) {
213
+ for (const part of Array.isArray(candidateOut?.content?.parts) ? candidateOut.content.parts : []) {
214
+ const inline = part?.inlineData ?? part?.inline_data;
215
+ const data = inline?.data;
216
+ if (typeof data === "string" && data) {
217
+ buffers.push(Buffer.from(data, "base64"));
218
+ if (inline?.mimeType || inline?.mime_type) mimeType = inline.mimeType ?? inline.mime_type;
219
+ }
220
+ }
221
+ }
222
+ if (buffers.length === 0) throw new Error(`${candidate.provider}: response carried no image data`);
223
+ return { buffers, usage: json?.usageMetadata ?? null, mode: edit ? "edit" : "generate", mimeType };
129
224
  }
130
225
 
131
226
  async function callProvider({ candidate, apiKey, params, images, mask, fetchImpl, userAgent }) {
227
+ if (candidate.provider === "google") return callGoogleProvider({ candidate, apiKey, params, images, fetchImpl, userAgent }); // JOHNNESS_PATCH_NANO_BANANA_63
132
228
  const edit = images.length > 0;
133
- const { url, headers } = buildProviderRequest({ provider: candidate.provider, apiKey, edit, userAgent });
229
+ const { url, headers } = buildProviderRequest({ provider: candidate.provider, apiKey, edit, userAgent, model: candidate.model });
134
230
  let body;
135
231
  if (edit) {
136
232
  body = new FormData();
@@ -168,7 +264,7 @@ async function callProvider({ candidate, apiKey, params, images, mask, fetchImpl
168
264
  }
169
265
  }
170
266
  if (buffers.length === 0) throw new Error(`${candidate.provider}: response carried no image data`);
171
- return { buffers, usage: json?.usage ?? null, mode: edit ? "edit" : "generate" };
267
+ return { buffers, usage: json?.usage ?? null, mode: edit ? "edit" : "generate", mimeType: "image/png" };
172
268
  }
173
269
 
174
270
  /**
@@ -217,7 +313,7 @@ export function createImageGenerateTool(options, deps) {
217
313
  return {
218
314
  label: "Image Generate",
219
315
  name: "image_generate",
220
- description: "Generate or edit images with GPT Image 2 (default openai-codex/gpt-image-2, then openai/gpt-image-2). Give a prompt; add image (or images, up to 4) to edit, and an optional mask PNG. Writes PNG files under the workspace media/generated folder and returns their paths; send them with the message tool.",
316
+ description: "Generate or edit images with GPT Image 2 (default openai-codex/gpt-image-2, then openai/gpt-image-2) or Nano Banana Pro (model: nano-banana). Give a prompt; add image (or images, up to 4) to edit, and an optional mask PNG. Writes PNG files under the workspace media/generated folder and returns their paths; send them with the message tool.",
221
317
  parameters: Type.Object({
222
318
  prompt: Type.String({ description: "What to draw or how to change the input image." }),
223
319
  image: Type.Optional(Type.String({ description: "Input image path to edit (workspace or allowed local root)." })),
@@ -227,7 +323,8 @@ export function createImageGenerateTool(options, deps) {
227
323
  count: Type.Optional(Type.Number({ description: "Number of images, 1..4 (default 1)." })),
228
324
  quality: Type.Optional(Type.String({ description: "low, medium, high, or auto." })),
229
325
  output: Type.Optional(Type.String({ description: "Output path inside the workspace (default media/generated/gpt-image-<timestamp>.png)." })),
230
- model: Type.Optional(Type.String({ description: "Override provider/model, for example openai/gpt-image-2." })),
326
+ model: Type.Optional(Type.String({ description: "Override provider/model, for example openai/gpt-image-2, or an alias: nano-banana (Nano Banana Pro), banana, image." })),
327
+ aspectRatio: Type.Optional(Type.String({ description: "Nano Banana Pro only: 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9. Ignored by the GPT Image 2 providers." })),
231
328
  }),
232
329
  execute: async (_toolCallId, args) => {
233
330
  const params = validateParams(args);
@@ -242,7 +339,9 @@ export function createImageGenerateTool(options, deps) {
242
339
  const images = [];
243
340
  for (const ref of params.inputs) images.push(await load(ref));
244
341
  const mask = params.mask ? await load(params.mask) : void 0;
245
- const outputs = Array.from({ length: params.count }, (_, i) => resolveOutputPath({ workspaceDir, output: params.output, index: i, count: params.count }));
342
+ // Validate the output path before spending a call, then resolve it again once the
343
+ // provider has told us what the bytes actually are. JOHNNESS_PATCH_NANO_BANANA_63
344
+ resolveOutputPath({ workspaceDir, output: params.output, index: 0, count: params.count });
246
345
  const result = await generateWithFallback({
247
346
  cfg: options?.config,
248
347
  params,
@@ -252,6 +351,8 @@ export function createImageGenerateTool(options, deps) {
252
351
  userAgent: deps.userAgent,
253
352
  resolveAuth: ({ provider }) => deps.resolveApiKeyForProvider({ provider, cfg: options?.config, agentDir }),
254
353
  });
354
+ const ext = extensionForMimeType(result.mimeType);
355
+ const outputs = Array.from({ length: params.count }, (_, i) => resolveOutputPath({ workspaceDir, output: params.output, index: i, count: params.count, ext }));
255
356
  const written = [];
256
357
  for (let i = 0; i < result.buffers.length && i < outputs.length; i++) {
257
358
  await fs.mkdir(path.dirname(outputs[i]), { recursive: true });
@@ -1665,6 +1665,22 @@ const DEFAULT_MODEL_ALIASES = {
1665
1665
  "gemini-flash": "google/gemini-3-flash-preview",
1666
1666
  "gemini-flash-lite": "google/gemini-3.1-flash-lite-preview"
1667
1667
  };
1668
+ // JOHNNESS_PATCH_NANO_BANANA_63: the shipped alias contract. Every alias resolves to the newest model
1669
+ // in its line that exists in the shipped catalog, so a fresh install or an /update moves
1670
+ // every agent forward with no per-instance config. This block carries only the aliases
1671
+ // family 63 moves or adds; the rest are already current in the literal above, and later
1672
+ // keys win. A user alias in agents.defaults.models[*].alias is consulted first and still
1673
+ // overrides. Adding a newer model to the catalog without moving its alias fails the
1674
+ // verifier (63.20, scripts/check-alias-contract.mjs).
1675
+ Object.assign(DEFAULT_MODEL_ALIASES, {
1676
+ gpt: "openai-codex/gpt-5.6-sol",
1677
+ flash: "google/gemini-3.8-flash",
1678
+ "gemini-flash": "google/gemini-3.8-flash",
1679
+ "gemini-flash-lite": "google/gemini-2.5-flash-lite",
1680
+ banana: "google/gemini-3-pro-image-preview",
1681
+ "nano-banana": "google/gemini-3-pro-image-preview",
1682
+ image: "openai-codex/gpt-image-2"
1683
+ });
1668
1684
  const DEFAULT_MODEL_COST = {
1669
1685
  input: 0,
1670
1686
  output: 0,
@@ -1640,6 +1640,22 @@ const DEFAULT_MODEL_ALIASES = {
1640
1640
  "gemini-flash": "google/gemini-3-flash-preview",
1641
1641
  "gemini-flash-lite": "google/gemini-3.1-flash-lite-preview"
1642
1642
  };
1643
+ // JOHNNESS_PATCH_NANO_BANANA_63: the shipped alias contract. Every alias resolves to the newest model
1644
+ // in its line that exists in the shipped catalog, so a fresh install or an /update moves
1645
+ // every agent forward with no per-instance config. This block carries only the aliases
1646
+ // family 63 moves or adds; the rest are already current in the literal above, and later
1647
+ // keys win. A user alias in agents.defaults.models[*].alias is consulted first and still
1648
+ // overrides. Adding a newer model to the catalog without moving its alias fails the
1649
+ // verifier (63.20, scripts/check-alias-contract.mjs).
1650
+ Object.assign(DEFAULT_MODEL_ALIASES, {
1651
+ gpt: "openai-codex/gpt-5.6-sol",
1652
+ flash: "google/gemini-3.8-flash",
1653
+ "gemini-flash": "google/gemini-3.8-flash",
1654
+ "gemini-flash-lite": "google/gemini-2.5-flash-lite",
1655
+ banana: "google/gemini-3-pro-image-preview",
1656
+ "nano-banana": "google/gemini-3-pro-image-preview",
1657
+ image: "openai-codex/gpt-image-2"
1658
+ });
1643
1659
  const DEFAULT_MODEL_COST = {
1644
1660
  input: 0,
1645
1661
  output: 0,
@@ -1571,6 +1571,22 @@ const DEFAULT_MODEL_ALIASES = {
1571
1571
  "gemini-flash": "google/gemini-3-flash-preview",
1572
1572
  "gemini-flash-lite": "google/gemini-3.1-flash-lite-preview"
1573
1573
  };
1574
+ // JOHNNESS_PATCH_NANO_BANANA_63: the shipped alias contract. Every alias resolves to the newest model
1575
+ // in its line that exists in the shipped catalog, so a fresh install or an /update moves
1576
+ // every agent forward with no per-instance config. This block carries only the aliases
1577
+ // family 63 moves or adds; the rest are already current in the literal above, and later
1578
+ // keys win. A user alias in agents.defaults.models[*].alias is consulted first and still
1579
+ // overrides. Adding a newer model to the catalog without moving its alias fails the
1580
+ // verifier (63.20, scripts/check-alias-contract.mjs).
1581
+ Object.assign(DEFAULT_MODEL_ALIASES, {
1582
+ gpt: "openai-codex/gpt-5.6-sol",
1583
+ flash: "google/gemini-3.8-flash",
1584
+ "gemini-flash": "google/gemini-3.8-flash",
1585
+ "gemini-flash-lite": "google/gemini-2.5-flash-lite",
1586
+ banana: "google/gemini-3-pro-image-preview",
1587
+ "nano-banana": "google/gemini-3-pro-image-preview",
1588
+ image: "openai-codex/gpt-image-2"
1589
+ });
1574
1590
  const DEFAULT_MODEL_COST = {
1575
1591
  input: 0,
1576
1592
  output: 0,
@@ -73,6 +73,7 @@ import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
73
73
  import { fileURLToPath } from "node:url";
74
74
  import crypto, { X509Certificate, createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
75
75
  import { complete, completeSimple, createAssistantMessageEventStream, getApiProvider, registerApiProvider, streamSimple } from "@mariozechner/pi-ai";
76
+ import { getModel as getShippedCatalogModel } from "@mariozechner/pi-ai"; // JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64
76
77
  import { CURRENT_SESSION_VERSION, DefaultResourceLoader, SessionManager, SettingsManager, codingTools, createAgentSession, createEditTool, createReadTool, createWriteTool, estimateTokens, generateSummary, readTool } from "@mariozechner/pi-coding-agent";
77
78
  import { EdgeTTS } from "node-edge-tts";
78
79
  import * as net$1 from "node:net";
@@ -39963,6 +39964,34 @@ function resolveImageFallbackCandidates(params) {
39963
39964
  for (const raw of imageFallbacks) addRaw(raw);
39964
39965
  return candidates;
39965
39966
  }
39967
+ /* JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64 (family 64): any openai-codex/<model> primary automatically fails
39968
+ over to openai/<model>, the same model id on the API-key provider, before any configured
39969
+ fallback. The sibling exists only when it is a real entry in the shipped catalog and the
39970
+ openai provider has credentials on this install (auth profile, OPENAI_API_KEY, or
39971
+ models.providers.openai.apiKey); otherwise nothing is added and behavior is unchanged.
39972
+ It is added through addExplicitCandidate, which bypasses the agents.defaults.models
39973
+ allowlist on purpose: the allowlist governs what a user may select, and this candidate is
39974
+ the model the user already selected, reached through a different billing path. */
39975
+ const CODEX_API_SIBLING_PROVIDER = "openai";
39976
+ function resolveCodexApiSiblingCandidate(params) {
39977
+ const provider = normalizeProviderId(String(params.provider ?? ""));
39978
+ const model = String(params.model ?? "").trim();
39979
+ if (provider !== "openai-codex" || !model) return null;
39980
+ try {
39981
+ if (!getShippedCatalogModel(CODEX_API_SIBLING_PROVIDER, model)) return null;
39982
+ const store = params.store ?? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
39983
+ if (resolveModelAuthMode(CODEX_API_SIBLING_PROVIDER, params.cfg, store) === "unknown") return null;
39984
+ } catch {
39985
+ return null;
39986
+ }
39987
+ return {
39988
+ provider: CODEX_API_SIBLING_PROVIDER,
39989
+ model
39990
+ };
39991
+ }
39992
+ function hasCodexApiSiblingFallback(params) {
39993
+ return resolveCodexApiSiblingCandidate(params) !== null;
39994
+ }
39966
39995
  function resolveFallbackCandidates(params) {
39967
39996
  const primary = params.cfg ? resolveConfiguredModelRef({
39968
39997
  cfg: params.cfg,
@@ -39982,6 +40011,14 @@ function resolveFallbackCandidates(params) {
39982
40011
  defaultProvider
39983
40012
  }));
39984
40013
  addExplicitCandidate(normalizedPrimary);
40014
+ const codexApiSibling = resolveCodexApiSiblingCandidate({
40015
+ provider: normalizedPrimary.provider,
40016
+ model: normalizedPrimary.model,
40017
+ cfg: params.cfg,
40018
+ agentDir: params.agentDir,
40019
+ store: params.authStore
40020
+ }); // JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64
40021
+ if (codexApiSibling) addExplicitCandidate(codexApiSibling);
39985
40022
  const modelFallbacks = (() => {
39986
40023
  if (params.fallbacksOverride !== void 0) return params.fallbacksOverride;
39987
40024
  const configuredFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model);
@@ -40057,13 +40094,15 @@ function resolveCooldownDecision(params) {
40057
40094
  };
40058
40095
  }
40059
40096
  async function runWithModelFallback(params) {
40097
+ const authStore = params.cfg ? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }) : null;
40060
40098
  const candidates = resolveFallbackCandidates({
40061
40099
  cfg: params.cfg,
40062
40100
  provider: params.provider,
40063
40101
  model: params.model,
40064
- fallbacksOverride: params.fallbacksOverride
40102
+ fallbacksOverride: params.fallbacksOverride,
40103
+ agentDir: params.agentDir, // JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64
40104
+ authStore: authStore ?? void 0
40065
40105
  });
40066
- const authStore = params.cfg ? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }) : null;
40067
40106
  const attempts = [];
40068
40107
  let lastError;
40069
40108
  const hasFallbackCandidates = candidates.length > 1;
@@ -103926,7 +103965,12 @@ async function runEmbeddedPiAgent(params) {
103926
103965
  cfg: params.config,
103927
103966
  agentId: params.agentId,
103928
103967
  sessionKey: params.sessionKey
103929
- });
103968
+ }) || hasCodexApiSiblingFallback({
103969
+ provider,
103970
+ model: modelId,
103971
+ cfg: params.config,
103972
+ agentDir
103973
+ }); // JOHNNESS_PATCH_CODEX_API_SIBLING_FALLBACK_64
103930
103974
  await ensureJohnnessModelsJson(params.config, agentDir);
103931
103975
  let modelResolveOverride;
103932
103976
  let legacyBeforeAgentStartResult;