johns-harness 2026.9.13 → 2026.9.14

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.
Files changed (71) hide show
  1. package/README.md +9 -6
  2. package/dist/auth-profiles-5CHn7vq1.js +12 -0
  3. package/dist/commands-registry-C78-tPS3.js +14 -0
  4. package/dist/commands-registry-D0Jkz-Sn.js +14 -0
  5. package/dist/commands-registry-DJWLO-6B.js +14 -0
  6. package/dist/commands-registry-l51VuG0m.js +14 -0
  7. package/dist/compact-B247y5Qt.js +20 -2
  8. package/dist/config-koj5M_EO.js +12 -0
  9. package/dist/daemon-cli.js +12 -0
  10. package/dist/image-generate.js +269 -0
  11. package/dist/model-selection-BGlGpPgM.js +12 -0
  12. package/dist/model-selection-BU6wl1le.js +12 -0
  13. package/dist/model-selection-L7RMwsG-.js +12 -0
  14. package/dist/pi-embedded-C6ITuRXf.js +20 -2
  15. package/dist/pi-embedded-DoQsYfIY.js +20 -2
  16. package/dist/pi-embedded-helpers-CkWXaNFn.js +9 -0
  17. package/dist/pi-embedded-helpers-DLLQ3MsR.js +9 -0
  18. package/dist/plugin-sdk/bluebubbles.js +12 -0
  19. package/dist/plugin-sdk/commands-registry-B4ZP4Y1B.js +14 -0
  20. package/dist/plugin-sdk/commands-registry-B8Og-fWU.js +14 -0
  21. package/dist/plugin-sdk/commands-registry-BoAXxFpH.js +14 -0
  22. package/dist/plugin-sdk/commands-registry-Ca1h6XvV.js +14 -0
  23. package/dist/plugin-sdk/commands-registry-Do-Y2EhN.js +14 -0
  24. package/dist/plugin-sdk/commands-registry-DpRs-E_M.js +14 -0
  25. package/dist/plugin-sdk/config-4Fgm-yEH.js +12 -0
  26. package/dist/plugin-sdk/config-Di-lyCdh.js +12 -0
  27. package/dist/plugin-sdk/discord.js +12 -0
  28. package/dist/plugin-sdk/dispatch-BP0viZiL.js +20 -2
  29. package/dist/plugin-sdk/dispatch-CQsjmw7g.js +20 -2
  30. package/dist/plugin-sdk/dispatch-Cerq29sy.js +20 -2
  31. package/dist/plugin-sdk/dispatch-Cndjtt0g.js +20 -2
  32. package/dist/plugin-sdk/dispatch-UogiJYul.js +20 -2
  33. package/dist/plugin-sdk/feishu.js +12 -0
  34. package/dist/plugin-sdk/googlechat.js +12 -0
  35. package/dist/plugin-sdk/imessage.js +12 -0
  36. package/dist/plugin-sdk/mattermost.js +14 -0
  37. package/dist/plugin-sdk/model-auth-CX9cPHdC.js +12 -0
  38. package/dist/plugin-sdk/model-auth-Ciehz0x5.js +12 -0
  39. package/dist/plugin-sdk/model-auth-DVyo6JSX.js +12 -0
  40. package/dist/plugin-sdk/model-auth-kiHYHGrs.js +12 -0
  41. package/dist/plugin-sdk/model-selection-Dbj4jDYM.js +12 -0
  42. package/dist/plugin-sdk/pi-embedded-helpers-B89Z8pGU.js +9 -0
  43. package/dist/plugin-sdk/pi-embedded-helpers-BDFUaaiY.js +9 -0
  44. package/dist/plugin-sdk/pi-embedded-helpers-BnFhV3or.js +9 -0
  45. package/dist/plugin-sdk/pi-embedded-helpers-CRUVFL1T.js +9 -0
  46. package/dist/plugin-sdk/pi-embedded-helpers-CY7PsNpl.js +9 -0
  47. package/dist/plugin-sdk/pi-embedded-helpers-Dira0ZRW.js +9 -0
  48. package/dist/plugin-sdk/reply-DbZnH8-h.js +20 -2
  49. package/dist/plugin-sdk/signal.js +12 -0
  50. package/dist/plugin-sdk/slack.js +12 -0
  51. package/dist/plugin-sdk/telegram.js +12 -0
  52. package/dist/plugin-sdk/tlon.js +12 -0
  53. package/dist/plugin-sdk/twitch.js +12 -0
  54. package/dist/plugin-sdk/zalo.js +12 -0
  55. package/dist/plugin-sdk/zalouser.js +12 -0
  56. package/dist/redact-snapshot-CjeFvdcl.js +5 -0
  57. package/dist/redact-snapshot-DuqfRMA9.js +5 -0
  58. package/dist/reply-C5LKjXcC.js +20 -2
  59. package/dist/runtime-path.js +58 -0
  60. package/dist/tool-catalog-CFg6jrp9.js +9 -0
  61. package/dist/tool-catalog-DE9Q8xiB.js +9 -0
  62. package/dist/update-cli-Cm_l3W29.js +1 -1
  63. package/dist/update-command.js +276 -0
  64. package/docs/PATCHES.md +14 -0
  65. package/package.json +1 -1
  66. package/skills/openai-image-gen/SKILL.md +3 -3
  67. package/skills/openai-image-gen/scripts/gen.py +1 -1
  68. package/vendor/patched-deps/@mariozechner/pi-ai/dist/models.generated.js +37 -0
  69. package/skills/nano-banana-pro/SKILL.md +0 -65
  70. package/skills/nano-banana-pro/scripts/generate_image.py +0 -235
  71. package/skills/nano-banana-pro/scripts/test_generate_image.py +0 -36
package/README.md CHANGED
@@ -55,7 +55,10 @@ 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.
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`).
59
+
60
+ ### Updating from chat
61
+ 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.
59
62
 
60
63
  ### From source
61
64
 
@@ -68,8 +71,8 @@ node johnness.mjs --help
68
71
  ### Verify
69
72
 
70
73
  ```bash
71
- npm test # 701 tests against the installed dependency tree
72
- bash scripts/verify-patches.sh # 258 checks across every patch family
74
+ npm test # 767 tests against the installed dependency tree
75
+ bash scripts/verify-patches.sh # 360 checks across every patch family
73
76
  ```
74
77
 
75
78
  ## Launch a swarm with one command
@@ -115,7 +118,7 @@ Coding agents today run as isolated processes. Two agents on the same repository
115
118
  - **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.
116
119
  - **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.
117
120
  - **An append-only action log.** Agents record every consequential action to `ACTION_LOG.md` and read it before acting.
118
- - **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: 59 patch families, each with a regression test and a rollback path.
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.
119
122
  - **Reliability with a definition.** Bounded retries, explicit failure states, exact-once continuation, context-pressure recovery, durable delivery, and supervised gateway recovery.
120
123
  - **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.
121
124
  - **Local voice.** Optional native Whisper transcription of inbound audio, entirely on your machine. Off by default. [Setup](docs/NATIVE-WHISPER-MUSE.md).
@@ -182,7 +185,7 @@ The log is a [`RULES.md`](RULES.md) workspace convention enforced through the ac
182
185
 
183
186
  ## Patch families
184
187
 
185
- The runtime carries 59 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 258 checks against the package tree and installed dependencies, and 701 tests run against the installed dependency tree.
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.
186
189
 
187
190
  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).
188
191
 
@@ -199,7 +202,7 @@ Every family follows the same six steps: reproduce the failure, trace the exact
199
202
  ## Documentation
200
203
 
201
204
  - [`docs/index.md`](docs/index.md): runtime docs, from gateway and channels to plugins, nodes, and CLI reference.
202
- - [`docs/PATCHES.md`](docs/PATCHES.md): the 59 patch families, one record each.
205
+ - [`docs/PATCHES.md`](docs/PATCHES.md): the 62 patch families, one record each.
203
206
  - [`docs/NATIVE-WHISPER-MUSE.md`](docs/NATIVE-WHISPER-MUSE.md): local Whisper transcription setup.
204
207
  - [`RULES.md`](RULES.md): the default operating rules, including Rule Zero on production authorization.
205
208
 
@@ -6443,6 +6443,17 @@ function applyLegacyAudioTranscriptionModel(params) {
6443
6443
  params.changes.push(params.alreadySetMessage);
6444
6444
  }
6445
6445
  const LEGACY_CONFIG_MIGRATIONS_PART_2 = [
6446
+ {
6447
+ id: "agent.image-generation-model-v1",
6448
+ describe: "Migrate agents.defaults.imageGenerationModel string to primary/fallbacks",
6449
+ apply: (raw, changes) => {
6450
+ const defaults = getRecord(getRecord(raw.agents)?.defaults);
6451
+ if (!defaults || typeof defaults.imageGenerationModel !== "string") return;
6452
+ const primary = defaults.imageGenerationModel.trim();
6453
+ defaults.imageGenerationModel = primary ? { primary, fallbacks: [] } : void 0;
6454
+ changes.push("Migrated agents.defaults.imageGenerationModel string to primary/fallbacks");
6455
+ }
6456
+ }, // JOHNNESS_PATCH_GPT_IMAGE_62
6446
6457
  {
6447
6458
  id: "agent.model-config-v2",
6448
6459
  describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
@@ -10876,6 +10887,7 @@ function isValidNonNegativeByteSizeString(value) {
10876
10887
  const AgentDefaultsSchema = z.object({
10877
10888
  model: AgentModelSchema.optional(),
10878
10889
  imageModel: AgentModelSchema.optional(),
10890
+ imageGenerationModel: AgentModelSchema.optional(), // JOHNNESS_PATCH_GPT_IMAGE_62
10879
10891
  pdfModel: AgentModelSchema.optional(),
10880
10892
  pdfMaxBytesMb: z.number().positive().optional(),
10881
10893
  pdfMaxPages: z.number().int().positive().optional(),
@@ -1,3 +1,4 @@
1
+ // JOHNNESS_PATCH_UPDATE_COMMAND_62
1
2
  // JOHNNESS_PATCH_DEVICE_LOGIN_60
2
3
  // JOHNNESS_PATCH_DEVICE_LOGIN_60B
3
4
  import { l as escapeRegExp } from "./utils-DC4zYvW0.js";
@@ -181,6 +182,19 @@ function buildChatCommands() {
181
182
  captureRemaining: true
182
183
  }]
183
184
  }),
185
+ defineChatCommand({
186
+ key: "update",
187
+ nativeName: "update",
188
+ description: "Install the newest johns-harness and restart (owner only).",
189
+ textAlias: "/update",
190
+ category: "management",
191
+ args: [{
192
+ name: "version",
193
+ description: "Exact version to install (default latest)",
194
+ type: "string",
195
+ required: false
196
+ }]
197
+ }),
184
198
  defineChatCommand({
185
199
  key: "login",
186
200
  nativeName: "login",
@@ -1,3 +1,4 @@
1
+ // JOHNNESS_PATCH_UPDATE_COMMAND_62
1
2
  // JOHNNESS_PATCH_DEVICE_LOGIN_60
2
3
  // JOHNNESS_PATCH_DEVICE_LOGIN_60B
3
4
  import { D as isPlainObject, u as escapeRegExp } from "./logger-Bj0Xl6pn.js";
@@ -218,6 +219,19 @@ function buildChatCommands() {
218
219
  captureRemaining: true
219
220
  }]
220
221
  }),
222
+ defineChatCommand({
223
+ key: "update",
224
+ nativeName: "update",
225
+ description: "Install the newest johns-harness and restart (owner only).",
226
+ textAlias: "/update",
227
+ category: "management",
228
+ args: [{
229
+ name: "version",
230
+ description: "Exact version to install (default latest)",
231
+ type: "string",
232
+ required: false
233
+ }]
234
+ }),
221
235
  defineChatCommand({
222
236
  key: "login",
223
237
  nativeName: "login",
@@ -1,3 +1,4 @@
1
+ // JOHNNESS_PATCH_UPDATE_COMMAND_62
1
2
  // JOHNNESS_PATCH_DEVICE_LOGIN_60
2
3
  // JOHNNESS_PATCH_DEVICE_LOGIN_60B
3
4
  import { D as isPlainObject, u as escapeRegExp } from "./logger-BW8uLq6f.js";
@@ -218,6 +219,19 @@ function buildChatCommands() {
218
219
  captureRemaining: true
219
220
  }]
220
221
  }),
222
+ defineChatCommand({
223
+ key: "update",
224
+ nativeName: "update",
225
+ description: "Install the newest johns-harness and restart (owner only).",
226
+ textAlias: "/update",
227
+ category: "management",
228
+ args: [{
229
+ name: "version",
230
+ description: "Exact version to install (default latest)",
231
+ type: "string",
232
+ required: false
233
+ }]
234
+ }),
221
235
  defineChatCommand({
222
236
  key: "login",
223
237
  nativeName: "login",
@@ -1,3 +1,4 @@
1
+ // JOHNNESS_PATCH_UPDATE_COMMAND_62
1
2
  // JOHNNESS_PATCH_DEVICE_LOGIN_60
2
3
  // JOHNNESS_PATCH_DEVICE_LOGIN_60B
3
4
  import { Bi as DEFAULT_PROVIDER, Or as resolveConfiguredModelRef, zi as DEFAULT_MODEL } from "./auth-profiles-5CHn7vq1.js";
@@ -181,6 +182,19 @@ function buildChatCommands() {
181
182
  captureRemaining: true
182
183
  }]
183
184
  }),
185
+ defineChatCommand({
186
+ key: "update",
187
+ nativeName: "update",
188
+ description: "Install the newest johns-harness and restart (owner only).",
189
+ textAlias: "/update",
190
+ category: "management",
191
+ args: [{
192
+ name: "version",
193
+ description: "Exact version to install (default latest)",
194
+ type: "string",
195
+ required: false
196
+ }]
197
+ }),
184
198
  defineChatCommand({
185
199
  key: "login",
186
200
  nativeName: "login",
@@ -1,3 +1,5 @@
1
+ import { createImageGenerateTool } from "./image-generate.js";
2
+ import { handleUpdateCommand } from "./update-command.js";
1
3
  // JOHNNESS_PATCH_DEVICE_LOGIN_60
2
4
  // JOHNNESS_PATCH_DEVICE_LOGIN_60B
3
5
  import { handleDeviceLoginCommand } from "./codex-oauth-device.js";
@@ -11283,6 +11285,7 @@ const TRUSTED_TOOL_RESULT_MEDIA = new Set([
11283
11285
  "exec",
11284
11286
  "gateway",
11285
11287
  "image",
11288
+ "image_generate",
11286
11289
  "memory_get",
11287
11290
  "memory_search",
11288
11291
  "message",
@@ -15583,7 +15586,8 @@ function buildAgentSystemPrompt(params) {
15583
15586
  sessions_spawn: acpSpawnRuntimeEnabled ? "Spawn an isolated sub-agent or ACP coding session (runtime=\"acp\" requires `agentId` unless `acp.defaultAgent` is configured; ACP harness ids follow acp.allowedAgents, not agents_list)" : "Spawn an isolated sub-agent session",
15584
15587
  subagents: "List, steer, or kill sub-agent runs for this requester session",
15585
15588
  session_status: "Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
15586
- image: "Analyze an image with the configured image model"
15589
+ image: "Analyze an image with the configured image model",
15590
+ image_generate: "Generate or edit images with GPT Image 2 (subscription first, API key second)"
15587
15591
  };
15588
15592
  const toolOrder = [
15589
15593
  "read",
@@ -15610,7 +15614,8 @@ function buildAgentSystemPrompt(params) {
15610
15614
  "sessions_send",
15611
15615
  "subagents",
15612
15616
  "session_status",
15613
- "image"
15617
+ "image",
15618
+ "image_generate"
15614
15619
  ];
15615
15620
  const canonicalToolNames = (params.toolNames ?? []).map((tool) => tool.trim()).filter(Boolean);
15616
15621
  const canonicalByNormalized = /* @__PURE__ */ new Map();
@@ -40979,6 +40984,8 @@ function resolveSessionEntryForHookSessionKey(sessionStore, sessionKey) {
40979
40984
  async function handleCommands(params) {
40980
40985
  const deviceLoginReply = await handleDeviceLoginCommand(params, shouldHandleTextCommands({ cfg: params.cfg, surface: params.command.surface, commandSource: params.ctx.CommandSource }), { routeReply, callGateway });
40981
40986
  if (deviceLoginReply) return deviceLoginReply;
40987
+ const updateReply = await handleUpdateCommand(params, shouldHandleTextCommands({ cfg: params.cfg, surface: params.command.surface, commandSource: params.ctx.CommandSource }), { routeReply, callGateway, writeRestartSentinel, extractDeliveryInfo, restart: () => scheduleGatewaySigusr1Restart({ reason: "/update" }) }); // JOHNNESS_PATCH_UPDATE_COMMAND_62
40988
+ if (updateReply) return updateReply;
40982
40989
  if (HANDLERS === null) HANDLERS = [
40983
40990
  handlePluginCommand,
40984
40991
  handleBashCommand,
@@ -55646,6 +55653,16 @@ function createJohnnessTools(options) {
55646
55653
  fsPolicy: options?.fsPolicy,
55647
55654
  modelHasVision: options?.modelHasVision
55648
55655
  }) : null;
55656
+ const imageGenerateTool = options?.agentDir?.trim() ? createImageGenerateTool({
55657
+ config: options?.config,
55658
+ agentDir: options.agentDir,
55659
+ workspaceDir,
55660
+ sandbox: options?.sandboxRoot && options?.sandboxFsBridge ? { root: options.sandboxRoot, bridge: options.sandboxFsBridge } : void 0
55661
+ }, {
55662
+ resolveApiKeyForProvider,
55663
+ loadWebMedia,
55664
+ localRoots: resolveMediaToolLocalRoots(workspaceDir, { workspaceOnly: options?.fsPolicy?.workspaceOnly === true })
55665
+ }) : null; // JOHNNESS_PATCH_GPT_IMAGE_62
55649
55666
  const pdfTool = options?.agentDir?.trim() ? createPdfTool({
55650
55667
  config: options?.config,
55651
55668
  agentDir: options.agentDir,
@@ -55765,6 +55782,7 @@ function createJohnnessTools(options) {
55765
55782
  ...webSearchTool ? [webSearchTool] : [],
55766
55783
  ...webFetchTool ? [webFetchTool] : [],
55767
55784
  ...imageTool ? [imageTool] : [],
55785
+ ...imageGenerateTool ? [imageGenerateTool] : [],
55768
55786
  ...pdfTool ? [pdfTool] : []
55769
55787
  ];
55770
55788
  const pluginTools = resolvePluginTools({
@@ -3132,6 +3132,17 @@ function applyLegacyAudioTranscriptionModel(params) {
3132
3132
  params.changes.push(params.alreadySetMessage);
3133
3133
  }
3134
3134
  const LEGACY_CONFIG_MIGRATIONS_PART_2 = [
3135
+ {
3136
+ id: "agent.image-generation-model-v1",
3137
+ describe: "Migrate agents.defaults.imageGenerationModel string to primary/fallbacks",
3138
+ apply: (raw, changes) => {
3139
+ const defaults = getRecord(getRecord(raw.agents)?.defaults);
3140
+ if (!defaults || typeof defaults.imageGenerationModel !== "string") return;
3141
+ const primary = defaults.imageGenerationModel.trim();
3142
+ defaults.imageGenerationModel = primary ? { primary, fallbacks: [] } : void 0;
3143
+ changes.push("Migrated agents.defaults.imageGenerationModel string to primary/fallbacks");
3144
+ }
3145
+ }, // JOHNNESS_PATCH_GPT_IMAGE_62
3135
3146
  {
3136
3147
  id: "agent.model-config-v2",
3137
3148
  describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
@@ -6762,6 +6773,7 @@ function isValidNonNegativeByteSizeString(value) {
6762
6773
  const AgentDefaultsSchema = z.object({
6763
6774
  model: AgentModelSchema.optional(),
6764
6775
  imageModel: AgentModelSchema.optional(),
6776
+ imageGenerationModel: AgentModelSchema.optional(), // JOHNNESS_PATCH_GPT_IMAGE_62
6765
6777
  pdfModel: AgentModelSchema.optional(),
6766
6778
  pdfMaxBytesMb: z.number().positive().optional(),
6767
6779
  pdfMaxPages: z.number().int().positive().optional(),
@@ -6112,6 +6112,17 @@ function applyLegacyAudioTranscriptionModel(params) {
6112
6112
  params.changes.push(params.alreadySetMessage);
6113
6113
  }
6114
6114
  const LEGACY_CONFIG_MIGRATIONS_PART_2 = [
6115
+ {
6116
+ id: "agent.image-generation-model-v1",
6117
+ describe: "Migrate agents.defaults.imageGenerationModel string to primary/fallbacks",
6118
+ apply: (raw, changes) => {
6119
+ const defaults = getRecord(getRecord(raw.agents)?.defaults);
6120
+ if (!defaults || typeof defaults.imageGenerationModel !== "string") return;
6121
+ const primary = defaults.imageGenerationModel.trim();
6122
+ defaults.imageGenerationModel = primary ? { primary, fallbacks: [] } : void 0;
6123
+ changes.push("Migrated agents.defaults.imageGenerationModel string to primary/fallbacks");
6124
+ }
6125
+ }, // JOHNNESS_PATCH_GPT_IMAGE_62
6115
6126
  {
6116
6127
  id: "agent.model-config-v2",
6117
6128
  describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
@@ -9694,6 +9705,7 @@ function isValidNonNegativeByteSizeString(value) {
9694
9705
  const AgentDefaultsSchema = z.object({
9695
9706
  model: AgentModelSchema.optional(),
9696
9707
  imageModel: AgentModelSchema.optional(),
9708
+ imageGenerationModel: AgentModelSchema.optional(), // JOHNNESS_PATCH_GPT_IMAGE_62
9697
9709
  pdfModel: AgentModelSchema.optional(),
9698
9710
  pdfMaxBytesMb: z.number().positive().optional(),
9699
9711
  pdfMaxPages: z.number().int().positive().optional(),
@@ -0,0 +1,269 @@
1
+ // JOHNNESS_PATCH_GPT_IMAGE_62
2
+ // First-class `image_generate` agent tool: OpenAI Images API (generations when no input
3
+ // image, edits when one is given) with the model from `agents.defaults.imageGenerationModel`.
4
+ // Default order: `openai-codex/gpt-image-2` (ChatGPT subscription, Family 58 OAuth token)
5
+ // then `openai/gpt-image-2` (OPENAI_API_KEY or the configured openai provider key).
6
+ // A provider that rejects the request as unsupported (404/405/501, 429 usage limit, or no
7
+ // credentials) yields to the next entry; the result names the provider that served it.
8
+ // Output is written as PNG under <workspace>/media/generated and the path is returned so the
9
+ // agent can send it with the `message` tool. Path safety mirrors createImageTool: inputs load
10
+ // through loadWebMedia with the same local roots, outputs never escape the workspace.
11
+ import fs from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { Type } from "@sinclair/typebox";
14
+
15
+ export const DEFAULT_IMAGE_GENERATION_MODEL = Object.freeze({
16
+ primary: "openai-codex/gpt-image-2",
17
+ fallbacks: Object.freeze(["openai/gpt-image-2"]),
18
+ });
19
+ export const IMAGE_GENERATION_MODEL_ID = "gpt-image-2";
20
+ const OPENAI_BASE = "https://api.openai.com/v1";
21
+ const CODEX_BASE = "https://chatgpt.com/backend-api/codex";
22
+ const SIZES = new Set(["1024x1024", "1536x1024", "1024x1536", "auto"]);
23
+ const DEFAULT_MAX_INPUT_BYTES = 20 * 1024 * 1024;
24
+ const JWT_CLAIM_PATH = "https://api.openai.com/auth";
25
+
26
+ export function coerceImageGenerationModelConfig(cfg) {
27
+ const raw = cfg?.agents?.defaults?.imageGenerationModel;
28
+ if (typeof raw === "string" && raw.trim()) return { primary: raw.trim(), fallbacks: [] };
29
+ if (raw && typeof raw === "object") {
30
+ const primary = typeof raw.primary === "string" && raw.primary.trim() ? raw.primary.trim() : void 0;
31
+ const fallbacks = Array.isArray(raw.fallbacks) ? raw.fallbacks.filter((v) => typeof v === "string" && v.trim()).map((v) => v.trim()) : [];
32
+ if (primary || fallbacks.length > 0) return { primary: primary ?? fallbacks.shift(), fallbacks };
33
+ }
34
+ return { primary: DEFAULT_IMAGE_GENERATION_MODEL.primary, fallbacks: [...DEFAULT_IMAGE_GENERATION_MODEL.fallbacks] };
35
+ }
36
+
37
+ export function listImageGenerationCandidates(cfg) {
38
+ const { primary, fallbacks } = coerceImageGenerationModelConfig(cfg);
39
+ const out = [];
40
+ for (const ref of [primary, ...fallbacks]) {
41
+ if (!ref || out.some((c) => c.ref === ref)) continue;
42
+ const slash = ref.indexOf("/");
43
+ if (slash <= 0) continue;
44
+ out.push({ ref, provider: ref.slice(0, slash), model: ref.slice(slash + 1) });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ export function codexAccountIdFromToken(token) {
50
+ try {
51
+ const payload = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
52
+ const id = payload?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
53
+ if (typeof id === "string" && id) return id;
54
+ } catch {}
55
+ return null;
56
+ }
57
+
58
+ /** Endpoint + headers for one provider. Same OAuth transport shape as openai-codex-responses. */
59
+ export function buildProviderRequest({ provider, apiKey, edit, userAgent = "johnness" }) {
60
+ if (provider === "openai-codex") {
61
+ const accountId = codexAccountIdFromToken(apiKey);
62
+ if (!accountId) throw new ProviderUnsupported("codex token has no chatgpt_account_id");
63
+ return {
64
+ url: `${CODEX_BASE}/images/${edit ? "edits" : "generations"}`,
65
+ headers: { Authorization: `Bearer ${apiKey}`, "chatgpt-account-id": accountId, originator: "pi", "User-Agent": userAgent },
66
+ };
67
+ }
68
+ return { url: `${OPENAI_BASE}/images/${edit ? "edits" : "generations"}`, headers: { Authorization: `Bearer ${apiKey}` } };
69
+ }
70
+
71
+ export class ProviderUnsupported extends Error {
72
+ constructor(message, status) {
73
+ super(message);
74
+ this.name = "ProviderUnsupported";
75
+ this.status = status;
76
+ }
77
+ }
78
+
79
+ export function isUnsupportedStatus(status, bodyText = "") {
80
+ if (status === 404 || status === 405 || status === 501) return true;
81
+ if (status === 429 && /usage_limit|usage limit/i.test(bodyText)) return true;
82
+ if (status === 401 || status === 403) return true;
83
+ return false;
84
+ }
85
+
86
+ function redact(text) {
87
+ return String(text ?? "").replace(/[A-Za-z0-9_-]{40,}/g, "<redacted>").slice(0, 400);
88
+ }
89
+
90
+ function sanitizeName(input, fallback) {
91
+ const base = String(input ?? "").trim().replace(/\.[a-z0-9]+$/i, "").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
92
+ return base || fallback;
93
+ }
94
+
95
+ export function resolveOutputPath({ workspaceDir, output, index = 0, count = 1 }) {
96
+ const root = path.resolve(workspaceDir);
97
+ const mediaDir = path.join(root, "media", "generated");
98
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
99
+ let target;
100
+ if (output && String(output).trim()) {
101
+ const raw = String(output).trim();
102
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^[a-zA-Z]:[\\/]/.test(raw)) throw new Error(`output must be a workspace path, got ${raw}`);
103
+ const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw);
104
+ const rel = path.relative(root, resolved);
105
+ 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";
107
+ } else {
108
+ target = path.join(mediaDir, `gpt-image-${stamp}${count > 1 ? `-${index + 1}` : ""}.png`);
109
+ }
110
+ return target;
111
+ }
112
+
113
+ export function validateParams(args) {
114
+ const record = args && typeof args === "object" ? args : {};
115
+ const prompt = typeof record.prompt === "string" ? record.prompt.trim() : "";
116
+ if (!prompt) throw new Error("prompt required");
117
+ const inputs = [];
118
+ if (typeof record.image === "string" && record.image.trim()) inputs.push(record.image.trim());
119
+ if (Array.isArray(record.images)) for (const v of record.images) if (typeof v === "string" && v.trim()) inputs.push(v.trim());
120
+ if (inputs.length > 4) throw new Error("at most 4 input images");
121
+ const mask = typeof record.mask === "string" && record.mask.trim() ? record.mask.trim() : void 0;
122
+ if (mask && inputs.length === 0) throw new Error("mask requires an input image");
123
+ const size = typeof record.size === "string" && record.size.trim() ? record.size.trim() : "auto";
124
+ if (!SIZES.has(size)) throw new Error(`size must be one of ${[...SIZES].join(", ")}`);
125
+ const countRaw = typeof record.count === "number" ? Math.floor(record.count) : 1;
126
+ if (!Number.isFinite(countRaw) || countRaw < 1 || countRaw > 4) throw new Error("count must be 1..4");
127
+ 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 };
129
+ }
130
+
131
+ async function callProvider({ candidate, apiKey, params, images, mask, fetchImpl, userAgent }) {
132
+ const edit = images.length > 0;
133
+ const { url, headers } = buildProviderRequest({ provider: candidate.provider, apiKey, edit, userAgent });
134
+ let body;
135
+ if (edit) {
136
+ body = new FormData();
137
+ body.set("model", candidate.model);
138
+ body.set("prompt", params.prompt);
139
+ body.set("n", String(params.count));
140
+ if (params.size !== "auto") body.set("size", params.size);
141
+ if (params.quality) body.set("quality", params.quality);
142
+ images.forEach((img, i) => body.append("image[]", new Blob([img.buffer], { type: img.mimeType }), `input-${i + 1}.${img.mimeType.includes("jpeg") ? "jpg" : "png"}`));
143
+ if (mask) body.set("mask", new Blob([mask.buffer], { type: mask.mimeType }), "mask.png");
144
+ } else {
145
+ headers["content-type"] = "application/json";
146
+ body = JSON.stringify({ model: candidate.model, prompt: params.prompt, n: params.count, ...(params.size !== "auto" ? { size: params.size } : {}), ...(params.quality ? { quality: params.quality } : {}) });
147
+ }
148
+ const res = await fetchImpl(url, { method: "POST", headers, body });
149
+ const text = await res.text();
150
+ if (!res.ok) {
151
+ if (isUnsupportedStatus(res.status, text)) throw new ProviderUnsupported(`${candidate.provider} ${res.status}: ${redact(text)}`, res.status);
152
+ throw new Error(`${candidate.provider} ${res.status}: ${redact(text)}`);
153
+ }
154
+ let json;
155
+ try {
156
+ json = JSON.parse(text);
157
+ } catch {
158
+ throw new Error(`${candidate.provider}: non-JSON response`);
159
+ }
160
+ const data = Array.isArray(json?.data) ? json.data : [];
161
+ const buffers = [];
162
+ for (const item of data) {
163
+ if (typeof item?.b64_json === "string") buffers.push(Buffer.from(item.b64_json, "base64"));
164
+ else if (typeof item?.url === "string") {
165
+ const r = await fetchImpl(item.url);
166
+ if (!r.ok) throw new Error(`${candidate.provider}: image download HTTP ${r.status}`);
167
+ buffers.push(Buffer.from(await r.arrayBuffer()));
168
+ }
169
+ }
170
+ 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" };
172
+ }
173
+
174
+ /**
175
+ * Try each candidate in order. Returns { provider, model, buffers, usage, mode, attempts }.
176
+ * `resolveAuth({ provider })` -> { apiKey } or throws; a throw counts as unsupported for that provider.
177
+ */
178
+ export async function generateWithFallback({ cfg, params, images, mask, resolveAuth, fetchImpl = fetch, userAgent }) {
179
+ const candidates = params.model ? [{ ref: params.model, provider: params.model.split("/")[0], model: params.model.split("/").slice(1).join("/") || IMAGE_GENERATION_MODEL_ID }] : listImageGenerationCandidates(cfg);
180
+ if (candidates.length === 0) throw new Error("No image generation model configured. Set agents.defaults.imageGenerationModel.primary.");
181
+ const attempts = [];
182
+ for (const candidate of candidates) {
183
+ let apiKey;
184
+ try {
185
+ apiKey = (await resolveAuth({ provider: candidate.provider }))?.apiKey;
186
+ if (!apiKey) throw new Error("no credentials");
187
+ } catch (error) {
188
+ attempts.push({ provider: candidate.provider, skipped: `auth: ${redact(error?.message ?? error)}` });
189
+ continue;
190
+ }
191
+ try {
192
+ const result = await callProvider({ candidate, apiKey, params, images, mask, fetchImpl, userAgent });
193
+ return { provider: candidate.provider, model: candidate.model, ...result, attempts };
194
+ } catch (error) {
195
+ if (error instanceof ProviderUnsupported) {
196
+ attempts.push({ provider: candidate.provider, skipped: error.message });
197
+ continue;
198
+ }
199
+ throw error;
200
+ }
201
+ }
202
+ throw new Error(`No image provider could serve the request. ${attempts.map((a) => `${a.provider}: ${a.skipped}`).join(" | ")}`);
203
+ }
204
+
205
+ /**
206
+ * Tool factory. `deps` are supplied by the bundle so this module owns no second auth path:
207
+ * resolveApiKeyForProvider({ provider, cfg, agentDir }) from auth-profiles (Family 58 owner refresh)
208
+ * loadWebMedia(pathOrUrl, { maxBytes, localRoots }) with the same roots createImageTool uses
209
+ */
210
+ export function createImageGenerateTool(options, deps) {
211
+ const agentDir = options?.agentDir?.trim();
212
+ const workspaceDir = options?.workspaceDir?.trim();
213
+ if (!agentDir || !workspaceDir) return null;
214
+ if (options?.sandbox?.root?.trim()) return null;
215
+ const localRoots = deps.localRoots ?? [workspaceDir];
216
+ const fetchImpl = deps.fetch ?? fetch;
217
+ return {
218
+ label: "Image Generate",
219
+ 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.",
221
+ parameters: Type.Object({
222
+ prompt: Type.String({ description: "What to draw or how to change the input image." }),
223
+ image: Type.Optional(Type.String({ description: "Input image path to edit (workspace or allowed local root)." })),
224
+ images: Type.Optional(Type.Array(Type.String(), { description: "Multiple input images to edit together (max 4)." })),
225
+ mask: Type.Optional(Type.String({ description: "PNG mask path; transparent areas are edited." })),
226
+ size: Type.Optional(Type.String({ description: "1024x1024, 1536x1024, 1024x1536, or auto (default)." })),
227
+ count: Type.Optional(Type.Number({ description: "Number of images, 1..4 (default 1)." })),
228
+ quality: Type.Optional(Type.String({ description: "low, medium, high, or auto." })),
229
+ 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." })),
231
+ }),
232
+ execute: async (_toolCallId, args) => {
233
+ const params = validateParams(args);
234
+ const maxBytes = deps.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES;
235
+ const load = async (ref) => {
236
+ const raw = ref.startsWith("@") ? ref.slice(1).trim() : ref;
237
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^[a-zA-Z]:[\\/]/.test(raw) && !/^file:/i.test(raw)) throw new Error(`input must be a local file path: ${ref}`);
238
+ const media = await deps.loadWebMedia(raw.startsWith("file://") ? raw.slice(7) : raw, { maxBytes, localRoots });
239
+ if (media.kind !== "image") throw new Error(`Unsupported media type: ${media.kind}`);
240
+ return { buffer: media.buffer, mimeType: ("contentType" in media && media.contentType) || ("mimeType" in media && media.mimeType) || "image/png" };
241
+ };
242
+ const images = [];
243
+ for (const ref of params.inputs) images.push(await load(ref));
244
+ 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 }));
246
+ const result = await generateWithFallback({
247
+ cfg: options?.config,
248
+ params,
249
+ images,
250
+ mask,
251
+ fetchImpl,
252
+ userAgent: deps.userAgent,
253
+ resolveAuth: ({ provider }) => deps.resolveApiKeyForProvider({ provider, cfg: options?.config, agentDir }),
254
+ });
255
+ const written = [];
256
+ for (let i = 0; i < result.buffers.length && i < outputs.length; i++) {
257
+ await fs.mkdir(path.dirname(outputs[i]), { recursive: true });
258
+ await fs.writeFile(outputs[i], result.buffers[i]);
259
+ written.push(outputs[i]);
260
+ }
261
+ const served = `${result.provider}/${result.model}`;
262
+ const text = [`${result.mode === "edit" ? "Edited" : "Generated"} ${written.length} image${written.length === 1 ? "" : "s"} with ${served}.`, ...written.map((p) => `MEDIA:${p}`)].join("\n");
263
+ return {
264
+ content: [{ type: "text", text }],
265
+ details: { provider: result.provider, model: result.model, mode: result.mode, paths: written, usage: result.usage, attempts: result.attempts },
266
+ };
267
+ },
268
+ };
269
+ }
@@ -3027,6 +3027,17 @@ function applyLegacyAudioTranscriptionModel(params) {
3027
3027
  params.changes.push(params.alreadySetMessage);
3028
3028
  }
3029
3029
  const LEGACY_CONFIG_MIGRATIONS_PART_2 = [
3030
+ {
3031
+ id: "agent.image-generation-model-v1",
3032
+ describe: "Migrate agents.defaults.imageGenerationModel string to primary/fallbacks",
3033
+ apply: (raw, changes) => {
3034
+ const defaults = getRecord(getRecord(raw.agents)?.defaults);
3035
+ if (!defaults || typeof defaults.imageGenerationModel !== "string") return;
3036
+ const primary = defaults.imageGenerationModel.trim();
3037
+ defaults.imageGenerationModel = primary ? { primary, fallbacks: [] } : void 0;
3038
+ changes.push("Migrated agents.defaults.imageGenerationModel string to primary/fallbacks");
3039
+ }
3040
+ }, // JOHNNESS_PATCH_GPT_IMAGE_62
3030
3041
  {
3031
3042
  id: "agent.model-config-v2",
3032
3043
  describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
@@ -9298,6 +9309,7 @@ function isValidNonNegativeByteSizeString(value) {
9298
9309
  const AgentDefaultsSchema = z.object({
9299
9310
  model: AgentModelSchema.optional(),
9300
9311
  imageModel: AgentModelSchema.optional(),
9312
+ imageGenerationModel: AgentModelSchema.optional(), // JOHNNESS_PATCH_GPT_IMAGE_62
9301
9313
  pdfModel: AgentModelSchema.optional(),
9302
9314
  pdfMaxBytesMb: z.number().positive().optional(),
9303
9315
  pdfMaxPages: z.number().int().positive().optional(),
@@ -3002,6 +3002,17 @@ function applyLegacyAudioTranscriptionModel(params) {
3002
3002
  params.changes.push(params.alreadySetMessage);
3003
3003
  }
3004
3004
  const LEGACY_CONFIG_MIGRATIONS_PART_2 = [
3005
+ {
3006
+ id: "agent.image-generation-model-v1",
3007
+ describe: "Migrate agents.defaults.imageGenerationModel string to primary/fallbacks",
3008
+ apply: (raw, changes) => {
3009
+ const defaults = getRecord(getRecord(raw.agents)?.defaults);
3010
+ if (!defaults || typeof defaults.imageGenerationModel !== "string") return;
3011
+ const primary = defaults.imageGenerationModel.trim();
3012
+ defaults.imageGenerationModel = primary ? { primary, fallbacks: [] } : void 0;
3013
+ changes.push("Migrated agents.defaults.imageGenerationModel string to primary/fallbacks");
3014
+ }
3015
+ }, // JOHNNESS_PATCH_GPT_IMAGE_62
3005
3016
  {
3006
3017
  id: "agent.model-config-v2",
3007
3018
  describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
@@ -9187,6 +9198,7 @@ function isValidNonNegativeByteSizeString(value) {
9187
9198
  const AgentDefaultsSchema = z.object({
9188
9199
  model: AgentModelSchema.optional(),
9189
9200
  imageModel: AgentModelSchema.optional(),
9201
+ imageGenerationModel: AgentModelSchema.optional(), // JOHNNESS_PATCH_GPT_IMAGE_62
9190
9202
  pdfModel: AgentModelSchema.optional(),
9191
9203
  pdfMaxBytesMb: z.number().positive().optional(),
9192
9204
  pdfMaxPages: z.number().int().positive().optional(),
@@ -2914,6 +2914,17 @@ function applyLegacyAudioTranscriptionModel(params) {
2914
2914
  params.changes.push(params.alreadySetMessage);
2915
2915
  }
2916
2916
  const LEGACY_CONFIG_MIGRATIONS_PART_2 = [
2917
+ {
2918
+ id: "agent.image-generation-model-v1",
2919
+ describe: "Migrate agents.defaults.imageGenerationModel string to primary/fallbacks",
2920
+ apply: (raw, changes) => {
2921
+ const defaults = getRecord(getRecord(raw.agents)?.defaults);
2922
+ if (!defaults || typeof defaults.imageGenerationModel !== "string") return;
2923
+ const primary = defaults.imageGenerationModel.trim();
2924
+ defaults.imageGenerationModel = primary ? { primary, fallbacks: [] } : void 0;
2925
+ changes.push("Migrated agents.defaults.imageGenerationModel string to primary/fallbacks");
2926
+ }
2927
+ }, // JOHNNESS_PATCH_GPT_IMAGE_62
2917
2928
  {
2918
2929
  id: "agent.model-config-v2",
2919
2930
  describe: "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
@@ -7347,6 +7358,7 @@ function isValidNonNegativeByteSizeString(value) {
7347
7358
  const AgentDefaultsSchema = z.object({
7348
7359
  model: AgentModelSchema.optional(),
7349
7360
  imageModel: AgentModelSchema.optional(),
7361
+ imageGenerationModel: AgentModelSchema.optional(), // JOHNNESS_PATCH_GPT_IMAGE_62
7350
7362
  pdfModel: AgentModelSchema.optional(),
7351
7363
  pdfMaxBytesMb: z.number().positive().optional(),
7352
7364
  pdfMaxPages: z.number().int().positive().optional(),