@aibridge/cli 0.1.1 → 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/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  ---
17
17
 
18
- Your agent is one model, from one provider. Your machine probably has several more sitting behind CLIs you already use — `grok`, `agy` (Antigravity), `codex`, `claude`. **aibridge** turns them into seats your agent can drive: a planner that studies your repo, an implementer that edits it and runs your real tests, a reviewer from a *different* model family that cross-checks the diff against the plan, concurrent one-shot delegates — and capabilities your agent's own provider may not offer at all, like real image generation (gpt-image-2, Grok Imagine).
18
+ Your agent is one model, from one provider. Your machine probably has several more sitting behind CLIs you already use — `grok`, `agy` (Antigravity), `codex`, `claude`. **aibridge** turns them into seats your agent can drive: a planner that studies your repo, an implementer that edits it and runs your real tests, a reviewer from a *different* model family that cross-checks the diff against the plan, concurrent one-shot delegates — and capabilities your agent's own provider may not offer at all, like real image generation (Codex, Antigravity, or Grok seats).
19
19
 
20
20
  ## Install
21
21
 
@@ -41,7 +41,7 @@ npx -y @aibridge/cli subagent "summarize the architecture of this repo"
41
41
  | `aibridge implement <plan.md>` | You have an approved plan file and want it executed in place — with your project's **real typecheck and tests** run until green |
42
42
  | `aibridge review [--plan <plan.md>]` | You want a **different model** to pressure-test the working-tree diff against the plan contract (over-reach is a finding) — or to review the plan itself before implementing |
43
43
  | `aibridge subagent "<task>"` | A self-contained task deserves a concurrent delegate, a cross-model second opinion, or a red-team pass |
44
- | `aibridge image-gen "<prompt>"` | You need a real raster image — gpt-image-2 (default) or Grok Imagine, with render verification |
44
+ | `aibridge image-gen "<prompt>" --out out.png` | You need a real raster image — on a Codex (default), Antigravity, or Grok seat, with render verification |
45
45
  | `aibridge quota` | Two-second check of every backend's remaining quota before you pipeline work |
46
46
  | `aibridge runs` | Inspect or watch past delegation runs (`~/.aibridge/runs`) |
47
47
 
@@ -61,7 +61,7 @@ Plan files — not their contents — travel between stages, so the loop is near
61
61
  - **The skill carries judgment; the CLI owns execution.** The skill teaches your agent prompt-craft, seat selection, and when to gate; the CLI deterministically drives the backing CLIs, captures their output, verifies results (a "generated image" under 100 KB is a code-drawn fake, an empty answer is a quota death), and logs every run.
62
62
  - **Seats stay cross-model by default.** Grok plans and reviews, Gemini implements — a model never reviews its own diff, and independent eyes catch what shared blind spots miss.
63
63
  - **No API keys.** Delegation runs on the backing CLIs' existing logins, each spending its own quota. (The skill treats a backend that shares your agent's own quota pool as a last resort.)
64
- - **Models are canonical slugs**: `<vendor>-<cli>/<model>[-<effort>]` — e.g. `xai-grok/grok-4.5`, `google-antigravity/gemini-3.6-flash`, `openai-codex/gpt-5.6-sol-high`, `anthropic-claude/opus`. No aliases; `aibridge <command> --help` lists every seat.
64
+ - **Models are canonical slugs**: `<vendor>-<cli>/<model>[-<effort>]` — e.g. `xai-grok/grok-4.5`, `google-antigravity/gemini-3.6-flash`, `openai-codex/gpt-5.6-sol-high`, `anthropic-claude/opus-5`. No aliases — not short ones, and not moving vendor aliases like `opus`: every seat pins an exact model version. `aibridge <command> --help` lists every seat.
65
65
 
66
66
  ## Requirements
67
67
 
@@ -72,6 +72,10 @@ Plan files — not their contents — travel between stages, so the loop is near
72
72
 
73
73
  Everything is published under the [`@aibridge`](https://www.npmjs.com/org/aibridge) scope: [`@aibridge/cli`](https://www.npmjs.com/package/@aibridge/cli) (the command), `@aibridge/proc` (spawn/capture), and one driver per backing CLI — `@aibridge/driver-agy`, `@aibridge/driver-grok`, `@aibridge/driver-codex`, `@aibridge/driver-claude` — reusable if you want to drive a single CLI from your own code.
74
74
 
75
+ ## Security
76
+
77
+ aibridge executes real delegation — that's the product, and security scanners rightly notice: backing CLIs read/write files and run shell in tools mode, **at the same trust level as the agent you already run**. Nothing gains more access than you granted your agent and those CLIs when you installed them. Task content goes to the delegate's provider (use `--no-tools` for untrusted input — reasoning only, no file/shell access). All packages publish from this public repo via OIDC with [SLSA provenance](https://www.npmjs.com/package/@aibridge/cli), with no install-time scripts.
78
+
75
79
  ## Contributing & development
76
80
 
77
81
  Dev docs, architecture, and the working agreements for coding agents live in [`AGENTS.md`](AGENTS.md); design history in [`docs/`](docs/). Quick loop:
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as runCli, t as buildContext } from "./context-8JjDzZBG.mjs";
2
+ import { r as runCli, t as buildContext } from "./context-DQxvMUd2.mjs";
3
3
  //#region src/cli.ts
4
4
  await runCli(buildContext(process), process.argv.slice(2));
5
5
  //#endregion
@@ -3,7 +3,6 @@ import { ExitCode, buildApplication, buildCommand, buildRouteMap, run } from "@s
3
3
  import { appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { isAbsolute, join, resolve } from "node:path";
6
- import { isNotFound, runCaptured } from "@aibridge/proc";
7
6
  import * as agy from "@aibridge/driver-agy";
8
7
  import { fetchAgyQuota, findModelQuota } from "@aibridge/driver-agy";
9
8
  import * as claude from "@aibridge/driver-claude";
@@ -11,6 +10,7 @@ import { fetchClaudeQuota } from "@aibridge/driver-claude";
11
10
  import * as codex from "@aibridge/driver-codex";
12
11
  import { fetchCodexQuota } from "@aibridge/driver-codex";
13
12
  import * as grok from "@aibridge/driver-grok";
13
+ import { runCaptured } from "@aibridge/proc";
14
14
  import { randomBytes } from "node:crypto";
15
15
  //#region src/models.ts
16
16
  const MODELS = {
@@ -35,7 +35,7 @@ const MODELS = {
35
35
  "high"
36
36
  ],
37
37
  defaultEffort: "high",
38
- brief: "Google Gemini 3.6 Flash via agy — default for implement; own Antigravity login"
38
+ brief: "Google Gemini 3.6 Flash via agy — default for implement, also image-gen; own Antigravity login"
39
39
  },
40
40
  "google-antigravity/claude-sonnet-4-6": {
41
41
  slug: "google-antigravity/claude-sonnet-4-6",
@@ -70,10 +70,10 @@ const MODELS = {
70
70
  ],
71
71
  brief: "OpenAI Codex gpt-5.6-sol via codex CLI"
72
72
  },
73
- "anthropic-claude/sonnet": {
74
- slug: "anthropic-claude/sonnet",
73
+ "anthropic-claude/sonnet-5": {
74
+ slug: "anthropic-claude/sonnet-5",
75
75
  backend: "claude",
76
- backendModel: "sonnet",
76
+ backendModel: "claude-sonnet-5",
77
77
  efforts: [
78
78
  "low",
79
79
  "medium",
@@ -81,12 +81,12 @@ const MODELS = {
81
81
  "xhigh",
82
82
  "max"
83
83
  ],
84
- brief: "Claude Sonnet via claude CLI — bills your Claude subscription"
84
+ brief: "Claude Sonnet 5 via claude CLI — bills your Claude subscription"
85
85
  },
86
- "anthropic-claude/opus": {
87
- slug: "anthropic-claude/opus",
86
+ "anthropic-claude/opus-5": {
87
+ slug: "anthropic-claude/opus-5",
88
88
  backend: "claude",
89
- backendModel: "opus",
89
+ backendModel: "claude-opus-5",
90
90
  efforts: [
91
91
  "low",
92
92
  "medium",
@@ -95,15 +95,36 @@ const MODELS = {
95
95
  "max"
96
96
  ],
97
97
  defaultEffort: "high",
98
- brief: "Claude Opus via claude CLI (default effort: high) — bills subscription"
98
+ brief: "Claude Opus 5 via claude CLI (default effort: high) — bills subscription"
99
+ },
100
+ "anthropic-claude/opus-5-1m": {
101
+ slug: "anthropic-claude/opus-5-1m",
102
+ backend: "claude",
103
+ backendModel: "claude-opus-5[1m]",
104
+ efforts: [
105
+ "low",
106
+ "medium",
107
+ "high",
108
+ "xhigh",
109
+ "max"
110
+ ],
111
+ defaultEffort: "high",
112
+ brief: "Claude Opus 5, 1M context via claude CLI — long-context work; bills subscription"
99
113
  }
100
114
  };
101
115
  const DEFAULT_MODEL = "xai-grok/grok-4.5";
102
116
  const DEFAULT_IMPLEMENTER = "google-antigravity/gemini-3.6-flash";
103
117
  const DEFAULT_IMAGE_GEN = "openai-codex/gpt-5.6-sol";
104
- const IMAGE_GEN_BACKENDS = /* @__PURE__ */ new Set(["codex", "grok"]);
118
+ const IMAGE_GEN_FORMATS = /* @__PURE__ */ new Map([
119
+ ["agy", "jpg"],
120
+ ["codex", "png"],
121
+ ["grok", "jpg"]
122
+ ]);
105
123
  function supportsImageGen(resolved) {
106
- return IMAGE_GEN_BACKENDS.has(resolved.spec.backend);
124
+ return IMAGE_GEN_FORMATS.has(resolved.spec.backend);
125
+ }
126
+ function imageFormatFor(resolved) {
127
+ return IMAGE_GEN_FORMATS.get(resolved.spec.backend);
107
128
  }
108
129
  const EFFORTS_SET = /* @__PURE__ */ new Set([
109
130
  "low",
@@ -132,7 +153,6 @@ function resolveModel(input) {
132
153
  }
133
154
  }
134
155
  function backendModelId(resolved) {
135
- if (!resolved.spec.backendModel) return;
136
156
  if (resolved.spec.backend === "agy") {
137
157
  const effort = resolved.effort ?? resolved.spec.defaultEffort;
138
158
  if (effort) return `${resolved.spec.backendModel}-${effort}`;
@@ -142,7 +162,7 @@ function backendModelId(resolved) {
142
162
  function listModelHelpLines(opts = {}) {
143
163
  const lines = [];
144
164
  for (const [slug, spec] of Object.entries(MODELS)) {
145
- if (opts.imageOnly && !IMAGE_GEN_BACKENDS.has(spec.backend)) continue;
165
+ if (opts.imageOnly && !IMAGE_GEN_FORMATS.has(spec.backend)) continue;
146
166
  lines.push(` ${slug}`);
147
167
  lines.push(` ${spec.brief}`);
148
168
  }
@@ -186,7 +206,8 @@ const DRIVERS = {
186
206
  agy: {
187
207
  probe: () => agy.probe(),
188
208
  run: (task) => agy.run(task),
189
- quota: () => agy.fetchAgyQuota()
209
+ quota: () => agy.fetchAgyQuota(),
210
+ generateImage: (req) => agy.generateImage(req)
190
211
  },
191
212
  grok: {
192
213
  probe: () => grok.probe(),
@@ -213,7 +234,7 @@ function getDriver(backend) {
213
234
  //#endregion
214
235
  //#region src/commands/image-gen/impl.ts
215
236
  const MIN_REAL_BYTES_CODEX = 1e5;
216
- const MIN_REAL_BYTES_GROK = 1e4;
237
+ const MIN_REAL_BYTES_TOOL = 1e4;
217
238
  async function imageGen$1(flags, prompt) {
218
239
  const fail = (msg) => {
219
240
  this.process.stderr.write(`aibridge image-gen: ${msg}\n`);
@@ -223,28 +244,19 @@ async function imageGen$1(flags, prompt) {
223
244
  const model = resolveModel(inputSlug);
224
245
  if (!model) return fail(formatUnknownModelError(inputSlug));
225
246
  if (!supportsImageGen(model)) return fail(formatImageGenModelError(inputSlug, model));
226
- if (model.spec.backend === "codex" && model.effort) return fail(`effort "-${model.effort}" has no effect on image-gen (gpt-image-2 renders, not the seat model); use ${DEFAULT_IMAGE_GEN}.`);
227
- const quality = (flags.quality ?? "high").toLowerCase();
228
- if (![
229
- "low",
230
- "medium",
231
- "high"
232
- ].includes(quality)) return fail(`invalid --quality "${flags.quality}" (use low | medium | high)`);
233
- let size;
234
- if (flags.size !== void 0) {
235
- const m = flags.size.match(/^(\d+)\s*x\s*(\d+)$/i);
236
- if (!m) return fail(`invalid --size "${flags.size}" (expected e.g. 1024x1024)`);
237
- size = {
238
- w: Number(m[1]),
239
- h: Number(m[2])
240
- };
241
- if (model.spec.backend === "codex") {
242
- const constraint = sizeConstraintError(size.w, size.h);
243
- if (constraint) return fail(`invalid --size ${size.w}x${size.h}: ${constraint}`);
244
- } else if (size.w < 1 || size.h < 1) return fail(`invalid --size ${size.w}x${size.h}: dimensions must be positive`);
247
+ if (model.spec.backend === "codex" && model.effort) return fail(`effort "-${model.effort}" has no effect on image-gen (the image tool renders, not the seat model); use ${DEFAULT_IMAGE_GEN}.`);
248
+ const expected = imageFormatFor(model);
249
+ if (expected === void 0) return fail(formatImageGenModelError(inputSlug, model));
250
+ const label = expected === "png" ? "PNG" : "JPEG";
251
+ if (!(expected === "png" ? /\.png$/i.test(flags.out) : /\.jpe?g$/i.test(flags.out))) return fail(`--out "${flags.out}" must end in ${expected === "png" ? ".png" : ".jpg or .jpeg"} — the ${model.spec.slug} seat renders ${label} and aibridge does not convert.`);
252
+ let aspectRatio;
253
+ if (flags.aspectRatio !== void 0) {
254
+ const m = flags.aspectRatio.match(/^(\d+)\s*:\s*(\d+)$/);
255
+ if (!m || Number(m[1]) < 1 || Number(m[2]) < 1) return fail(`invalid --aspect-ratio "${flags.aspectRatio}" (expected e.g. 16:9)`);
256
+ aspectRatio = `${Number(m[1])}:${Number(m[2])}`;
245
257
  }
246
258
  const timeoutSec = flags.timeout ?? 600;
247
- const outPath = resolve(this.process.cwd(), flags.out ?? "./aibridge-image.png");
259
+ const outPath = resolve(this.process.cwd(), flags.out);
248
260
  const imagePaths = [];
249
261
  if (flags.image !== void 0) for (const raw of flags.image.split(",").map((s) => s.trim()).filter(Boolean)) {
250
262
  const abs = resolve(this.process.cwd(), raw);
@@ -253,16 +265,15 @@ async function imageGen$1(flags, prompt) {
253
265
  }
254
266
  const driver = getDriver(model.spec.backend);
255
267
  if (!driver.generateImage) return fail(formatImageGenModelError(inputSlug, model));
256
- const minBytes = model.spec.backend === "codex" ? MIN_REAL_BYTES_CODEX : MIN_REAL_BYTES_GROK;
268
+ const minBytes = model.spec.backend === "codex" ? MIN_REAL_BYTES_CODEX : MIN_REAL_BYTES_TOOL;
257
269
  const work = mkdtempSync(join(tmpdir(), "aibridge-imagegen-"));
258
270
  try {
259
271
  let outcome = await driver.generateImage({
260
272
  prompt,
261
273
  workDir: work,
262
- backendModel: model.spec.backendModel,
274
+ backendModel: backendModelId(model),
263
275
  effort: model.effort,
264
- quality,
265
- size,
276
+ aspectRatio,
266
277
  imagePaths,
267
278
  timeoutSec,
268
279
  forceful: false,
@@ -271,10 +282,9 @@ async function imageGen$1(flags, prompt) {
271
282
  if (model.spec.backend === "codex" && outcome.kind === "suspect") outcome = await driver.generateImage({
272
283
  prompt,
273
284
  workDir: work,
274
- backendModel: model.spec.backendModel,
285
+ backendModel: backendModelId(model),
275
286
  effort: model.effort,
276
- quality,
277
- size,
287
+ aspectRatio,
278
288
  imagePaths,
279
289
  timeoutSec,
280
290
  forceful: true,
@@ -282,34 +292,20 @@ async function imageGen$1(flags, prompt) {
282
292
  });
283
293
  if (outcome.kind === "ok" && outcome.bytes < minBytes) outcome = { kind: "suspect" };
284
294
  if (outcome.kind === "error") return fail(outcome.reason);
285
- if (outcome.kind === "suspect") return fail(model.spec.backend === "grok" ? "grok produced no usable image. Check SuperGrok image access and re-run with a simpler prompt." : "codex produced only a tiny/code-drawn image, not a real gpt-image-2 render. Try --quality high or a clearer, simpler prompt.");
295
+ if (outcome.kind === "suspect") return fail(model.spec.backend === "agy" ? "agy produced no usable image. Re-run with a simpler prompt, or check Antigravity image access." : model.spec.backend === "grok" ? "grok produced no usable image. Check SuperGrok image access and re-run with a simpler prompt." : "codex produced only a tiny/code-drawn image, not a real render. Try a clearer, simpler prompt.");
286
296
  const local = join(work, "result.bin");
287
297
  copyFileSync(outcome.path, local);
288
- let dims = imageSize(local);
289
- if (size && dims && (dims.width !== size.w || dims.height !== size.h)) if (await magick([
290
- local,
291
- "-resize",
292
- `${size.w}x${size.h}!`,
293
- local
294
- ])) dims = imageSize(local) ?? dims;
295
- else this.process.stderr.write(`aibridge image-gen: rendered ${dims.width}x${dims.height}, wanted ${size.w}x${size.h}, and ImageMagick (magick/convert) is unavailable to resize.
296
- `);
297
- const outExt = /\.png$/i.test(outPath) ? "png" : /\.jpe?g$/i.test(outPath) ? "jpg" : null;
298
- const actualFmt = pngSize(local) ? "png" : jpegSize(local) ? "jpg" : null;
299
- const needsConvert = outExt !== null && actualFmt !== null && outExt !== actualFmt;
300
- if (!needsConvert || !await magick([local, outPath])) {
301
- if (needsConvert) this.process.stderr.write(`aibridge image-gen: render is ${actualFmt.toUpperCase()} but out path wants ${outExt.toUpperCase()}, and ImageMagick (magick/convert) is unavailable to convert; writing the raw bytes as-is.
302
- `);
303
- copyFileSync(local, outPath);
304
- }
298
+ const dims = imageSize(local);
299
+ const actual = pngSize(local) ? "png" : jpegSize(local) ? "jpg" : null;
300
+ if (actual !== null && actual !== expected) this.process.stderr.write(`aibridge image-gen: expected a ${label} render from this seat but got ${actual === "png" ? "PNG" : "JPEG"}; wrote the raw bytes to ${outPath} anyway — the extension does not match the contents.\n`);
301
+ copyFileSync(local, outPath);
305
302
  const bytes = statSync(outPath).size;
306
303
  if (flags.json) this.process.stdout.write(`${JSON.stringify({
307
304
  out: outPath,
308
305
  bytes,
309
306
  width: dims?.width ?? null,
310
307
  height: dims?.height ?? null,
311
- sizeRequested: flags.size ?? null,
312
- quality: model.spec.backend === "codex" ? quality : null,
308
+ aspectRatio: flags.aspectRatio ?? null,
313
309
  model: model.spec.slug,
314
310
  backend: model.spec.backend,
315
311
  real: true
@@ -317,8 +313,7 @@ async function imageGen$1(flags, prompt) {
317
313
  else {
318
314
  const kb = Math.round(bytes / 1024);
319
315
  const dimStr = dims ? `${dims.width}x${dims.height}, ` : "";
320
- const qualityStr = model.spec.backend === "codex" ? `, ${quality} quality` : "";
321
- this.process.stdout.write(`✓ Wrote ${outPath} (${dimStr}${kb} KB${qualityStr}, ${model.spec.slug})\n`);
316
+ this.process.stdout.write(`✓ Wrote ${outPath} (${dimStr}${kb} KB, ${model.spec.slug})\n`);
322
317
  }
323
318
  } finally {
324
319
  rmSync(work, {
@@ -327,15 +322,6 @@ async function imageGen$1(flags, prompt) {
327
322
  });
328
323
  }
329
324
  }
330
- function sizeConstraintError(w, h) {
331
- if (w % 16 !== 0 || h % 16 !== 0) return "each edge must be divisible by 16";
332
- const long = Math.max(w, h);
333
- if (long / Math.min(w, h) > 3) return "aspect ratio must be within 1:3–3:1";
334
- if (long > 3840) return "longest edge must be <= 3840px";
335
- const px = w * h;
336
- if (px < 655360 || px > 8294400) return "total pixels must be 655,360–8,294,400";
337
- return null;
338
- }
339
325
  function imageSize(path) {
340
326
  return pngSize(path) ?? jpegSize(path);
341
327
  }
@@ -384,24 +370,15 @@ function jpegSize(path) {
384
370
  return null;
385
371
  }
386
372
  }
387
- async function magick(args) {
388
- for (const tool of ["magick", "convert"]) try {
389
- const r = await runCaptured(tool, [...args], { timeoutMs: 6e4 });
390
- if (!r.timedOut && r.code === 0) return true;
391
- } catch (err) {
392
- if (!isNotFound(err)) throw err;
393
- }
394
- return false;
395
- }
396
373
  //#endregion
397
374
  //#region src/commands/image-gen/command.ts
398
375
  const fullDescription$6 = [
399
- "Renders an image by driving the seat's CLI (codex gpt-image-2, grok ",
400
- "Imagine), then verifies the result is a real render before returning it.",
376
+ "Renders an image by driving the seat's own CLI, then verifies the result is",
377
+ "a real render before returning it.",
401
378
  "",
402
379
  "Image-gen seats (canonical slug):",
403
380
  ...listModelHelpLines({ imageOnly: true }),
404
- `Default: ${DEFAULT_IMAGE_GEN} (gpt-image-2 via codex; historical default).`
381
+ `Default: ${DEFAULT_IMAGE_GEN}.`
405
382
  ].join("\n");
406
383
  const imageGen = buildCommand({
407
384
  func: imageGen$1,
@@ -416,14 +393,13 @@ const imageGen = buildCommand({
416
393
  out: {
417
394
  kind: "parsed",
418
395
  parse: String,
419
- optional: true,
420
- brief: "Path to write the image (default: ./aibridge-image.png)"
396
+ brief: "Path to write the image — extension must match the seat format (.png for codex, .jpg for agy/grok)"
421
397
  },
422
- size: {
398
+ aspectRatio: {
423
399
  kind: "parsed",
424
400
  parse: String,
425
401
  optional: true,
426
- brief: "WIDTHxHEIGHT (codex: each edge ÷16; grok: mapped to aspect_ratio, then optionally resized)"
402
+ brief: "Aspect ratio N:M, e.g. 16:9 (agy/grok: real tool param; codex: prompt hint)"
427
403
  },
428
404
  image: {
429
405
  kind: "parsed",
@@ -431,12 +407,6 @@ const imageGen = buildCommand({
431
407
  optional: true,
432
408
  brief: "Reference image path(s), comma-separated — visual reference"
433
409
  },
434
- quality: {
435
- kind: "parsed",
436
- parse: String,
437
- optional: true,
438
- brief: "low | medium | high (codex/gpt-image-2; default high)"
439
- },
440
410
  timeout: {
441
411
  kind: "parsed",
442
412
  parse: positiveIntSeconds,
@@ -473,7 +443,7 @@ async function delegate(opts, driver = getDriver(opts.model.spec.backend)) {
473
443
  tools: opts.tools,
474
444
  timeoutSec: opts.timeoutSec,
475
445
  cwd: opts.cwd,
476
- backendModel: backendModelId(opts.model) ?? opts.model.spec.backendModel,
446
+ backendModel: backendModelId(opts.model),
477
447
  effort: opts.model.effort,
478
448
  onStdout: (c) => opts.run.stdout(c),
479
449
  onStderr: (c) => opts.run.stderr(c),
@@ -526,7 +496,7 @@ async function preflightModel(resolved) {
526
496
  if (resolved.spec.backend === "codex") return preflightCodex();
527
497
  if (resolved.spec.backend !== "agy") return { ok: true };
528
498
  try {
529
- return evaluateAgyPreflight(await fetchAgyQuota(), backendModelId(resolved) ?? "");
499
+ return evaluateAgyPreflight(await fetchAgyQuota(), backendModelId(resolved));
530
500
  } catch (err) {
531
501
  return {
532
502
  ok: true,
@@ -1414,15 +1384,13 @@ async function subagent$1(flags, prompt) {
1414
1384
  this.process.exitCode = 1;
1415
1385
  return;
1416
1386
  }
1417
- if (flags.json) {
1418
- const modelId = backendModelId(model) ?? null;
1419
- this.process.stdout.write(`${JSON.stringify({
1420
- model: modelId,
1421
- slug: model.spec.slug,
1422
- response: outcome.response,
1423
- exitCode: outcome.exitCode
1424
- })}\n`);
1425
- } else this.process.stdout.write(`${outcome.response}\n`);
1387
+ if (flags.json) this.process.stdout.write(`${JSON.stringify({
1388
+ model: backendModelId(model),
1389
+ slug: model.spec.slug,
1390
+ response: outcome.response,
1391
+ exitCode: outcome.exitCode
1392
+ })}\n`);
1393
+ else this.process.stdout.write(`${outcome.response}\n`);
1426
1394
  }
1427
1395
  //#endregion
1428
1396
  //#region src/commands/subagent/command.ts
@@ -1509,7 +1477,7 @@ const app = buildApplication(buildRouteMap({
1509
1477
  runs,
1510
1478
  quota
1511
1479
  },
1512
- docs: { brief: "Bridge tasks to non-Claude AI CLIs — a plan → implement → review workflow, task delegation, and image generation (codex gpt-image-2 / grok Imagine)." }
1480
+ docs: { brief: "Bridge tasks to non-Claude AI CLIs — a plan → implement → review workflow, task delegation, and image generation (codex / agy / grok seats)." }
1513
1481
  }), {
1514
1482
  name: "aibridge",
1515
1483
  versionInfo: { currentVersion: version },
package/dist/index.d.mts CHANGED
@@ -20,14 +20,16 @@ declare function runCli(ctx: LocalContext, argv: readonly string[]): Promise<voi
20
20
  *
21
21
  * Models are registered by canonical, provider-qualified slug —
22
22
  * `<vendor>-<cli>/<model>[-<effort>]`, e.g. `openai-codex/gpt-5.6-sol-high`.
23
- * Canonical slugs only — no short aliases, by design.
23
+ * Canonical slugs only — no short aliases, by design. That holds on both sides:
24
+ * `backendModel` is a pinned model id (`claude-opus-5`), never a moving vendor
25
+ * alias (`opus`), so a seat never silently changes model under you.
24
26
  */
25
27
  type Backend = 'agy' | 'claude' | 'codex' | 'grok';
26
28
  type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
27
29
  interface ModelSpec {
28
30
  readonly slug: string;
29
31
  readonly backend: Backend;
30
- readonly backendModel: string | undefined;
32
+ readonly backendModel: string;
31
33
  readonly efforts: readonly Effort[] | null;
32
34
  readonly defaultEffort?: Effort;
33
35
  readonly brief: string;
@@ -42,7 +44,7 @@ declare const DEFAULT_IMPLEMENTER = "google-antigravity/gemini-3.6-flash";
42
44
  declare const DEFAULT_IMAGE_GEN = "openai-codex/gpt-5.6-sol";
43
45
  declare function supportsImageGen(resolved: ResolvedModel): boolean;
44
46
  declare function resolveModel(input: string): ResolvedModel | undefined;
45
- declare function backendModelId(resolved: ResolvedModel): string | undefined;
47
+ declare function backendModelId(resolved: ResolvedModel): string;
46
48
  declare function listModelHelpLines(opts?: {
47
49
  readonly imageOnly?: boolean;
48
50
  }): string[];
@@ -62,7 +64,7 @@ interface DelegationTask {
62
64
  readonly tools: boolean;
63
65
  readonly timeoutSec: number;
64
66
  readonly cwd: string;
65
- readonly backendModel: string | undefined;
67
+ readonly backendModel: string;
66
68
  readonly effort: Effort | undefined;
67
69
  readonly onStdout?: (chunk: string) => void;
68
70
  readonly onStderr?: (chunk: string) => void;
@@ -82,13 +84,9 @@ type QuotaSnapshot = AgyQuotaSnapshot | CodexQuotaSnapshot | ClaudeQuotaSnapshot
82
84
  interface ImageGenRequest {
83
85
  readonly prompt: string;
84
86
  readonly workDir: string;
85
- readonly backendModel: string | undefined;
87
+ readonly backendModel: string;
86
88
  readonly effort: Effort | undefined;
87
- readonly quality: string;
88
- readonly size: {
89
- readonly w: number;
90
- readonly h: number;
91
- } | undefined;
89
+ readonly aspectRatio: string | undefined;
92
90
  readonly imagePaths: readonly string[];
93
91
  readonly timeoutSec: number;
94
92
  readonly forceful: boolean;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { C as listModelHelpLines, S as formatUnknownModelError, T as supportsImageGen, _ as DEFAULT_IMPLEMENTER, a as readRunLogs, b as backendModelId, c as evaluateCodexPreflight, d as renderPreflightRefusal, f as delegate, g as DEFAULT_IMAGE_GEN, h as positiveIntSeconds, i as listRuns, l as preflightCodex, m as nonEmptyPrompt, n as app, o as startRun, p as getDriver, r as runCli, s as evaluateAgyPreflight, t as buildContext, u as preflightModel, v as DEFAULT_MODEL, w as resolveModel, x as formatImageGenModelError, y as MODELS } from "./context-8JjDzZBG.mjs";
1
+ import { C as listModelHelpLines, S as formatUnknownModelError, T as supportsImageGen, _ as DEFAULT_IMPLEMENTER, a as readRunLogs, b as backendModelId, c as evaluateCodexPreflight, d as renderPreflightRefusal, f as delegate, g as DEFAULT_IMAGE_GEN, h as positiveIntSeconds, i as listRuns, l as preflightCodex, m as nonEmptyPrompt, n as app, o as startRun, p as getDriver, r as runCli, s as evaluateAgyPreflight, t as buildContext, u as preflightModel, v as DEFAULT_MODEL, w as resolveModel, x as formatImageGenModelError, y as MODELS } from "./context-DQxvMUd2.mjs";
2
2
  export { DEFAULT_IMAGE_GEN, DEFAULT_IMPLEMENTER, DEFAULT_MODEL, MODELS, app, backendModelId, buildContext, delegate, evaluateAgyPreflight, evaluateCodexPreflight, formatImageGenModelError, formatUnknownModelError, getDriver, listModelHelpLines, listRuns, nonEmptyPrompt, positiveIntSeconds, preflightCodex, preflightModel, readRunLogs, renderPreflightRefusal, resolveModel, runCli, startRun, supportsImageGen };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibridge/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "CLI that bridges tasks to AI CLIs on your machine (plan / implement / review / subagent / image-gen)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,11 +35,11 @@
35
35
  ],
36
36
  "dependencies": {
37
37
  "@stricli/core": "1.3.0",
38
- "@aibridge/proc": "0.1.1",
39
- "@aibridge/driver-agy": "0.1.1",
40
- "@aibridge/driver-grok": "0.1.1",
41
- "@aibridge/driver-codex": "0.1.1",
42
- "@aibridge/driver-claude": "0.1.1"
38
+ "@aibridge/proc": "0.2.0",
39
+ "@aibridge/driver-codex": "0.2.0",
40
+ "@aibridge/driver-grok": "0.2.0",
41
+ "@aibridge/driver-claude": "0.2.0",
42
+ "@aibridge/driver-agy": "0.2.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "tsdown": "0.22.14"
package/src/app.ts CHANGED
@@ -14,7 +14,7 @@ const require = createRequire(import.meta.url);
14
14
  const { version } = require('../package.json') as { version: string };
15
15
 
16
16
  const BRIEF =
17
- 'Bridge tasks to non-Claude AI CLIs — a plan → implement → review workflow, task delegation, and image generation (codex gpt-image-2 / grok Imagine).';
17
+ 'Bridge tasks to non-Claude AI CLIs — a plan → implement → review workflow, task delegation, and image generation (codex / agy / grok seats).';
18
18
 
19
19
  const routes = buildRouteMap({
20
20
  routes: {
@@ -4,12 +4,12 @@ import { nonEmptyPrompt, positiveIntSeconds } from '../../parsers.ts';
4
4
  import imageGenImpl from './impl.ts';
5
5
 
6
6
  const fullDescription = [
7
- "Renders an image by driving the seat's CLI (codex gpt-image-2, grok ",
8
- 'Imagine), then verifies the result is a real render before returning it.',
7
+ "Renders an image by driving the seat's own CLI, then verifies the result is",
8
+ 'a real render before returning it.',
9
9
  '',
10
10
  'Image-gen seats (canonical slug):',
11
11
  ...listModelHelpLines({ imageOnly: true }),
12
- `Default: ${DEFAULT_IMAGE_GEN} (gpt-image-2 via codex; historical default).`,
12
+ `Default: ${DEFAULT_IMAGE_GEN}.`,
13
13
  ].join('\n');
14
14
 
15
15
  export const imageGen = buildCommand({
@@ -25,15 +25,14 @@ export const imageGen = buildCommand({
25
25
  out: {
26
26
  kind: 'parsed',
27
27
  parse: String,
28
- optional: true,
29
- brief: 'Path to write the image (default: ./aibridge-image.png)',
28
+ brief:
29
+ 'Path to write the image — extension must match the seat format (.png for codex, .jpg for agy/grok)',
30
30
  },
31
- size: {
31
+ aspectRatio: {
32
32
  kind: 'parsed',
33
33
  parse: String,
34
34
  optional: true,
35
- brief:
36
- 'WIDTHxHEIGHT (codex: each edge ÷16; grok: mapped to aspect_ratio, then optionally resized)',
35
+ brief: 'Aspect ratio N:M, e.g. 16:9 (agy/grok: real tool param; codex: prompt hint)',
37
36
  },
38
37
  image: {
39
38
  kind: 'parsed',
@@ -41,12 +40,6 @@ export const imageGen = buildCommand({
41
40
  optional: true,
42
41
  brief: 'Reference image path(s), comma-separated — visual reference',
43
42
  },
44
- quality: {
45
- kind: 'parsed',
46
- parse: String,
47
- optional: true,
48
- brief: 'low | medium | high (codex/gpt-image-2; default high)',
49
- },
50
43
  timeout: {
51
44
  kind: 'parsed',
52
45
  parse: positiveIntSeconds,
@@ -10,30 +10,30 @@ import {
10
10
  } from 'node:fs';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { join, resolve } from 'node:path';
13
- import { isNotFound, runCaptured } from '@aibridge/proc';
14
13
  import type { LocalContext } from '../../context.ts';
15
14
  import type { ImageResult } from '../../driver.ts';
16
15
  import { getDriver } from '../../drivers.ts';
17
16
  import {
17
+ backendModelId,
18
18
  DEFAULT_IMAGE_GEN,
19
19
  formatImageGenModelError,
20
20
  formatUnknownModelError,
21
+ imageFormatFor,
21
22
  resolveModel,
22
23
  supportsImageGen,
23
24
  } from '../../models.ts';
24
25
 
25
26
  export interface ImageGenFlags {
26
27
  readonly model?: string;
27
- readonly out?: string;
28
- readonly size?: string;
28
+ readonly out: string;
29
+ readonly aspectRatio?: string;
29
30
  readonly image?: string;
30
- readonly quality?: string;
31
31
  readonly timeout?: number;
32
32
  readonly json: boolean;
33
33
  }
34
34
 
35
35
  const MIN_REAL_BYTES_CODEX = 100_000;
36
- const MIN_REAL_BYTES_GROK = 10_000;
36
+ const MIN_REAL_BYTES_TOOL = 10_000;
37
37
 
38
38
  export default async function imageGen(
39
39
  this: LocalContext,
@@ -52,30 +52,32 @@ export default async function imageGen(
52
52
 
53
53
  if (model.spec.backend === 'codex' && model.effort) {
54
54
  return fail(
55
- `effort "-${model.effort}" has no effect on image-gen (gpt-image-2 renders, not the seat model); use ${DEFAULT_IMAGE_GEN}.`,
55
+ `effort "-${model.effort}" has no effect on image-gen (the image tool renders, not the seat model); use ${DEFAULT_IMAGE_GEN}.`,
56
56
  );
57
57
  }
58
58
 
59
- const quality = (flags.quality ?? 'high').toLowerCase();
60
- if (!['low', 'medium', 'high'].includes(quality)) {
61
- return fail(`invalid --quality "${flags.quality}" (use low | medium | high)`);
59
+ const expected = imageFormatFor(model);
60
+ if (expected === undefined) return fail(formatImageGenModelError(inputSlug, model));
61
+
62
+ const label = expected === 'png' ? 'PNG' : 'JPEG';
63
+ const extValid = expected === 'png' ? /\.png$/i.test(flags.out) : /\.jpe?g$/i.test(flags.out);
64
+ if (!extValid) {
65
+ return fail(
66
+ `--out "${flags.out}" must end in ${expected === 'png' ? '.png' : '.jpg or .jpeg'} — the ${model.spec.slug} seat renders ${label} and aibridge does not convert.`,
67
+ );
62
68
  }
63
69
 
64
- let size: { w: number; h: number } | undefined;
65
- if (flags.size !== undefined) {
66
- const m = flags.size.match(/^(\d+)\s*x\s*(\d+)$/i);
67
- if (!m) return fail(`invalid --size "${flags.size}" (expected e.g. 1024x1024)`);
68
- size = { w: Number(m[1]), h: Number(m[2]) };
69
- if (model.spec.backend === 'codex') {
70
- const constraint = sizeConstraintError(size.w, size.h);
71
- if (constraint) return fail(`invalid --size ${size.w}x${size.h}: ${constraint}`);
72
- } else if (size.w < 1 || size.h < 1) {
73
- return fail(`invalid --size ${size.w}x${size.h}: dimensions must be positive`);
70
+ let aspectRatio: string | undefined;
71
+ if (flags.aspectRatio !== undefined) {
72
+ const m = flags.aspectRatio.match(/^(\d+)\s*:\s*(\d+)$/);
73
+ if (!m || Number(m[1]) < 1 || Number(m[2]) < 1) {
74
+ return fail(`invalid --aspect-ratio "${flags.aspectRatio}" (expected e.g. 16:9)`);
74
75
  }
76
+ aspectRatio = `${Number(m[1])}:${Number(m[2])}`;
75
77
  }
76
78
 
77
79
  const timeoutSec = flags.timeout ?? 600;
78
- const outPath = resolve(this.process.cwd(), flags.out ?? './aibridge-image.png');
80
+ const outPath = resolve(this.process.cwd(), flags.out);
79
81
 
80
82
  const imagePaths: string[] = [];
81
83
  if (flags.image !== undefined) {
@@ -94,17 +96,16 @@ export default async function imageGen(
94
96
  return fail(formatImageGenModelError(inputSlug, model));
95
97
  }
96
98
 
97
- const minBytes = model.spec.backend === 'codex' ? MIN_REAL_BYTES_CODEX : MIN_REAL_BYTES_GROK;
99
+ const minBytes = model.spec.backend === 'codex' ? MIN_REAL_BYTES_CODEX : MIN_REAL_BYTES_TOOL;
98
100
  const work = mkdtempSync(join(tmpdir(), 'aibridge-imagegen-'));
99
101
 
100
102
  try {
101
103
  let outcome: ImageResult = await driver.generateImage({
102
104
  prompt,
103
105
  workDir: work,
104
- backendModel: model.spec.backendModel,
106
+ backendModel: backendModelId(model),
105
107
  effort: model.effort,
106
- quality,
107
- size,
108
+ aspectRatio,
108
109
  imagePaths,
109
110
  timeoutSec,
110
111
  forceful: false,
@@ -115,10 +116,9 @@ export default async function imageGen(
115
116
  outcome = await driver.generateImage({
116
117
  prompt,
117
118
  workDir: work,
118
- backendModel: model.spec.backendModel,
119
+ backendModel: backendModelId(model),
119
120
  effort: model.effort,
120
- quality,
121
- size,
121
+ aspectRatio,
122
122
  imagePaths,
123
123
  timeoutSec,
124
124
  forceful: true,
@@ -133,42 +133,29 @@ export default async function imageGen(
133
133
  if (outcome.kind === 'error') return fail(outcome.reason);
134
134
  if (outcome.kind === 'suspect') {
135
135
  return fail(
136
- model.spec.backend === 'grok'
137
- ? 'grok produced no usable image. Check SuperGrok image access and re-run with a simpler prompt.'
138
- : 'codex produced only a tiny/code-drawn image, not a real gpt-image-2 render. ' +
139
- 'Try --quality high or a clearer, simpler prompt.',
136
+ model.spec.backend === 'agy'
137
+ ? 'agy produced no usable image. Re-run with a simpler prompt, or check Antigravity image access.'
138
+ : model.spec.backend === 'grok'
139
+ ? 'grok produced no usable image. Check SuperGrok image access and re-run with a simpler prompt.'
140
+ : 'codex produced only a tiny/code-drawn image, not a real render. ' +
141
+ 'Try a clearer, simpler prompt.',
140
142
  );
141
143
  }
142
144
 
143
145
  const local = join(work, 'result.bin');
144
146
  copyFileSync(outcome.path, local);
145
147
 
146
- let dims = imageSize(local);
147
- if (size && dims && (dims.width !== size.w || dims.height !== size.h)) {
148
- const resized = await magick([local, '-resize', `${size.w}x${size.h}!`, local]);
149
- if (resized) {
150
- dims = imageSize(local) ?? dims;
151
- } else {
152
- this.process.stderr.write(
153
- `aibridge image-gen: rendered ${dims.width}x${dims.height}, wanted ${size.w}x${size.h}, ` +
154
- 'and ImageMagick (magick/convert) is unavailable to resize.\n',
155
- );
156
- }
157
- }
148
+ const dims = imageSize(local);
149
+ const actual = pngSize(local) ? 'png' : jpegSize(local) ? 'jpg' : null;
158
150
 
159
- const outExt = /\.png$/i.test(outPath) ? 'png' : /\.jpe?g$/i.test(outPath) ? 'jpg' : null;
160
- const actualFmt = pngSize(local) ? 'png' : jpegSize(local) ? 'jpg' : null;
161
- const needsConvert = outExt !== null && actualFmt !== null && outExt !== actualFmt;
162
- if (!needsConvert || !(await magick([local, outPath]))) {
163
- if (needsConvert) {
164
- this.process.stderr.write(
165
- `aibridge image-gen: render is ${actualFmt.toUpperCase()} but out path wants ` +
166
- `${outExt.toUpperCase()}, and ImageMagick (magick/convert) is unavailable to convert; ` +
167
- 'writing the raw bytes as-is.\n',
168
- );
169
- }
170
- copyFileSync(local, outPath);
151
+ // ponytail: guard for a backend changing formats in the future without throwing away a paid render
152
+ if (actual !== null && actual !== expected) {
153
+ this.process.stderr.write(
154
+ `aibridge image-gen: expected a ${label} render from this seat but got ${actual === 'png' ? 'PNG' : 'JPEG'}; wrote the raw bytes to ${outPath} anyway — the extension does not match the contents.\n`,
155
+ );
171
156
  }
157
+
158
+ copyFileSync(local, outPath);
172
159
  const bytes = statSync(outPath).size;
173
160
 
174
161
  if (flags.json) {
@@ -178,8 +165,7 @@ export default async function imageGen(
178
165
  bytes,
179
166
  width: dims?.width ?? null,
180
167
  height: dims?.height ?? null,
181
- sizeRequested: flags.size ?? null,
182
- quality: model.spec.backend === 'codex' ? quality : null,
168
+ aspectRatio: flags.aspectRatio ?? null,
183
169
  model: model.spec.slug,
184
170
  backend: model.spec.backend,
185
171
  real: true,
@@ -188,27 +174,13 @@ export default async function imageGen(
188
174
  } else {
189
175
  const kb = Math.round(bytes / 1024);
190
176
  const dimStr = dims ? `${dims.width}x${dims.height}, ` : '';
191
- const qualityStr = model.spec.backend === 'codex' ? `, ${quality} quality` : '';
192
- this.process.stdout.write(
193
- `✓ Wrote ${outPath} (${dimStr}${kb} KB${qualityStr}, ${model.spec.slug})\n`,
194
- );
177
+ this.process.stdout.write(`✓ Wrote ${outPath} (${dimStr}${kb} KB, ${model.spec.slug})\n`);
195
178
  }
196
179
  } finally {
197
180
  rmSync(work, { recursive: true, force: true });
198
181
  }
199
182
  }
200
183
 
201
- function sizeConstraintError(w: number, h: number): string | null {
202
- if (w % 16 !== 0 || h % 16 !== 0) return 'each edge must be divisible by 16';
203
- const long = Math.max(w, h);
204
- const short = Math.min(w, h);
205
- if (long / short > 3) return 'aspect ratio must be within 1:3–3:1';
206
- if (long > 3840) return 'longest edge must be <= 3840px';
207
- const px = w * h;
208
- if (px < 655_360 || px > 8_294_400) return 'total pixels must be 655,360–8,294,400';
209
- return null;
210
- }
211
-
212
184
  function imageSize(path: string): { width: number; height: number } | null {
213
185
  return pngSize(path) ?? jpegSize(path);
214
186
  }
@@ -254,15 +226,3 @@ function jpegSize(path: string): { width: number; height: number } | null {
254
226
  return null;
255
227
  }
256
228
  }
257
-
258
- async function magick(args: readonly string[]): Promise<boolean> {
259
- for (const tool of ['magick', 'convert']) {
260
- try {
261
- const r = await runCaptured(tool, [...args], { timeoutMs: 60_000 });
262
- if (!r.timedOut && r.code === 0) return true;
263
- } catch (err) {
264
- if (!isNotFound(err)) throw err;
265
- }
266
- }
267
- return false;
268
- }
@@ -72,10 +72,9 @@ export default async function subagent(
72
72
  }
73
73
 
74
74
  if (flags.json) {
75
- const modelId = backendModelId(model) ?? null;
76
75
  this.process.stdout.write(
77
76
  `${JSON.stringify({
78
- model: modelId,
77
+ model: backendModelId(model),
79
78
  slug: model.spec.slug,
80
79
  response: outcome.response,
81
80
  exitCode: outcome.exitCode,
package/src/delegate.ts CHANGED
@@ -29,7 +29,7 @@ export async function delegate(
29
29
  tools: opts.tools,
30
30
  timeoutSec: opts.timeoutSec,
31
31
  cwd: opts.cwd,
32
- backendModel: backendModelId(opts.model) ?? opts.model.spec.backendModel,
32
+ backendModel: backendModelId(opts.model),
33
33
  effort: opts.model.effort,
34
34
  onStdout: c => opts.run.stdout(c),
35
35
  onStderr: c => opts.run.stderr(c),
package/src/driver.ts CHANGED
@@ -12,7 +12,7 @@ export interface DelegationTask {
12
12
  readonly tools: boolean;
13
13
  readonly timeoutSec: number;
14
14
  readonly cwd: string;
15
- readonly backendModel: string | undefined;
15
+ readonly backendModel: string;
16
16
  readonly effort: Effort | undefined;
17
17
  readonly onStdout?: (chunk: string) => void;
18
18
  readonly onStderr?: (chunk: string) => void;
@@ -33,10 +33,9 @@ export type QuotaSnapshot = AgyQuotaSnapshot | CodexQuotaSnapshot | ClaudeQuotaS
33
33
  export interface ImageGenRequest {
34
34
  readonly prompt: string;
35
35
  readonly workDir: string;
36
- readonly backendModel: string | undefined;
36
+ readonly backendModel: string;
37
37
  readonly effort: Effort | undefined;
38
- readonly quality: string;
39
- readonly size: { readonly w: number; readonly h: number } | undefined;
38
+ readonly aspectRatio: string | undefined;
40
39
  readonly imagePaths: readonly string[];
41
40
  readonly timeoutSec: number;
42
41
  readonly forceful: boolean;
package/src/drivers.ts CHANGED
@@ -9,6 +9,7 @@ const agyDriver: AgentCliDriver = {
9
9
  probe: () => agy.probe(),
10
10
  run: task => agy.run(task),
11
11
  quota: () => agy.fetchAgyQuota(),
12
+ generateImage: req => agy.generateImage(req),
12
13
  };
13
14
 
14
15
  const grokDriver: AgentCliDriver = {
@@ -22,7 +22,17 @@ describe('models registry', () => {
22
22
  });
23
23
 
24
24
  it('rejects short aliases — canonical slugs only', () => {
25
- for (const alias of ['grok', 'gemini', 'codex', 'sonnet', 'opus', 'gemini-3.6', 'gpt-oss']) {
25
+ for (const alias of [
26
+ 'grok',
27
+ 'gemini',
28
+ 'codex',
29
+ 'sonnet',
30
+ 'opus',
31
+ 'gemini-3.6',
32
+ 'gpt-oss',
33
+ 'anthropic-claude/sonnet',
34
+ 'anthropic-claude/opus',
35
+ ]) {
26
36
  expect(resolveModel(alias)).toBeUndefined();
27
37
  }
28
38
  });
@@ -32,8 +42,8 @@ describe('models registry', () => {
32
42
  expect(grokMedium?.spec.slug).toBe('xai-grok/grok-4.5');
33
43
  expect(grokMedium?.effort).toBe('medium');
34
44
 
35
- const sonnetMax = resolveModel('anthropic-claude/sonnet-max');
36
- expect(sonnetMax?.spec.slug).toBe('anthropic-claude/sonnet');
45
+ const sonnetMax = resolveModel('anthropic-claude/sonnet-5-max');
46
+ expect(sonnetMax?.spec.slug).toBe('anthropic-claude/sonnet-5');
37
47
  expect(sonnetMax?.effort).toBe('max');
38
48
  });
39
49
 
@@ -71,37 +81,41 @@ describe('models registry', () => {
71
81
  if (!grok) throw new Error('grok resolution failed');
72
82
  expect(backendModelId(grok)).toBe('grok-4.5');
73
83
 
74
- const sonnet = resolveModel('anthropic-claude/sonnet');
84
+ const sonnet = resolveModel('anthropic-claude/sonnet-5');
75
85
  if (!sonnet) throw new Error('sonnet resolution failed');
76
- expect(backendModelId(sonnet)).toBe('sonnet');
86
+ expect(backendModelId(sonnet)).toBe('claude-sonnet-5');
77
87
  });
78
88
 
79
- it('marks only codex and grok seats as image-gen capable', () => {
89
+ it('marks codex, grok, and gemini-3.6-flash seats as image-gen capable', () => {
80
90
  const codex = resolveModel('openai-codex/gpt-5.6-sol');
81
91
  const grok = resolveModel('xai-grok/grok-4.5');
82
92
  const gemini = resolveModel('google-antigravity/gemini-3.6-flash');
83
- if (!codex || !grok || !gemini) throw new Error('resolution failed');
93
+ const claudeSonnet = resolveModel('anthropic-claude/sonnet-5');
94
+ if (!codex || !grok || !gemini || !claudeSonnet) throw new Error('resolution failed');
84
95
  expect(supportsImageGen(codex)).toBe(true);
85
96
  expect(supportsImageGen(grok)).toBe(true);
86
- expect(supportsImageGen(gemini)).toBe(false);
97
+ expect(supportsImageGen(gemini)).toBe(true);
98
+ expect(supportsImageGen(claudeSonnet)).toBe(false);
87
99
  });
88
100
 
89
101
  it('lists only image-capable seats when imageOnly', () => {
90
102
  const lines = listModelHelpLines({ imageOnly: true }).join('\n');
91
103
  expect(lines).toContain('xai-grok/grok-4.5');
92
104
  expect(lines).toContain('openai-codex/gpt-5.6-sol');
93
- expect(lines).not.toContain('google-antigravity/gemini-3.6-flash');
105
+ expect(lines).toContain('google-antigravity/gemini-3.6-flash');
106
+ expect(lines).not.toContain('anthropic-claude/sonnet-5');
94
107
  });
95
108
 
96
109
  it('formats image-gen model errors with capable seats only', () => {
97
- const gemini = resolveModel('google-antigravity/gemini-3.6-flash');
98
- if (!gemini) throw new Error('gemini resolution failed');
99
- const err = formatImageGenModelError('google-antigravity/gemini-3.6-flash', gemini);
110
+ const claudeSonnet = resolveModel('anthropic-claude/sonnet-5');
111
+ if (!claudeSonnet) throw new Error('claudeSonnet resolution failed');
112
+ const err = formatImageGenModelError('anthropic-claude/sonnet-5', claudeSonnet);
100
113
  expect(err).toContain('cannot generate images');
101
- expect(err).toContain('backend "agy"');
114
+ expect(err).toContain('backend "claude"');
102
115
  expect(err).toContain('xai-grok/grok-4.5');
103
116
  expect(err).toContain('openai-codex/gpt-5.6-sol');
117
+ expect(err).toContain('google-antigravity/gemini-3.6-flash');
104
118
  const seatsSection = err.slice(err.indexOf('Image-gen seats'));
105
- expect(seatsSection).not.toContain('google-antigravity/gemini-3.6-flash');
119
+ expect(seatsSection).not.toContain('anthropic-claude/sonnet-5');
106
120
  });
107
121
  });
package/src/models.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Models are registered by canonical, provider-qualified slug —
5
5
  * `<vendor>-<cli>/<model>[-<effort>]`, e.g. `openai-codex/gpt-5.6-sol-high`.
6
- * Canonical slugs only — no short aliases, by design.
6
+ * Canonical slugs only — no short aliases, by design. That holds on both sides:
7
+ * `backendModel` is a pinned model id (`claude-opus-5`), never a moving vendor
8
+ * alias (`opus`), so a seat never silently changes model under you.
7
9
  */
8
10
 
9
11
  export type Backend = 'agy' | 'claude' | 'codex' | 'grok';
@@ -12,7 +14,7 @@ export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
12
14
  export interface ModelSpec {
13
15
  readonly slug: string; // canonical, effort-less
14
16
  readonly backend: Backend;
15
- readonly backendModel: string | undefined; // undefined = CLI default
17
+ readonly backendModel: string; // always explicit — never "whatever the CLI defaults to"
16
18
  readonly efforts: readonly Effort[] | null;
17
19
  readonly defaultEffort?: Effort; // only when backend REQUIRES one (agy gemini)
18
20
  readonly brief: string;
@@ -37,7 +39,8 @@ export const MODELS: Record<string, ModelSpec> = {
37
39
  backendModel: 'gemini-3.6-flash',
38
40
  efforts: ['low', 'medium', 'high'],
39
41
  defaultEffort: 'high',
40
- brief: 'Google Gemini 3.6 Flash via agy — default for implement; own Antigravity login',
42
+ brief:
43
+ 'Google Gemini 3.6 Flash via agy — default for implement, also image-gen; own Antigravity login',
41
44
  },
42
45
  'google-antigravity/claude-sonnet-4-6': {
43
46
  slug: 'google-antigravity/claude-sonnet-4-6',
@@ -67,20 +70,28 @@ export const MODELS: Record<string, ModelSpec> = {
67
70
  efforts: ['low', 'medium', 'high', 'xhigh'],
68
71
  brief: 'OpenAI Codex gpt-5.6-sol via codex CLI',
69
72
  },
70
- 'anthropic-claude/sonnet': {
71
- slug: 'anthropic-claude/sonnet',
73
+ 'anthropic-claude/sonnet-5': {
74
+ slug: 'anthropic-claude/sonnet-5',
72
75
  backend: 'claude',
73
- backendModel: 'sonnet',
76
+ backendModel: 'claude-sonnet-5',
74
77
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
75
- brief: 'Claude Sonnet via claude CLI — bills your Claude subscription',
78
+ brief: 'Claude Sonnet 5 via claude CLI — bills your Claude subscription',
76
79
  },
77
- 'anthropic-claude/opus': {
78
- slug: 'anthropic-claude/opus',
80
+ 'anthropic-claude/opus-5': {
81
+ slug: 'anthropic-claude/opus-5',
79
82
  backend: 'claude',
80
- backendModel: 'opus',
83
+ backendModel: 'claude-opus-5',
81
84
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
82
85
  defaultEffort: 'high',
83
- brief: 'Claude Opus via claude CLI (default effort: high) — bills subscription',
86
+ brief: 'Claude Opus 5 via claude CLI (default effort: high) — bills subscription',
87
+ },
88
+ 'anthropic-claude/opus-5-1m': {
89
+ slug: 'anthropic-claude/opus-5-1m',
90
+ backend: 'claude',
91
+ backendModel: 'claude-opus-5[1m]',
92
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
93
+ defaultEffort: 'high',
94
+ brief: 'Claude Opus 5, 1M context via claude CLI — long-context work; bills subscription',
84
95
  },
85
96
  } as const satisfies Record<string, ModelSpec>;
86
97
 
@@ -88,10 +99,20 @@ export const DEFAULT_MODEL = 'xai-grok/grok-4.5';
88
99
  export const DEFAULT_IMPLEMENTER = 'google-antigravity/gemini-3.6-flash';
89
100
  export const DEFAULT_IMAGE_GEN = 'openai-codex/gpt-5.6-sol';
90
101
 
91
- const IMAGE_GEN_BACKENDS: ReadonlySet<Backend> = new Set(['codex', 'grok']);
102
+ export type ImageFormat = 'jpg' | 'png';
103
+
104
+ const IMAGE_GEN_FORMATS: ReadonlyMap<Backend, ImageFormat> = new Map([
105
+ ['agy', 'jpg'],
106
+ ['codex', 'png'],
107
+ ['grok', 'jpg'],
108
+ ]);
92
109
 
93
110
  export function supportsImageGen(resolved: ResolvedModel): boolean {
94
- return IMAGE_GEN_BACKENDS.has(resolved.spec.backend);
111
+ return IMAGE_GEN_FORMATS.has(resolved.spec.backend);
112
+ }
113
+
114
+ export function imageFormatFor(resolved: ResolvedModel): ImageFormat | undefined {
115
+ return IMAGE_GEN_FORMATS.get(resolved.spec.backend);
95
116
  }
96
117
 
97
118
  const EFFORTS_SET: ReadonlySet<string> = new Set<Effort>(['low', 'medium', 'high', 'xhigh', 'max']);
@@ -122,10 +143,7 @@ export function resolveModel(input: string): ResolvedModel | undefined {
122
143
  return undefined;
123
144
  }
124
145
 
125
- export function backendModelId(resolved: ResolvedModel): string | undefined {
126
- if (!resolved.spec.backendModel) {
127
- return undefined;
128
- }
146
+ export function backendModelId(resolved: ResolvedModel): string {
129
147
  if (resolved.spec.backend === 'agy') {
130
148
  const effort = resolved.effort ?? resolved.spec.defaultEffort;
131
149
  if (effort) {
@@ -138,7 +156,7 @@ export function backendModelId(resolved: ResolvedModel): string | undefined {
138
156
  export function listModelHelpLines(opts: { readonly imageOnly?: boolean } = {}): string[] {
139
157
  const lines: string[] = [];
140
158
  for (const [slug, spec] of Object.entries(MODELS)) {
141
- if (opts.imageOnly && !IMAGE_GEN_BACKENDS.has(spec.backend)) continue;
159
+ if (opts.imageOnly && !IMAGE_GEN_FORMATS.has(spec.backend)) continue;
142
160
  lines.push(` ${slug}`);
143
161
  lines.push(` ${spec.brief}`);
144
162
  }
@@ -62,8 +62,7 @@ export async function preflightModel(resolved: ResolvedModel): Promise<Preflight
62
62
 
63
63
  try {
64
64
  const snapshot = await fetchAgyQuota();
65
- const modelId = backendModelId(resolved) ?? '';
66
- return evaluateAgyPreflight(snapshot, modelId);
65
+ return evaluateAgyPreflight(snapshot, backendModelId(resolved));
67
66
  } catch (err) {
68
67
  return {
69
68
  ok: true,