pi-codex-image-gen 0.1.15 → 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 +24 -0
- package/CONTRIBUTING.md +18 -1
- package/README.md +145 -6
- package/SECURITY.md +49 -1
- package/extensions/index.ts +205 -36
- package/package.json +6 -6
- package/skills/imagegen/SKILL.md +7 -1
- package/src/artifacts.ts +150 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ 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
|
+
|
|
9
33
|
## [0.1.15] - 2026-10-02
|
|
10
34
|
|
|
11
35
|
### Changed
|
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
|
|
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
|
|
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
|
|
|
@@ -125,13 +261,15 @@ Project config overrides global config only when project trust is active. If pro
|
|
|
125
261
|
|
|
126
262
|
| Mode | Behavior |
|
|
127
263
|
| --------- | ---------------------------------------------------------------- |
|
|
128
|
-
| `none` |
|
|
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. |
|
|
@@ -149,8 +287,9 @@ Project config overrides global config only when project trust is active. If pro
|
|
|
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.
|
|
153
|
-
7.
|
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project-local Codex image generation extension.
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
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,11 +12,12 @@ 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";
|
|
@@ -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
|
-
|
|
275
|
-
|
|
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
|
|
353
|
-
if (
|
|
354
|
-
throw new Error(`Requested the last ${count} conversation images, but only ${
|
|
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
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
62
|
-
"@earendil-works/pi-coding-agent": "
|
|
63
|
-
"@types/node": "^26.
|
|
64
|
-
"typebox": "^1.3.
|
|
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": ">=
|
|
71
|
+
"node": ">=22.19.0"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@mocito/install-telemetry": "0.1.1"
|
package/skills/imagegen/SKILL.md
CHANGED
|
@@ -26,7 +26,13 @@ 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.
|
|
31
37
|
- The default routing model is `gpt-6-astra`. Explicit tool parameters and configuration values override this default.
|
|
32
38
|
- Do not automatically repeat quota, connection, timeout, or incomplete-stream failures. The remote generation may already have consumed quota.
|
|
@@ -42,7 +48,7 @@ Rules:
|
|
|
42
48
|
|
|
43
49
|
Pi tool save-path policy:
|
|
44
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.
|
|
45
|
-
- Do not describe
|
|
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.
|
|
46
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.
|
|
47
53
|
- Save-path precedence in Pi tool mode:
|
|
48
54
|
1. If the user names a destination, copy the selected output there and leave the original in place.
|
package/src/artifacts.ts
ADDED
|
@@ -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
|
+
}
|