pi-codex-image-gen 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,36 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-10-09
10
+
11
+ ### Documentation
12
+
13
+ - Record the decision to defer a native Pi image adapter: generic image operations do not preserve the current approval, large-original delivery, save/recovery, and usage contracts.
14
+ - Summarize independent image credentials, absent-only legacy fallback, routing-model semantics, and separately invoked API billing.
15
+
16
+ ### Added
17
+
18
+ - Add shared `codex_images` namespace discovery and quota/file-effect annotations without changing either entry point's exposure or the artifact-delivery contract.
19
+ - Add `codex_generate_image_artifact` for codemode and other nested workflows, returning structured original-file metadata instead of base64 image payloads.
20
+ - Add branch-local artifact recovery, recent-artifact edits, and `/image-artifacts`; keep completed private temporary originals through script failures and reload.
21
+
22
+ ### Fixed
23
+
24
+ - Block legacy-account fallback when package-owned image authentication is configured but cannot be resolved, including unsupported stored credential types hidden by Pi's OAuth-only provider.
25
+ - Make inline-delivery `codex_generate_image` model-only so nested calls cannot consume image quota while discarding attachments (#158).
26
+ - Preserve recoverable artifacts when persistent saves fail, report post-generation storage failures without retries, and support explicit codemode display through Pi's image reader.
27
+ - Anchor recovery before generation so cancelled commits that finish after tree navigation or session replacement remain recoverable only from the originating branch and its forks.
28
+
29
+ ### Changed
30
+
31
+ - Update the shared Pi development and contract-test baseline to 1.1.0; require Node.js >=22.19.0 to match the host runtime. Pi remains a host-supplied peer dependency.
32
+
33
+ ## [0.1.15] - 2026-10-02
34
+
35
+ ### Changed
36
+
37
+ - Use `gpt-6-astra` as the default Codex image-routing model instead of `gpt-5.5`. Preserve explicit tool/configuration overrides and backend-selected image rendering.
38
+
9
39
  ## [0.1.14] - 2026-10-02
10
40
 
11
41
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -22,7 +22,24 @@ pi install -l /path/to/pi-mono/packages/pi-codex-image-gen
22
22
  pi
23
23
  ```
24
24
 
25
- With Pi 0.85.1 or later, run `/login codex-images`, complete the ChatGPT browser login, and ask Pi to generate an image. Chat can stay on `openai` OAuth. Neither the Codex app nor Pi's legacy OAuth helpers are required.
25
+ With Pi 1.1.0 or later, run `/login codex-images`, complete the ChatGPT browser login, and ask Pi to generate an image. Chat can stay on `openai` OAuth. Neither the Codex app nor Pi's legacy OAuth helpers are required.
26
+
27
+ ### Image delivery smoke test
28
+
29
+ The default tests use real Pi sessions with synthetic credentials and mocked SSE;
30
+ they do not consume subscription quota. Test both nested events and the parent
31
+ codemode result. The shared development baseline is Pi 1.1.0 on Node >=22.19.0.
32
+
33
+ For an explicitly authorized live smoke test, load this package in a disposable
34
+ Pi profile with the existing image-capable OAuth login. Keep credentials out of
35
+ the repository and do not copy them into test fixtures. Generate one image using
36
+ `codex_generate_image_artifact` with `save: "none"`, inspect its original pixels,
37
+ and display it by reading `result.artifact.path` and passing the returned image
38
+ block to `image()`. Report the reader's text note if no image block is returned.
39
+ Run `/image-artifacts`, reload, and recover the same file without regeneration.
40
+ Check that a nested call to `codex_generate_image` is unavailable without making
41
+ another image request. Do not retry an ambiguous connection/quota failure.
42
+ Do not use the separately billed API CLI.
26
43
 
27
44
  For a one-off run without changing settings:
28
45
 
package/README.md CHANGED
@@ -10,6 +10,7 @@ Create and edit images without leaving [Pi](https://pi.dev).
10
10
  - **Edit from references** — transform up to five local or recent conversation images.
11
11
  - **Save where work happens** — return images inline or organize them by project, session, or custom directory.
12
12
  - **Separate image login** — keep Pi chat on `openai` OAuth while the extension handles image authentication.
13
+ - **Scripted workflows without image payloads** — generate recoverable originals through codemode and load images only for explicit display.
13
14
 
14
15
  ## Install
15
16
 
@@ -54,9 +55,133 @@ The tool reports generation stages and the backend's returned size, quality, bac
54
55
  - Prompts: 32,000 characters. References: five regular PNG/JPEG/WebP files or conversation images, at most 20 MiB each and 50 MiB combined.
55
56
  - Responses: 100 MiB total, with at most one 32 MiB decoded output image. Base64 and format signatures are checked; this is not a full image decoder. Backend text and revised prompts are limited to 4,000 characters; HTTP error bodies are read only up to 16 KiB and are not displayed.
56
57
  - Transient HTTP failures have bounded retries. Quota exhaustion, moderation errors, failed/incomplete streams, connection errors, and deadlines are not automatically retried. Avoid immediately repeating an ambiguous failure: the first generation may have consumed quota.
57
- - Local save settings are checked before generation. Existing files are never overwritten; a save failure still returns the inline image and a warning.
58
+ - Local save settings are checked before generation. Existing files are never overwritten; a persistent save failure still returns the direct inline image or scripted original artifact and a bounded warning.
58
59
  - Cloudflare challenges are reported as connection failures, not as proof that your subscription or image model is unsupported.
59
60
 
61
+ ### Direct and codemode tools
62
+
63
+ Use Pi 1.1.0 or newer and Node.js >=22.19.0.
64
+
65
+ | Tool | Reachability | Delivery |
66
+ | --- | --- | --- |
67
+ | `codex_generate_image` | Model-only; available directly with codemode off, `on`, or `only`. Nested calls are blocked. | Summary and inline image, plus an optional persistent save. |
68
+ | `codex_generate_image_artifact` | Codemode and other nested callers. Not declared directly by default, but explicit activation is supported. | Structured metadata and an original file path; no image payload. |
69
+
70
+ Pi's `codemode` exposure is not a strict codemode-only restriction. Other tools
71
+ can call the artifact tool through `ctx.executeTool()`. If explicitly activated
72
+ directly, it still returns paths and metadata rather than an image attachment.
73
+ Both tools use the same generation/editing parameters, image login, backend,
74
+ validation, and quota safeguards.
75
+
76
+ Both definitions share the `codex_images` namespace and advisory hints:
77
+ mutating, non-destructive (no overwrites), non-idempotent, and open-world.
78
+ Generation can consume quota and write files; these hints never grant approval.
79
+ Await `describeNamespace("codex_images")` or
80
+ `searchTools("image artifact", { namespace: "codex_images" })` for the callable
81
+ artifact tool, including with zero inline budget. The inline tool stays
82
+ model-only and receives no new output schema. The artifact schema, delivery,
83
+ recovery, explicit-request policy, and no-automatic-retry rules are unchanged.
84
+
85
+ ```js
86
+ const result = await tools.codex_generate_image_artifact({
87
+ prompt: "A red fox in watercolor",
88
+ save: "none"
89
+ });
90
+ text(result); // summary, artifact: {path, mimeType, byteCount}, savedPath?, saveWarning?
91
+ ```
92
+
93
+ For explicit display:
94
+
95
+ ```js
96
+ const preview = await tools.read({ path: result.artifact.path });
97
+ if (preview?.type === "image") image(preview);
98
+ else text(preview); // Pi can omit images it cannot decode or bound.
99
+ text(result.summary);
100
+ ```
101
+
102
+ `read` can resize or omit an image that cannot be decoded within Pi's display
103
+ limits. Check that its result is an image block before calling `image()` when
104
+ handling untrusted outputs. The original file is unchanged. Do not print image
105
+ bytes using `text()`, `console`, or `return`, or store them in codemode's store.
106
+ Large originals (up to 32 MiB) are transported as paths, not base64 through
107
+ codemode's 16,777,216-character output budget. Pi's `image()` also saves a
108
+ temporary display copy.
109
+
110
+ For chained edits, use `referencedImagePaths: [result.artifact.path]`.
111
+ `numLastImagesToInclude` also reads artifact records from the current session
112
+ branch, including generations that were never displayed. The normal 20 MiB
113
+ per-reference and 50 MiB aggregate limits still apply: a 32 MiB output is
114
+ recoverable but cannot be used as an edit input without first reducing its size.
115
+ Each edit is a new generation request and can consume quota.
116
+
117
+ #### Artifact storage and recovery
118
+
119
+ The artifact tool reserves a new private `pi-codex-image-*` directory under the
120
+ OS temporary directory before generating. Original files have user-only
121
+ permissions (directory `0700`, file `0600`) and are never overwritten.
122
+ `save: "none"` means **no persistent user copy** for this tool; it does not
123
+ disable temporary original storage. The direct tool's `none` mode still does
124
+ not write the image to disk.
125
+
126
+ Completed originals are not automatically deleted by this extension, including
127
+ on script failure/timeout, reload, shutdown, or branch changes. They remain
128
+ until user or OS temporary-file cleanup; there is no guaranteed retention
129
+ period across OS cleanup or reboot. Copy needed assets to persistent storage.
130
+ Only incomplete reservations owned by the current call are cleaned up.
131
+
132
+ Before generation, a branch-local `codex-image-artifact-reservation` entry
133
+ anchors recovery to the originating branch. On completion, a private, bounded
134
+ JSON manifest beside the reserved original records path, MIME type, byte count,
135
+ and call ID. Normal same-branch completions also append a
136
+ `codex-image-artifact` session entry, without image bytes or prompts. Late
137
+ completions after cancellation do not append records to unrelated branches or
138
+ replacement sessions; the original branch's reservation reads the manifest.
139
+ Run `/image-artifacts` to list the last 20 recorded original paths on the
140
+ current branch without generation or network work. Records survive reload,
141
+ resume, and session forks that preserve the entries; abandoned branches are
142
+ not included. Listing reads validated recovery manifests, not image bytes, and
143
+ does not verify that an original still exists. A missing
144
+ original fails recent-image editing before generation; do not regenerate it
145
+ automatically.
146
+
147
+ If temporary storage is known to be unavailable, the artifact call fails before
148
+ generation. If writing the original fails after generation, a successful
149
+ requested persistent save becomes the recovery artifact, with a warning. If
150
+ neither file can be saved, the call fails explicitly: **quota may already have
151
+ been consumed and there is no recoverable artifact**. No generation retry is
152
+ made. A failure to persist session recovery metadata is reported with the
153
+ recoverable file path rather than hiding the completed file.
154
+
155
+ ### Native Pi image models
156
+
157
+ `codex-images` is an **authentication-only provider**, not an image-model
158
+ catalog entry. Pi's `models.generateImages()` and
159
+ `ctx.modelRegistry.generateImages()` do not expose this package's subscription
160
+ backend. Continue using the direct or artifact tool above. OpenRouter image
161
+ models use their own credentials/billing and are not a subscription fallback.
162
+
163
+ A native adapter is deferred on the Pi 1.1.0 baseline. The generic image
164
+ context provides text/image blocks, not this package's save modes, routing
165
+ override, output-format control, or branch-aware recent-image selection.
166
+ Generic calls also do not inherit the image tools' approval hooks or exclusions.
167
+ Registering an image operation would add another callable surface, not merely
168
+ another discovery label.
169
+
170
+ Generic codemode image results are held in memory until explicitly displayed.
171
+ An undisplayed image has no automatically saved original or `/image-artifacts`
172
+ record. VM memory or aggregate output limits can reject display after
173
+ generation finishes. A successful `image()` display saves a temporary copy,
174
+ but that is not this package's pre-generation reservation and branch-recovery
175
+ contract. The artifact tool avoids transporting large originals as base64.
176
+
177
+ The current tools retain backend numeric counters in `details.usage`; these
178
+ are informational, not Pi ledger entries or verified subscription charges.
179
+ Pi aggregates **reported** native image usage on a codemode result, but a raw
180
+ extension `generateImages()` call does not automatically persist it. Neither
181
+ missing usage nor a zero catalog price proves generation is free. A future
182
+ adapter needs an explicit accounting and unknown-cost policy as well as safe
183
+ delivery, recovery, authentication, and approval contracts.
184
+
60
185
  ### Images 2.5 and API fallback
61
186
 
62
187
  The optional `skills/imagegen/scripts/image_gen.py` API CLI accepts `--model gpt-image-2.5-flare` or `--model gpt-image-2.5-sunburst`, including their `2026-09-08` snapshots. Both accept `--quality xhigh` and `--quality max` in addition to the existing quality settings. The CLI default remains `gpt-image-2`. Both 2.5 models support `--size auto` and custom dimensions such as `1536x864`, under the [documented size constraints](skills/imagegen/references/image-api.md#flexible-sizes-gpt-image-2-and-25). Resolutions above `2560x1440` are experimental.
@@ -69,6 +194,17 @@ In subscription tests on September 11, 2026, the direct endpoint accepted Flare,
69
194
 
70
195
  ## Authentication
71
196
 
197
+ | Credential / route | Model selection | Usage and fallback |
198
+ | --- | --- | --- |
199
+ | `codex-images` OAuth / private Codex Responses | `model` selects a routing model, not an image model | ChatGPT image quota; preferred credential |
200
+ | Legacy Pi `openai-codex` OAuth / same backend | Same routing semantics | Used only when owned image credentials are absent; no switch after an auth/generation failure |
201
+ | Pi `openai` ChatGPT plan-sharing login | Any chat model | Not accepted for image generation; never sent to this backend |
202
+ | OpenAI API key | Explicit standalone Python CLI only | Separate API billing; never an automatic fallback from the Pi tool |
203
+
204
+ Changing the chat default to GPT-6.1 Sol does not change image authentication
205
+ or certify the image backend's served model. The backend chooses the image
206
+ model; successful inference on another route is not image capability evidence.
207
+
72
208
  The extension owns an image-capable ChatGPT OAuth flow, registered as **Codex Images** (`codex-images`). Pi stores its credentials and handles refresh. Your chat provider can remain `openai`; image authentication is independent.
73
209
 
74
210
  ```
@@ -77,11 +213,11 @@ The extension owns an image-capable ChatGPT OAuth flow, registered as **Codex Im
77
213
 
78
214
  Complete the browser login with your ChatGPT account. You can also run `/login` and select **Codex Images (ChatGPT subscription)**. The browser redirects to `http://localhost:1455/auth/callback`. If the callback cannot reach Pi, paste the **full redirect URL** into Pi's login prompt, not into chat. Login expires after ten minutes.
79
215
 
80
- Use Pi 0.85.1 or later. Pi stores the `codex-images` credential in its agent auth store (normally `~/.pi/agent/auth.json`). `/logout codex-images` removes that credential without changing your chat login. No credential files are created by the extension itself.
216
+ Use Pi 1.1.0 or later. Pi stores the `codex-images` credential in its agent auth store (normally `~/.pi/agent/auth.json`). `/logout codex-images` removes that credential without changing your chat login. No credential files are created by the extension itself.
81
217
 
82
218
  The package implements the Codex-compatible OAuth protocol itself. It neither imports Pi's `openai-codex` OAuth helpers nor requires the Codex app or its credential store. It still depends on OpenAI continuing to accept that public OAuth client and private image endpoint; this is not a new OAuth application registered with OpenAI.
83
219
 
84
- Existing Pi `openai-codex` credentials remain a compatibility fallback when `codex-images` credentials are absent and the legacy provider is available. With both image logins, `codex-images` wins. A selected login that fails to refresh or generate does not switch to another account.
220
+ Existing Pi `openai-codex` credentials remain a compatibility fallback when `codex-images` credentials are absent and the legacy provider is available. With both image logins, `codex-images` wins. A selected login that fails to refresh or generate does not switch to another account. Configured but unusable image credentials also block fallback, including a stored API-key credential that Pi's OAuth-only provider cannot resolve. Re-run `/login codex-images` to repair that login instead of silently using a different account.
85
221
 
86
222
  Both image logins use `https://chatgpt.com/backend-api/codex/responses`. Pi's new `openai` plan-sharing OAuth grant is different and [does not support image generation](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations). The extension never sends that chat token to the Codex backend. API keys do **not** enable this tool or API-key billing.
87
223
 
@@ -100,7 +236,7 @@ Project config overrides global config only when project trust is active. If pro
100
236
  {
101
237
  "save": "global",
102
238
  "saveDir": "~/Pictures/generated",
103
- "model": "gpt-5.5"
239
+ "model": "gpt-6-astra"
104
240
  }
105
241
  ```
106
242
 
@@ -110,7 +246,7 @@ Project config overrides global config only when project trust is active. If pro
110
246
  | --------- | ------ | ---------- | ---------------------------------------- |
111
247
  | `save` | string | `"global"` | Default save mode (see below). |
112
248
  | `saveDir` | string | — | Directory used when `save=custom`. |
113
- | `model` | string | `"gpt-5.5"`| Codex routing model, not the backend image model. |
249
+ | `model` | string | `"gpt-6-astra"`| Codex routing model, not the backend image model. |
114
250
 
115
251
  ### Environment variables
116
252
 
@@ -125,17 +261,19 @@ Project config overrides global config only when project trust is active. If pro
125
261
 
126
262
  | Mode | Behavior |
127
263
  | --------- | ---------------------------------------------------------------- |
128
- | `none` | Image is returned inline but not written to disk. |
264
+ | `none` | Direct tool: inline image, no disk save. Artifact tool: private temporary original, no persistent copy. |
129
265
  | `project` | Saves to `<project>/.pi/generated-images/<session-id>/`. |
130
266
  | `global` | Saves to `~/.pi/agent/generated-images/<session-id>/`. |
131
267
  | `custom` | Saves to a user-specified directory (requires `saveDir` or env). `~` and `~/...` expand to the current user's home directory. |
132
268
 
133
269
  ## Tool parameters
134
270
 
271
+ Both generation entry points accept these parameters.
272
+
135
273
  | Parameter | Type | Required | Description |
136
274
  | -------------- | ------ | -------- | ------------------------------------------------------------------ |
137
275
  | `prompt` | string | ✅ | The image generation prompt. |
138
- | `model` | string | — | Override the Codex model. Defaults to config or `gpt-5.5`. |
276
+ | `model` | string | — | Override the Codex model. Defaults to config or `gpt-6-astra`. |
139
277
  | `outputFormat` | string | — | `png` (default), `jpeg`, or `webp`. |
140
278
  | `save` | string | — | Override save mode for this call. |
141
279
  | `saveDir` | string | — | Directory when `save=custom`. Relative paths resolve under CWD. |
@@ -145,12 +283,13 @@ Project config overrides global config only when project trust is active. If pro
145
283
  ## How it works
146
284
 
147
285
  1. Resolves package-owned `codex-images` OAuth via Pi, or falls back to available legacy Pi `openai-codex` OAuth.
148
- 2. Sends a request to the Codex Responses endpoint and routing model (default `gpt-5.5`) with the `image_generation` tool enabled.
286
+ 2. Sends a request to the Codex Responses endpoint and routing model (default `gpt-6-astra`) with the `image_generation` tool enabled.
149
287
  3. For edits, attaches the selected local or conversation images to the request.
150
288
  4. The backend selects an image model to generate or edit the image.
151
289
  5. Parses the SSE stream and strictly validates the returned base64 and image format.
152
- 6. Saves the image according to the active save mode; persistence failures produce a warning without discarding a valid inline image.
153
- 7. Returns the image data inline plus metadata (model, format, path, revised prompt, usage).
290
+ 6. For the artifact tool, commits the reserved temporary original and records its branch-local recovery metadata.
291
+ 7. Saves a persistent copy according to the active save mode; failures produce a bounded warning without discarding a usable inline image or artifact.
292
+ 8. Returns the direct inline image or structured artifact metadata. Codemode displays an image only through an explicit `read` and `image()` operation.
154
293
 
155
294
  ## Troubleshooting
156
295
 
package/SECURITY.md CHANGED
@@ -21,7 +21,7 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
21
21
 
22
22
  `pi-codex-image-gen` is a Pi package. Pi extensions execute with the same permissions as the local user running Pi. Users should review installed Pi packages and only install packages from sources they trust.
23
23
 
24
- The extension implements image-capable ChatGPT OAuth under the provider ID `codex-images`, using the Codex-compatible public OAuth client ID (not a client secret). Pi stores access/refresh tokens in its agent credential store, normally `~/.pi/agent/auth.json`, and coordinates refresh. This is local credential persistence, not encrypted secret storage; protect the agent directory and do not commit it. The extension does not write its own credential files or access the Codex app's credential store. `/logout codex-images` removes only the image login. Existing legacy Pi `openai-codex` OAuth remains a fallback when package-owned credentials are absent; refresh or request failure does not switch accounts.
24
+ The extension implements image-capable ChatGPT OAuth under the provider ID `codex-images`, using the Codex-compatible public OAuth client ID (not a client secret). Pi stores access/refresh tokens in its agent credential store, normally `~/.pi/agent/auth.json`, and coordinates refresh. This is local credential persistence, not encrypted secret storage; protect the agent directory and do not commit it. The extension does not write its own credential files or access the Codex app's credential store. `/logout codex-images` removes only the image login. Existing legacy Pi `openai-codex` OAuth remains a fallback when package-owned credentials are absent; refresh or request failure does not switch accounts. An unresolved owned login also checks Pi's provider-auth configuration status: a configured but unusable credential is an error, not permission to try a different account. This includes a stored API-key credential unsupported by the OAuth-only provider; no credential file is read directly by the extension.
25
25
 
26
26
  Browser login uses random OAuth state and S256 PKCE. Its callback listener binds only to `127.0.0.1:1455`; the registered redirect URI is `http://localhost:1455/auth/callback`. Loopback HTTP is limited to this local callback. Automatic and pasted callbacks must have the expected origin, path, and state. Pasted input must be the full redirect URL, never a bare authorization code. Invalid callbacks cannot finish login. Listener cleanup runs on success, failure, and cancellation. Login has a ten-minute limit; token requests have a 30-second limit and 64 KiB response bound, with no retries or redirects. Do not paste login URLs into chat or log them.
27
27
 
@@ -31,4 +31,52 @@ Network work has a five-minute deadline and a 100 MiB response bound. Output ima
31
31
 
32
32
  Generated files are created exclusively with user-only permissions. A repeated backend ID or existing destination produces a save warning rather than overwriting that file. Cancellation and stream failures do not trigger automatic generation retries because the remote operation may already have consumed quota.
33
33
 
34
+ The inline-delivery tool is `model-only`; Pi blocks nested calls before execution.
35
+ The artifact tool uses `codemode` exposure, not an exclusive codemode permission:
36
+ other nested tools and explicitly activated direct calls can use it. Tool
37
+ discovery does not initiate generation or credential resolution. Both entry
38
+ points preserve the same OAuth and backend limits.
39
+
40
+ The provider exposes no native image models. A native image operation would
41
+ be separately callable through `models.generateImages()`, without inheriting
42
+ the named tools' approval hooks or exclusions. Such an adapter is deferred;
43
+ the generic API is not an alternate route around these tools' restrictions.
44
+ Backend counters in `details.usage` are informational and do not establish
45
+ metered session usage, remaining quota, or a verified subscription price.
46
+
47
+ Artifact generation reserves a private random OS temporary directory (`0700`)
48
+ and an exclusively created original file (`0600`) before requesting generation.
49
+ The opened file descriptor is used for writes; a synced, regular file with the
50
+ same inode/device and expected length must remain at the returned path.
51
+ Successful originals are retained through script errors, reload, shutdown, and
52
+ branch changes; only incomplete reservations are deleted by the extension.
53
+ There is no automatic expiry. User/OS cleanup can remove them at any time, so
54
+ temporary paths are not durable project assets. `save: "none"` on the artifact
55
+ tool still writes this temporary original. Protect or explicitly remove
56
+ sensitive images when no longer needed.
57
+
58
+ The session stores bounded artifact metadata (path, MIME type, byte count, call
59
+ ID), not generated bytes, prompts, or credentials. A pre-generation reservation
60
+ entry anchors the operation to its source branch. A private `0600` JSON
61
+ completion manifest in the reserved directory allows late completions to be
62
+ recovered without appending state to an unrelated current branch/session.
63
+ Forks and resumes that retain the reservation can recover the same original.
64
+ Fallback persistent copies keep the private recovery manifest, but remove any
65
+ incomplete temporary original.
66
+
67
+ `/image-artifacts` exposes the last 20 current-branch paths without reading
68
+ image bytes or accessing the network. Manifest reads reject symlinks and
69
+ non-regular files, are capped at 8 KiB, and validate the returned metadata and
70
+ its reservation identity. Session metadata is validated before recent-image selection or
71
+ listing. Recent artifact inputs receive the existing regular-file, signature,
72
+ and input-size checks. Explicit display uses Pi's image reader and can resize
73
+ or omit un-decodable images without modifying originals.
74
+
75
+ A failed persistent copy does not remove a committed temporary original. If
76
+ the temporary write fails after generation, a successful requested persistent
77
+ copy can serve as the artifact; if both are unavailable, the tool reports an
78
+ explicit post-generation failure and possible quota consumption, not success
79
+ or an automatic retry. Disk or session-store failures cannot guarantee recovery.
80
+ Base64 originals do not cross codemode's structured generation-result boundary.
81
+
34
82
  At startup, `@mocito/install-telemetry` sends a best-effort install/update ping to the configured telemetry endpoint once per package version unless CI, Pi offline/telemetry settings, or `enableInstallTelemetry: false` disables it. It contains only the package name/version and parsed platform/runtime/architecture; it does not include prompts, file paths, configuration values, credentials, or provider responses.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Project-local Codex image generation extension.
3
3
  *
4
- * Registers `codex_generate_image`, a tool that uses package-owned
4
+ * Registers inline and artifact image tools that use package-owned
5
5
  * ChatGPT image OAuth or legacy Pi auth to call the Codex Responses backend with the
6
6
  * native `image_generation` tool. The backend selects the image model.
7
7
  */
@@ -12,15 +12,16 @@ import { mkdir, open, writeFile } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { isAbsolute, join, resolve } from "node:path";
14
14
  import { StringEnum } from "@earendil-works/pi-ai";
15
- import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext, getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
15
+ import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext, type ExtensionToolContext, type AgentToolUpdateCallback, type AgentToolResult, getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
16
16
  import { type Static, Type } from "typebox";
17
17
  import { reportInstallTelemetry } from "../src/install-telemetry.js";
18
18
  import { extractImageAccountId, imageAuthProvider, IMAGE_AUTH_PROVIDER } from "../src/image-oauth.js";
19
19
  import { abortable, httpFailure, MAX_IMAGE_BYTES, parseCodexSse, withRequestDeadline, type ParsedCodexResponse } from "../src/codex-response.js";
20
+ import { ARTIFACT_ENTRY, RESERVATION_ENTRY, artifactRecord, artifactReservation, readArtifactManifest, branchArtifacts, reserveArtifact, type ImageArtifact } from "../src/artifacts.js";
20
21
 
21
22
  const PACKAGE_NAME = "pi-codex-image-gen";
22
23
  const LEGACY_PROVIDER = "openai-codex";
23
- const DEFAULT_MODEL = "gpt-5.5";
24
+ const DEFAULT_MODEL = "gpt-6-astra";
24
25
  const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
25
26
  const DEFAULT_SAVE_MODE = "global";
26
27
  const OPENAI_BETA_HEADER = "responses=experimental";
@@ -117,6 +118,17 @@ const TOOL_PARAMS = Type.Object({
117
118
 
118
119
  type ToolParams = Static<typeof TOOL_PARAMS>;
119
120
 
121
+ const ARTIFACT_OUTPUT = Type.Object({
122
+ summary: Type.String(),
123
+ artifact: Type.Object({
124
+ path: Type.String(),
125
+ mimeType: StringEnum(["image/png", "image/jpeg", "image/webp"]),
126
+ byteCount: Type.Integer({ minimum: 1, maximum: MAX_IMAGE_BYTES }),
127
+ }),
128
+ savedPath: Type.Optional(Type.String()),
129
+ saveWarning: Type.Optional(Type.String()),
130
+ });
131
+
120
132
  // --- Config types ---
121
133
 
122
134
  interface ExtensionConfig {
@@ -149,6 +161,13 @@ async function resolveImageAuth(registry: ExtensionContext["modelRegistry"]): Pr
149
161
  try {
150
162
  if (typeof registry.getProviderAuth === "function") {
151
163
  const resolved = await registry.getProviderAuth(provider);
164
+ // Pi returns undefined for a stored credential whose type this
165
+ // OAuth-only provider cannot use. That is not an absent login and
166
+ // must not silently select a different account's legacy OAuth.
167
+ if (!resolved && provider === IMAGE_AUTH_PROVIDER
168
+ && registry.getProviderAuthStatus?.(provider).configured) {
169
+ throw new Error("Configured image credentials could not be resolved.");
170
+ }
152
171
  if (resolved && resolved.source !== "OAuth") {
153
172
  throw new Error("Image credentials must use OAuth.");
154
173
  }
@@ -271,15 +290,19 @@ async function saveImage(
271
290
  return filePath;
272
291
  }
273
292
 
274
- export function selectRecentImages(messages: unknown[], count: number): InputImage[] {
275
- const images: InputImage[] = [];
293
+ type RecentImage = InputImage | { path: string; mimeType: string };
294
+
295
+ export function selectRecentImages(messages: unknown[], count: number): RecentImage[] {
296
+ const images: RecentImage[] = [];
276
297
  for (let index = messages.length - 1; index >= 0 && images.length < count; index--) {
277
298
  const message = messages[index] as { content?: unknown };
278
299
  if (!Array.isArray(message?.content)) continue;
279
300
  for (let contentIndex = message.content.length - 1; contentIndex >= 0 && images.length < count; contentIndex--) {
280
- const block = message.content[contentIndex] as { type?: unknown; data?: unknown; mimeType?: unknown };
301
+ const block = message.content[contentIndex] as { type?: unknown; data?: unknown; mimeType?: unknown; path?: unknown };
281
302
  if (block?.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") {
282
303
  images.push({ data: block.data, mimeType: block.mimeType });
304
+ } else if (block?.type === "image_artifact" && typeof block.path === "string" && typeof block.mimeType === "string") {
305
+ images.push({ path: block.path, mimeType: block.mimeType });
283
306
  }
284
307
  }
285
308
  }
@@ -349,12 +372,16 @@ export async function resolveInputImages(
349
372
  if (!Number.isInteger(count) || count < 1 || count > MAX_EDIT_IMAGES) {
350
373
  throw new Error(`numLastImagesToInclude must be between 1 and ${MAX_EDIT_IMAGES}.`);
351
374
  }
352
- const images = selectRecentImages(messages, count);
353
- if (images.length !== count) {
354
- throw new Error(`Requested the last ${count} conversation images, but only ${images.length} were available.`);
375
+ const recent = selectRecentImages(messages, count);
376
+ if (recent.length !== count) {
377
+ throw new Error(`Requested the last ${count} conversation images, but only ${recent.length} were available.`);
355
378
  }
379
+ const images: InputImage[] = [];
356
380
  let total = 0;
357
- for (const image of images) {
381
+ for (const selected of recent) {
382
+ const image = "path" in selected
383
+ ? { data: (await readInputImage(selected.path)).toString("base64"), mimeType: selected.mimeType }
384
+ : selected;
358
385
  if (image.data.length > Math.ceil(MAX_INPUT_IMAGE_BYTES / 3) * 4) throw new Error("Conversation image exceeds 20 MiB.");
359
386
  const format = OUTPUT_FORMATS.find(format => mimeForFormat(format) === image.mimeType);
360
387
  if (!format) throw new Error("Conversation image has an unsupported format.");
@@ -362,6 +389,7 @@ export async function resolveInputImages(
362
389
  if (bytes.length > MAX_INPUT_IMAGE_BYTES) throw new Error("Conversation image exceeds 20 MiB.");
363
390
  total += bytes.length;
364
391
  if (total > MAX_TOTAL_INPUT_BYTES) throw new Error("Conversation images exceed 50 MiB in total.");
392
+ images.push(image);
365
393
  }
366
394
  return images;
367
395
  }
@@ -464,22 +492,44 @@ export default function codexImageGen(pi: ExtensionAPI) {
464
492
  reportInstallTelemetry();
465
493
  pi.registerProvider(imageAuthProvider);
466
494
 
467
- pi.registerTool({
468
- name: "codex_generate_image",
469
- label: "Codex Image",
470
- description:
471
- "Generate or edit an image with the OpenAI Codex ChatGPT backend built-in image_generation tool. The backend selects the image model. Accepts up to five local or recent conversation images (20 MiB each, 50 MiB total). Requires /login codex-images or existing legacy Pi OAuth credentials; works with openai OAuth chat and does not require the Codex app or an API key. Network deadline: 5 minutes; output image limit: 32 MiB; backend text is limited to 4,000 characters.",
472
- promptSnippet: "Generate or edit bitmap images via the OpenAI Codex ChatGPT backend image_generation tool.",
473
- promptGuidelines: [
474
- "Use codex_generate_image when the user asks to generate or edit a raster image with OpenAI/Codex image generation.",
475
- "Do not use codex_generate_image without a clear image-generation request, because it consumes the user's Codex image quota.",
476
- "The model parameter selects a Codex routing model, not an image model. Do not pass gpt-image-* IDs.",
477
- "Output metadata is backend-reported, not independently verified. Check pixels for dimensions and transparency; do not infer a served model from appearance or a successful request.",
478
- "Do not automatically repeat quota, connection, deadline, or incomplete-stream failures. The backend may already have consumed image quota.",
479
- ],
480
- parameters: TOOL_PARAMS,
481
- executionMode: "parallel", // #4: safe to run concurrently — no shared state, saves serialized per-path
482
- async execute(toolCallId, params: ToolParams, signal, onUpdate, ctx) {
495
+ const guidelines = [
496
+ "Generate or edit images only on a clear user request; each call consumes Codex image quota.",
497
+ "The model parameter selects a Codex routing model, not an image model. Do not pass gpt-image-* IDs.",
498
+ "Output metadata is backend-reported. Inspect pixels for dimensions and transparency; do not infer a served model.",
499
+ "Do not automatically repeat quota, connection, deadline, incomplete-stream, or artifact-storage failures. Quota may already have been consumed.",
500
+ ];
501
+
502
+ async function recordOriginal(
503
+ artifact: ImageArtifact, toolCallId: string, reservation: Awaited<ReturnType<typeof reserveArtifact>>,
504
+ ctx: ExtensionToolContext, sessionId: string, originEntryId: string | null,
505
+ ): Promise<string | undefined> {
506
+ const record = { artifact, toolCallId: toolCallId.slice(0, 256) };
507
+ let manifestPublished = false;
508
+ try {
509
+ await reservation.publish(record);
510
+ manifestPublished = true;
511
+ } catch { /* A completed original must survive a metadata-storage failure. */ }
512
+ // Do not attach late results to an unrelated branch or a replacement
513
+ // session. The pre-generation reservation and private manifest preserve
514
+ // recovery on the originating branch, including its forks and resumes.
515
+ try {
516
+ if (ctx.sessionManager.getSessionId() === sessionId && originEntryId
517
+ && ctx.sessionManager.getBranch().some(entry => entry.id === originEntryId)) {
518
+ pi.appendEntry(ARTIFACT_ENTRY, record);
519
+ return undefined;
520
+ }
521
+ } catch { /* The context may be inactive after reload; use the manifest. */ }
522
+ if (manifestPublished) return undefined;
523
+ return `Artifact recovery metadata could not be persisted. Recover the image from ${artifact.path}. No generation retry was made.`;
524
+ }
525
+
526
+ async function executeImage(
527
+ artifactMode: boolean, toolCallId: string, params: ToolParams, signal: AbortSignal | undefined,
528
+ onUpdate: AgentToolUpdateCallback<unknown> | undefined, ctx: ExtensionToolContext,
529
+ ): Promise<AgentToolResult<unknown>> {
530
+ let reservation: Awaited<ReturnType<typeof reserveArtifact>> | undefined;
531
+ let originEntryId: string | null = null;
532
+ try {
483
533
  if (typeof params.prompt !== "string" || !params.prompt.trim() || params.prompt.length > MAX_PROMPT_CHARS) {
484
534
  throw new Error("Image prompt must contain 1 to 32,000 characters.");
485
535
  }
@@ -496,15 +546,48 @@ export default function codexImageGen(pi: ExtensionAPI) {
496
546
  }
497
547
  const sessionId = ctx.sessionManager.getSessionId();
498
548
  const saveConfig = resolveSaveConfig(params, ctx.cwd, sessionId, config);
499
- const auth = await resolveImageAuth(ctx.modelRegistry);
500
- const provider = auth.provider;
501
- const model = ctx.modelRegistry.find(provider, requestedModel)?.id || requestedModel;
502
549
  const messages: unknown[] = [];
503
- for (const entry of ctx.sessionManager.getBranch()) {
550
+ const branch = ctx.sessionManager.getBranch();
551
+ const completedPaths = new Set(branch.flatMap(entry => {
552
+ const record = entry.type === "custom" && entry.customType === ARTIFACT_ENTRY ? artifactRecord(entry.data) : undefined;
553
+ return record ? [record.artifact.path] : [];
554
+ }));
555
+ for (const entry of params.numLastImagesToInclude !== undefined ? branch : []) {
504
556
  if (entry.type === "message") messages.push(entry.message);
505
557
  if (entry.type === "custom_message") messages.push(entry);
558
+ if (entry.type === "custom" && entry.customType === ARTIFACT_ENTRY) {
559
+ const record = artifactRecord(entry.data);
560
+ if (record) messages.push({ content: [{ type: "image_artifact", ...record.artifact }] });
561
+ }
562
+ if (entry.type === "custom" && entry.customType === RESERVATION_ENTRY) {
563
+ const pending = artifactReservation(entry.data);
564
+ const record = pending ? await readArtifactManifest(pending) : undefined;
565
+ // Normal completions also have a session entry. Prefer that
566
+ // later entry and avoid counting the same original twice.
567
+ if (record && !completedPaths.has(record.artifact.path)) {
568
+ messages.push({ content: [{ type: "image_artifact", ...record.artifact }] });
569
+ }
570
+ }
506
571
  }
507
572
  const inputImages = await resolveInputImages(params, ctx.cwd, messages);
573
+ signal?.throwIfAborted();
574
+ if (artifactMode) {
575
+ try {
576
+ reservation = await reserveArtifact(extensionForFormat(outputFormat));
577
+ } catch {
578
+ throw new Error("Image artifact storage is unavailable. No generation request was made.");
579
+ }
580
+ signal?.throwIfAborted();
581
+ // Attach the recovery anchor synchronously before quota/network
582
+ // work. A cancelled parent may settle while commit I/O is pending.
583
+ pi.appendEntry(RESERVATION_ENTRY, {
584
+ path: reservation.path, mimeType: mimeForFormat(outputFormat), toolCallId: toolCallId.slice(0, 256),
585
+ });
586
+ originEntryId = ctx.sessionManager.getLeafId();
587
+ }
588
+ const auth = await resolveImageAuth(ctx.modelRegistry);
589
+ const provider = auth.provider;
590
+ const model = ctx.modelRegistry.find(provider, requestedModel)?.id || requestedModel;
508
591
 
509
592
  onUpdate?.({
510
593
  content: [{ type: "text", text: `Requesting image ${inputImages.length > 0 ? "edit" : "generation"} through ${provider}/${model}...` }],
@@ -528,18 +611,37 @@ export default function codexImageGen(pi: ExtensionAPI) {
528
611
  let savedPath: string | undefined;
529
612
  let attemptedPath: string | undefined;
530
613
  let saveWarning: string | undefined;
614
+ let artifact: ImageArtifact | undefined;
615
+ let recoveryWarning: string | undefined;
616
+ if (reservation) {
617
+ try {
618
+ artifact = await reservation.commit(imageBytes, mimeForFormat(outputFormat));
619
+ } catch {
620
+ // Attempt the requested persistent copy below, never generation again.
621
+ }
622
+ // Persist before callbacks, persistent saves, or parent script output.
623
+ if (artifact) recoveryWarning = await recordOriginal(artifact, toolCallId, reservation, ctx, sessionId, originEntryId);
624
+ }
531
625
  if (saveConfig.mode !== "none" && saveConfig.outputDir) {
532
626
  attemptedPath = imagePath(outputFormat, saveConfig.outputDir, parsed.image.id || toolCallId);
533
627
  try {
534
628
  savedPath = await saveImage(imageBytes, outputFormat, saveConfig.outputDir, parsed.image.id || toolCallId);
535
- onUpdate?.({
536
- content: [{ type: "text", text: `Image saved to ${savedPath}.` }],
537
- details: { provider, model, savedPath, byteCount: imageBytes.length },
538
- });
539
629
  } catch (error) {
540
- saveWarning = `Image generation succeeded, but the image could not be saved to disk: ${error instanceof Error ? error.message : String(error)}`;
630
+ const reason = (error instanceof Error ? error.message : String(error))
631
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 500);
632
+ saveWarning = `Image generation succeeded, but the image could not be saved to disk: ${reason}`;
633
+ }
634
+ }
635
+ if (artifactMode && !artifact) {
636
+ if (savedPath) {
637
+ artifact = { path: savedPath, mimeType: mimeForFormat(outputFormat), byteCount: imageBytes.length };
638
+ recoveryWarning = await recordOriginal(artifact, toolCallId, reservation!, ctx, sessionId, originEntryId);
639
+ saveWarning = "Temporary artifact storage failed; recover the original from the persistent saved path. No generation retry was made.";
640
+ } else {
641
+ throw new Error("Image generation succeeded, but artifact storage failed and no usable file could be saved. Image quota may have been consumed. No automatic retry was made.");
541
642
  }
542
643
  }
644
+ saveWarning = [saveWarning, recoveryWarning].filter(Boolean).join(" ") || undefined;
543
645
 
544
646
  const summary = [
545
647
  `Generated image via ${provider}/${model} using the backend-selected image model.`,
@@ -548,17 +650,28 @@ export default function codexImageGen(pi: ExtensionAPI) {
548
650
  reportedImage.quality ? `Backend-reported quality: ${reportedImage.quality}.` : undefined,
549
651
  reportedImage.background ? `Backend-reported background: ${reportedImage.background}.` : undefined,
550
652
  parsed.image.revisedPrompt ? `Revised prompt: ${parsed.image.revisedPrompt}` : undefined,
551
- savedPath ? `Saved image to: ${savedPath}` : "Image was not saved to disk.",
653
+ artifact ? `Original image artifact: ${artifact.path}.` : undefined,
654
+ savedPath ? `Saved image to: ${savedPath}` : artifactMode ? "No persistent image copy was saved." : "Image was not saved to disk.",
552
655
  saveWarning ? `Warning: ${saveWarning}` : undefined,
553
656
  ]
554
657
  .filter(Boolean)
555
658
  .join(" ");
556
659
 
660
+ if (savedPath || artifact) onUpdate?.({
661
+ content: [{ type: "text", text: artifact ? `Original image artifact: ${artifact.path}.` : `Image saved to ${savedPath}.` }],
662
+ details: { provider, model, savedPath, artifact, byteCount: imageBytes.length },
663
+ });
664
+
557
665
  return {
558
666
  content: [
559
667
  { type: "text", text: summary },
560
- { type: "image", data: parsed.image.result, mimeType: mimeForFormat(outputFormat) },
668
+ ...(!artifactMode ? [{ type: "image" as const, data: parsed.image.result, mimeType: mimeForFormat(outputFormat) }] : []),
561
669
  ],
670
+ ...(artifact ? { structuredContent: {
671
+ summary, artifact: { ...artifact },
672
+ ...(savedPath ? { savedPath } : {}),
673
+ ...(saveWarning ? { saveWarning } : {}),
674
+ } } : {}),
562
675
  details: {
563
676
  provider,
564
677
  model,
@@ -570,6 +683,7 @@ export default function codexImageGen(pi: ExtensionAPI) {
570
683
  outputFormat,
571
684
  saveMode: saveConfig.mode,
572
685
  savedPath,
686
+ artifact,
573
687
  attemptedPath,
574
688
  saveWarning,
575
689
  inputImageCount: inputImages.length,
@@ -579,6 +693,61 @@ export default function codexImageGen(pi: ExtensionAPI) {
579
693
  usage: parsed.usage,
580
694
  },
581
695
  };
696
+ } finally {
697
+ // Cleanup only reservations made by this call, never completed originals.
698
+ await reservation?.dispose().catch(() => undefined);
699
+ }
700
+ }
701
+
702
+ const description = "Generate or edit an image with the OpenAI Codex ChatGPT backend built-in image_generation tool. The backend selects the image model. Accepts up to five local or recent conversation images (20 MiB each, 50 MiB total). Requires /login codex-images or existing legacy Pi OAuth credentials; works with openai OAuth chat and does not require the Codex app or an API key. Network deadline: 5 minutes; output image limit: 32 MiB; backend text is limited to 4,000 characters.";
703
+ const metadata = {
704
+ namespace: {
705
+ name: "codex_images",
706
+ description: "Quota-consuming image generation and private original artifacts.",
707
+ instructions: "Generate only on an explicit image request. codex_generate_image is model-only and returns an inline image. Scripts use codex_generate_image_artifact and receive metadata with artifact.path, never base64; read that path and use image(block) only for requested display. Completed originals survive script failures/reload; save=none still creates temporary originals. Calls consume quota and may write files, so do not automatically retry failures. Await each artifact before using it for an edit. Existing OAuth, approval and exposure rules still apply.",
708
+ },
709
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
710
+ };
711
+ pi.registerTool({
712
+ ...metadata,
713
+ name: "codex_generate_image",
714
+ label: "Codex Image",
715
+ exposure: "model-only",
716
+ description: `${description} Direct model calls only; returns an inline image. For codemode workflows use codex_generate_image_artifact.`,
717
+ promptSnippet: "Generate or edit bitmap images via the OpenAI Codex ChatGPT backend image_generation tool.",
718
+ promptGuidelines: guidelines,
719
+ parameters: TOOL_PARAMS,
720
+ executionMode: "parallel",
721
+ execute: (id, params, signal, update, ctx) => executeImage(false, id, params, signal, update, ctx),
722
+ });
723
+ pi.registerTool({
724
+ ...metadata,
725
+ name: "codex_generate_image_artifact",
726
+ label: "Codex Image Artifact",
727
+ exposure: "codemode",
728
+ description: `${description} Returns structured metadata and a private temporary original path, never image bytes. save=none disables persistent copies, not temporary storage. Completed artifacts survive script errors and reload until user/OS cleanup. Read artifact.path and call image(block) only for explicit display. Other nested callers and explicitly activated direct calls are supported. Recover current-branch paths with /image-artifacts.`,
729
+ promptGuidelines: [
730
+ ...guidelines,
731
+ "For display read result.artifact.path and call image(block) only if read returns an image block; otherwise report its text note. Do not print image bytes.",
732
+ "For chained edits pass result.artifact.path through referencedImagePaths; each edit consumes quota.",
733
+ ],
734
+ parameters: TOOL_PARAMS,
735
+ outputSchema: ARTIFACT_OUTPUT,
736
+ executionMode: "parallel",
737
+ execute: (id, params, signal, update, ctx) => executeImage(true, id, params, signal, update, ctx),
738
+ });
739
+ pi.registerCommand("image-artifacts", {
740
+ description: "List the last 20 generated original artifact paths on the current branch (no generation).",
741
+ handler: async (_args, ctx) => {
742
+ const records = (await branchArtifacts(ctx.sessionManager.getBranch())).slice(-20);
743
+ pi.sendMessage({
744
+ customType: "codex-image-artifact-list",
745
+ content: records.length ? records.map(record =>
746
+ `${record.artifact.path} (${record.artifact.mimeType}, ${record.artifact.byteCount} bytes)`,
747
+ ).join("\n") + "\nTemporary originals may be removed by user/OS cleanup. Copy needed assets to persistent storage."
748
+ : "No image artifacts recorded on this branch.",
749
+ display: true,
750
+ });
582
751
  },
583
752
  });
584
753
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-image-gen",
3
- "version": "0.1.14",
3
+ "version": "0.2.0",
4
4
  "description": "Image generation and editing for Pi using your ChatGPT Codex login.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -58,17 +58,17 @@
58
58
  "typebox": "*"
59
59
  },
60
60
  "devDependencies": {
61
- "@earendil-works/pi-ai": "^0.85.1",
62
- "@earendil-works/pi-coding-agent": "^0.85.1",
63
- "@types/node": "^26.2.0",
64
- "typebox": "^1.3.10",
61
+ "@earendil-works/pi-ai": "1.1.0",
62
+ "@earendil-works/pi-coding-agent": "1.1.0",
63
+ "@types/node": "^26.6.3",
64
+ "typebox": "^1.3.34",
65
65
  "typescript": "^7.0.2"
66
66
  },
67
67
  "publishConfig": {
68
68
  "access": "public"
69
69
  },
70
70
  "engines": {
71
- "node": ">=20.6.0"
71
+ "node": ">=22.19.0"
72
72
  },
73
73
  "dependencies": {
74
74
  "@mocito/install-telemetry": "0.1.1"
@@ -26,8 +26,15 @@ Within CLI fallback, the CLI exposes three subcommands:
26
26
 
27
27
  Rules:
28
28
  - Use the Pi `codex_generate_image` tool by default for new image generation requests.
29
+ - `codex_generate_image` is model-only; call it directly, never through codemode. For scripted workflows use `tools.codex_generate_image_artifact(...)`, which returns `{ summary, artifact: { path, mimeType, byteCount }, savedPath?, saveWarning? }` without image bytes. Both tools support the same generation/edit parameters and subscription login. Use Pi >=1.1.0.
30
+ - To display an artifact, call `tools.read({path: result.artifact.path})`, then `image(block)` if the returned value is an image block. Pi can resize or omit an un-decodable display image; the original remains unchanged. Never print or store base64 image bytes. Pass artifact paths directly through `referencedImagePaths` for chained edits.
31
+ - Artifact `save: "none"` still writes a private temporary original; it means no persistent user copy. Completed originals survive script failure/timeout, reload, shutdown, and branch changes until user/OS cleanup. Copy needed project assets to persistent storage. Run `/image-artifacts` to recover current-branch paths without generation. Recent-image selection includes branch-local original artifacts, even if never displayed; reference-size limits still apply.
32
+ - Do not retry artifact-storage failures automatically. Known storage failures reject before generation; failures after backend success can already have consumed quota. A successful requested persistent save can be used as recovery if the temporary write fails. If neither succeeds, the tool explicitly reports that no artifact is recoverable.
29
33
  - Image login is separate from chat: use `/login codex-images` and complete the ChatGPT OAuth flow in Pi. The Codex app is not required. Existing legacy Pi `openai-codex` OAuth can be a fallback; new `openai` plan-sharing chat OAuth cannot generate images. Never ask the user to paste login URLs or tokens into chat.
34
+ - Legacy OAuth is used only when owned image credentials are absent. Configured but unusable `codex-images` credentials block fallback; repair that login instead of switching accounts.
35
+ - `codex-images` has no native Pi image-model entry. Do not substitute `models.generateImages()` or an OpenRouter image model for these subscription tools: native image calls do not inherit their approval, save/recovery, output-size, or billing contracts.
30
36
  - The tool's `model` parameter selects a Codex routing model, not Flare or Sunburst. Do not claim a specific served image model unless the response reports it. Read `details.reportedImage` for backend-reported output settings, then inspect the actual image; prompt requests for quality, dimensions, or transparency are not guarantees.
37
+ - The default routing model is `gpt-6-astra`. Explicit tool parameters and configuration values override this default.
31
38
  - Do not automatically repeat quota, connection, timeout, or incomplete-stream failures. The remote generation may already have consumed quota.
32
39
  - Use `referencedImagePaths` for edits when every target has a local path. Use `numLastImagesToInclude` only when a target is available solely in recent conversation history. Never provide both selectors. Masks and advanced CLI-only controls still require confirmed CLI fallback.
33
40
  - Do not switch to CLI fallback for ordinary generation quality, size, or output file-path control.
@@ -41,7 +48,7 @@ Rules:
41
48
 
42
49
  Pi tool save-path policy:
43
50
  - In Pi tool mode, generated images are saved under Pi's agent directory by default: `<pi-agent-dir>/generated-images/<pi-session-id>/<image-call-id>.*`. The default Pi agent directory is `~/.pi/agent`, but it can be overridden with `PI_CODING_AGENT_DIR`; use Pi's configured agent directory, not a hardcoded home path.
44
- - Do not describe or rely on OS temp as the default Pi tool destination.
51
+ - Do not describe OS temp as the default persistent destination. The artifact tool additionally keeps a private temporary original regardless of persistent save mode; its path is not a durable project asset.
45
52
  - Use the tool's `save` and `saveDir` controls to choose a save directory. Custom mode appends a session directory; it does not accept an exact output filename. If an exact asset path is needed, copy the generated image there and leave the original in place.
46
53
  - Save-path precedence in Pi tool mode:
47
54
  1. If the user names a destination, copy the selected output there and leave the original in place.
@@ -0,0 +1,150 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdtemp, open, rm, type FileHandle } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+
6
+ export const ARTIFACT_ENTRY = "codex-image-artifact";
7
+ export const RESERVATION_ENTRY = "codex-image-artifact-reservation";
8
+
9
+ export interface ImageArtifact {
10
+ path: string;
11
+ mimeType: string;
12
+ byteCount: number;
13
+ }
14
+
15
+ export interface ArtifactRecord {
16
+ artifact: ImageArtifact;
17
+ toolCallId: string;
18
+ }
19
+
20
+ export interface ArtifactReservation {
21
+ path: string;
22
+ mimeType: string;
23
+ toolCallId: string;
24
+ }
25
+
26
+ export function artifactReservation(value: unknown): ArtifactReservation | undefined {
27
+ if (!value || typeof value !== "object") return undefined;
28
+ const record = value as ArtifactReservation;
29
+ // Reuse the path/MIME/call-ID validation without accepting a completed image.
30
+ const validated = artifactRecord({
31
+ artifact: { path: record.path, mimeType: record.mimeType, byteCount: 1 },
32
+ toolCallId: record.toolCallId,
33
+ });
34
+ return validated ? { path: validated.artifact.path, mimeType: validated.artifact.mimeType, toolCallId: validated.toolCallId } : undefined;
35
+ }
36
+
37
+ // Read metadata, never image bytes, from the current branch. Session files can
38
+ // be edited externally, so do not blindly spread their data into tool results.
39
+ export function artifactRecord(value: unknown): ArtifactRecord | undefined {
40
+ if (!value || typeof value !== "object") return undefined;
41
+ const record = value as Partial<ArtifactRecord>;
42
+ const artifact = record.artifact;
43
+ if (!artifact || typeof artifact.path !== "string" || artifact.path.length > 4096
44
+ || !isAbsolute(artifact.path) || /[\u0000-\u001f\u007f]/.test(artifact.path)
45
+ || !["image/png", "image/jpeg", "image/webp"].includes(artifact.mimeType)
46
+ || !Number.isInteger(artifact.byteCount) || artifact.byteCount <= 0 || artifact.byteCount > 32 * 1024 * 1024
47
+ || typeof record.toolCallId !== "string" || record.toolCallId.length > 256) return undefined;
48
+ return {
49
+ artifact: { path: artifact.path, mimeType: artifact.mimeType, byteCount: artifact.byteCount },
50
+ toolCallId: record.toolCallId,
51
+ };
52
+ }
53
+
54
+ /** Only a published, bounded manifest marks a reservation ready. The branch
55
+ * reservation is written before generation, so late completions need not
56
+ * mutate whichever branch/session the user is viewing after cancellation. */
57
+ export async function readArtifactManifest(reservation: ArtifactReservation): Promise<ArtifactRecord | undefined> {
58
+ let file: FileHandle | undefined;
59
+ try {
60
+ file = await open(`${reservation.path}.json`, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
61
+ const info = await file.stat();
62
+ const visible = await lstat(`${reservation.path}.json`);
63
+ if (!info.isFile() || !visible.isFile() || info.ino !== visible.ino || info.dev !== visible.dev || info.size > 8192) return undefined;
64
+ const buffer = Buffer.alloc(8193);
65
+ let total = 0;
66
+ while (total < buffer.length) {
67
+ const { bytesRead } = await file.read(buffer, total, buffer.length - total, null);
68
+ if (!bytesRead) break;
69
+ total += bytesRead;
70
+ }
71
+ if (total > 8192) return undefined;
72
+ const record = artifactRecord(JSON.parse(buffer.subarray(0, total).toString("utf8")));
73
+ return record?.toolCallId === reservation.toolCallId && record.artifact.mimeType === reservation.mimeType ? record : undefined;
74
+ } catch {
75
+ return undefined;
76
+ } finally {
77
+ await file?.close().catch(() => undefined);
78
+ }
79
+ }
80
+
81
+ export async function branchArtifacts(entries: readonly { type: string; customType?: string; data?: unknown }[]): Promise<ArtifactRecord[]> {
82
+ const records = new Map<string, ArtifactRecord>();
83
+ for (const entry of entries) {
84
+ const reservation = entry.type === "custom" && entry.customType === RESERVATION_ENTRY ? artifactReservation(entry.data) : undefined;
85
+ const record = reservation ? await readArtifactManifest(reservation)
86
+ : entry.type === "custom" && entry.customType === ARTIFACT_ENTRY ? artifactRecord(entry.data) : undefined;
87
+ if (record) {
88
+ records.delete(record.artifact.path);
89
+ records.set(record.artifact.path, record);
90
+ }
91
+ }
92
+ return [...records.values()];
93
+ }
94
+
95
+ /** Reserve private storage before spending quota. Keep completed files until
96
+ * user/OS cleanup; reload, shutdown, and branch changes must not remove them. */
97
+ export async function reserveArtifact(extension: string, root = tmpdir()) {
98
+ if (!["png", "jpg", "webp"].includes(extension)) throw new Error("Unsupported artifact format.");
99
+ const absoluteRoot = resolve(root);
100
+ if (absoluteRoot.length > 4000 || /[\u0000-\u001f\u007f]/.test(absoluteRoot)) {
101
+ throw new Error("Image artifact storage path is unsupported. No generation request was made.");
102
+ }
103
+ const dir = await mkdtemp(join(absoluteRoot, "pi-codex-image-"));
104
+ const path = join(dir, `original.${extension}`);
105
+ let file: FileHandle | undefined;
106
+ let committed = false;
107
+ try {
108
+ file = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | (constants.O_NOFOLLOW ?? 0), 0o600);
109
+ } catch {
110
+ await rm(dir, { recursive: true, force: true });
111
+ throw new Error("Image artifact storage is unavailable. No generation request was made.");
112
+ }
113
+ const handle = file;
114
+ return {
115
+ path,
116
+ async commit(bytes: Buffer, mimeType: string): Promise<ImageArtifact> {
117
+ await handle.writeFile(bytes);
118
+ await handle.sync();
119
+ const info = await handle.stat();
120
+ const visible = await lstat(path);
121
+ if (!visible.isFile() || info.ino !== visible.ino || info.dev !== visible.dev || visible.size !== bytes.length) {
122
+ throw new Error("Image artifact is no longer available at its reserved path.");
123
+ }
124
+ committed = true;
125
+ return { path, mimeType, byteCount: bytes.length };
126
+ },
127
+ async publish(record: ArtifactRecord) {
128
+ const validated = artifactRecord(record);
129
+ if (!validated) throw new Error("Invalid artifact recovery metadata.");
130
+ const json = JSON.stringify(validated);
131
+ if (Buffer.byteLength(json) > 8192) throw new Error("Artifact recovery metadata exceeds the size limit.");
132
+ const manifest = await open(`${path}.json`, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), 0o600);
133
+ try {
134
+ await manifest.writeFile(json);
135
+ await manifest.sync();
136
+ } finally {
137
+ await manifest.close();
138
+ }
139
+ if (record.artifact.path !== path) await rm(path, { force: true });
140
+ committed = true; // Retain fallback manifests too, never incomplete originals.
141
+ },
142
+ async dispose() {
143
+ try {
144
+ await handle.close();
145
+ } finally {
146
+ if (!committed) await rm(dir, { recursive: true, force: true });
147
+ }
148
+ },
149
+ };
150
+ }