ima2-gen 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +10 -1
  3. package/bin/commands/backfillThumbs.js +6 -0
  4. package/bin/commands/gen.js +13 -5
  5. package/bin/commands/multimode.js +7 -5
  6. package/bin/commands/node.js +2 -1
  7. package/bin/ima2.js +14 -10
  8. package/config.js +8 -1
  9. package/docs/API.md +133 -9
  10. package/docs/CLI.md +6 -5
  11. package/docs/FAQ.ko.md +16 -0
  12. package/docs/FAQ.md +30 -0
  13. package/docs/README.ko.md +7 -3
  14. package/docs/migration/runtime-test-inventory.md +25 -1
  15. package/lib/agentCommandParser.js +10 -6
  16. package/lib/agentGenerationPlanner.js +96 -19
  17. package/lib/agentImageVideoGen.js +281 -0
  18. package/lib/agentPlannerModel.js +172 -0
  19. package/lib/agentQuestionResponder.js +9 -3
  20. package/lib/agentQueueStore.js +42 -0
  21. package/lib/agentQueueWorker.js +57 -7
  22. package/lib/agentRuntime.js +103 -274
  23. package/lib/agentSettings.js +9 -6
  24. package/lib/agentToolManifest.js +90 -0
  25. package/lib/agentTypes.js +1 -0
  26. package/lib/agyCli.js +31 -0
  27. package/lib/agyImageAdapter.js +104 -12
  28. package/lib/capabilities.js +7 -5
  29. package/lib/configKeys.js +6 -0
  30. package/lib/errorClassify.js +8 -7
  31. package/lib/eventBus.js +71 -0
  32. package/lib/geminiApiImageAdapter.js +24 -21
  33. package/lib/generationErrors.js +3 -1
  34. package/lib/generationRequestLog.js +25 -0
  35. package/lib/grokImageAdapter.js +76 -144
  36. package/lib/grokImageCore.js +153 -0
  37. package/lib/grokMultimodeAdapter.js +7 -4
  38. package/lib/grokVideoAdapter.js +3 -2
  39. package/lib/grokVideoCanvas.js +13 -0
  40. package/lib/grokVideoPlannerPrompt.js +52 -12
  41. package/lib/historyList.js +1 -0
  42. package/lib/inflight.js +54 -17
  43. package/lib/multimodeHelpers.js +11 -0
  44. package/lib/nodeHelpers.js +59 -0
  45. package/lib/oauthProxy/generators.js +6 -5
  46. package/lib/oauthProxy/prompts.js +60 -41
  47. package/lib/oauthProxy/streams.js +1 -1
  48. package/lib/promptBuilder/systemPrompt.js +2 -5
  49. package/lib/promptSafetyPolicy.js +1 -5
  50. package/lib/responsesDoctor.js +3 -3
  51. package/lib/responsesFallback.js +29 -15
  52. package/lib/responsesImageAdapter.js +11 -7
  53. package/lib/routeHelpers.js +44 -0
  54. package/lib/ssePublish.js +12 -0
  55. package/lib/storyboardPrefix.js +28 -0
  56. package/lib/thumbBackfill.js +16 -5
  57. package/node_modules/qs/CHANGELOG.md +10 -0
  58. package/node_modules/qs/README.md +1 -1
  59. package/node_modules/qs/dist/qs.js +15 -15
  60. package/node_modules/qs/eslint.config.mjs +1 -0
  61. package/node_modules/qs/lib/parse.js +52 -22
  62. package/node_modules/qs/lib/stringify.js +11 -4
  63. package/node_modules/qs/package.json +2 -2
  64. package/node_modules/qs/test/parse.js +49 -0
  65. package/node_modules/qs/test/stringify.js +129 -0
  66. package/package.json +4 -1
  67. package/routes/agent.js +19 -2
  68. package/routes/agy.js +48 -0
  69. package/routes/auth.js +6 -2
  70. package/routes/edit.js +7 -1
  71. package/routes/events.js +78 -0
  72. package/routes/generate.js +111 -128
  73. package/routes/generationRequestLog.js +16 -0
  74. package/routes/index.js +6 -0
  75. package/routes/keys.js +22 -2
  76. package/routes/multimode.js +100 -57
  77. package/routes/nodes.js +64 -108
  78. package/routes/video.js +100 -17
  79. package/skills/ima2/SKILL.md +98 -21
  80. package/ui/dist/.vite/manifest.json +22 -12
  81. package/ui/dist/assets/AgentWorkspace-qEMrATBP.js +3 -0
  82. package/ui/dist/assets/{CardNewsWorkspace-Dqyc1WZ1.js → CardNewsWorkspace-DoJnVHmn.js} +1 -1
  83. package/ui/dist/assets/GenerationRequestLogPanel-BDWRRLNV.js +1 -0
  84. package/ui/dist/assets/{NodeCanvas-ChEXzQbb.js → NodeCanvas-DIMo45sp.js} +3 -3
  85. package/ui/dist/assets/{PromptBuilderPanel-B95ZufnR.js → PromptBuilderPanel-BF3lcSJD.js} +2 -2
  86. package/ui/dist/assets/{PromptImportDialog-DGOwFQET.js → PromptImportDialog-Cy6ZymKG.js} +2 -2
  87. package/ui/dist/assets/{PromptImportDiscoverySection-CgvdnR49.js → PromptImportDiscoverySection-CzuW-8P2.js} +1 -1
  88. package/ui/dist/assets/PromptImportFolderSection-DP5ywnsv.js +1 -0
  89. package/ui/dist/assets/{PromptLibraryPanel-B9kndPw1.js → PromptLibraryPanel-CAsKr7CV.js} +2 -2
  90. package/ui/dist/assets/SettingsWorkspace-_PdPVsxi.js +1 -0
  91. package/ui/dist/assets/index-BUinlX2n.js +4 -0
  92. package/ui/dist/assets/index-CX3fge8X.css +1 -0
  93. package/ui/dist/assets/index-ygo6nfqx.js +23 -0
  94. package/ui/dist/index.html +2 -2
  95. package/ui/dist/assets/AgentWorkspace-CYv84Rus.js +0 -3
  96. package/ui/dist/assets/PromptImportFolderSection-CfUye9J8.js +0 -1
  97. package/ui/dist/assets/SettingsWorkspace-B3tgLrmF.js +0 -1
  98. package/ui/dist/assets/index-BhcvL0g-.js +0 -1
  99. package/ui/dist/assets/index-BtK3YhJc.js +0 -39
  100. package/ui/dist/assets/index-ClOLOjnA.css +0 -1
@@ -210,6 +210,9 @@ test('parse()', function (t) {
210
210
  t.test('uses original key when depth = 0', function (st) {
211
211
  st.deepEqual(qs.parse('a[0]=b&a[1]=c', { depth: 0 }), { 'a[0]': 'b', 'a[1]': 'c' });
212
212
  st.deepEqual(qs.parse('a[0][0]=b&a[0][1]=c&a[1]=d&e=2', { depth: 0 }), { 'a[0][0]': 'b', 'a[0][1]': 'c', 'a[1]': 'd', e: '2' });
213
+ st.deepEqual(qs.parse('a.b=c', { depth: 0, allowDots: true }), { 'a[b]': 'c' }, 'normalizes dots before applying depth-0 behavior');
214
+ st.deepEqual(qs.parse('toString=foo', { depth: 0 }), {}, 'respects prototype guard at depth 0');
215
+ st.deepEqual(qs.parse('toString=foo', { depth: 0, allowPrototypes: true }), { toString: 'foo' }, 'allows prototypes at depth 0 when enabled');
213
216
  st.end();
214
217
  });
215
218
 
@@ -263,6 +266,52 @@ test('parse()', function (t) {
263
266
  st.end();
264
267
  });
265
268
 
269
+ t.test('parses keys with literal [] inside a bracket group (#493)', function (st) {
270
+ // A bracket pair inside a bracket group should be treated literally as part of the key
271
+ st.deepEqual(
272
+ qs.parse('search[withbracket[]]=foobar'),
273
+ { search: { 'withbracket[]': 'foobar' } },
274
+ 'treats inner [] literally when inside a bracket group'
275
+ );
276
+
277
+ // Single-level variant
278
+ st.deepEqual(
279
+ qs.parse('a[b[]]=c'),
280
+ { a: { 'b[]': 'c' } },
281
+ 'keeps "b[]" as a literal key'
282
+ );
283
+
284
+ // Nested with an array push on the outer level
285
+ st.deepEqual(
286
+ qs.parse('list[][x[]]=y'),
287
+ { list: [{ 'x[]': 'y' }] },
288
+ 'preserves inner [] while still treating outer [] as array push'
289
+ );
290
+
291
+ // Multiple nested bracket pairs: inner [] remains literal as part of the key
292
+ st.deepEqual(
293
+ qs.parse('a[b[c[]]]=d'),
294
+ { a: { 'b[c[]]': 'd' } },
295
+ 'treats "b[c[]]" as a literal key inside the bracket group'
296
+ );
297
+
298
+ // Depth limits with literal brackets: preserve inner [] while limiting bracket-group parsing
299
+ st.deepEqual(
300
+ qs.parse('a[b[c[]]][d]=e', { depth: 1 }),
301
+ { a: { 'b[c[]]': { '[d]': 'e' } } },
302
+ 'respects depth: 1 and preserves literal inner [] in the parsed key'
303
+ );
304
+
305
+ // Unterminated inner bracket group is wrapped as a literal remainder segment
306
+ st.deepEqual(
307
+ qs.parse('a[[]b=c'),
308
+ { a: { '[[]b': 'c' } },
309
+ 'handles unterminated inner bracket groups without throwing'
310
+ );
311
+
312
+ st.end();
313
+ });
314
+
266
315
  t.test('allows to specify array indices', function (st) {
267
316
  st.deepEqual(qs.parse('a[1]=c&a[0]=b&a[2]=d'), { a: ['b', 'c', 'd'] });
268
317
  st.deepEqual(qs.parse('a[1]=c&a[0]=b'), { a: ['b', 'c'] });
@@ -651,6 +651,49 @@ test('stringify()', function (t) {
651
651
  st.end();
652
652
  });
653
653
 
654
+ t.test('does not crash on null/undefined entries in arrayFormat=comma with encodeValuesOnly', function (st) {
655
+ st.doesNotThrow(
656
+ function () { qs.stringify({ a: [null, 'b'] }, { arrayFormat: 'comma', encodeValuesOnly: true }); },
657
+ 'does not pass a raw null array entry to the encoder'
658
+ );
659
+ st.doesNotThrow(
660
+ function () { qs.stringify({ a: [undefined, 'b'] }, { arrayFormat: 'comma', encodeValuesOnly: true }); },
661
+ 'does not pass a raw undefined array entry to the encoder'
662
+ );
663
+ st.doesNotThrow(
664
+ function () { qs.stringify({ a: [null] }, { arrayFormat: 'comma', encodeValuesOnly: true }); },
665
+ 'does not crash on a single-null array'
666
+ );
667
+
668
+ st.equal(
669
+ qs.stringify({ a: [null, 'b'] }, { arrayFormat: 'comma', encodeValuesOnly: true }),
670
+ 'a=,b',
671
+ 'null entry joins as empty, comma stays unencoded under encodeValuesOnly'
672
+ );
673
+ st.equal(
674
+ qs.stringify({ a: [undefined, 'b'] }, { arrayFormat: 'comma', encodeValuesOnly: true }),
675
+ 'a=,b',
676
+ 'undefined entry joins as empty, comma stays unencoded under encodeValuesOnly'
677
+ );
678
+ st.equal(
679
+ qs.stringify({ a: [null] }, { arrayFormat: 'comma', encodeValuesOnly: true }),
680
+ 'a=',
681
+ 'single-null array stringifies as empty value'
682
+ );
683
+ st.equal(
684
+ qs.stringify({ a: [null] }, { arrayFormat: 'comma', encodeValuesOnly: true, strictNullHandling: true }),
685
+ 'a',
686
+ 'strictNullHandling drops the equals sign for a single-null array'
687
+ );
688
+ st.equal(
689
+ qs.stringify({ a: [null] }, { arrayFormat: 'comma', encodeValuesOnly: true, skipNulls: true }),
690
+ '',
691
+ 'skipNulls drops a single-null array entirely'
692
+ );
693
+
694
+ st.end();
695
+ });
696
+
654
697
  t.test('stringifies a null object', { skip: !hasProto }, function (st) {
655
698
  st.equal(qs.stringify({ __proto__: null, a: 'b' }), 'a=b');
656
699
  st.end();
@@ -825,6 +868,35 @@ test('stringify()', function (t) {
825
868
  st.end();
826
869
  });
827
870
 
871
+ t.test('skips null/undefined entries in filter=array', function (st) {
872
+ st.doesNotThrow(
873
+ function () { qs.stringify({ a: 'b', undefined: 'x' }, { filter: ['a', undefined] }); },
874
+ 'does not pass a raw undefined filter entry to the encoder'
875
+ );
876
+ st.doesNotThrow(
877
+ function () { qs.stringify({ a: 'b', 'null': 'x' }, { filter: ['a', null] }); },
878
+ 'does not pass a raw null filter entry to the encoder'
879
+ );
880
+
881
+ st.equal(
882
+ qs.stringify({ a: 'b', undefined: 'x', c: 'd' }, { filter: ['a', undefined, 'c'] }),
883
+ 'a=b&c=d',
884
+ 'undefined filter entry is skipped, remaining keys are kept'
885
+ );
886
+ st.equal(
887
+ qs.stringify({ a: 'b', 'null': 'x', c: 'd' }, { filter: ['a', null, 'c'] }),
888
+ 'a=b&c=d',
889
+ 'null filter entry is skipped, remaining keys are kept'
890
+ );
891
+ st.equal(
892
+ qs.stringify({ a: 'b', 'null': 'x' }, { filter: [null] }),
893
+ '',
894
+ 'filter array containing only null yields empty string'
895
+ );
896
+
897
+ st.end();
898
+ });
899
+
828
900
  t.test('supports custom representations when filter=function', function (st) {
829
901
  var calls = 0;
830
902
  var obj = { a: 'b', c: 'd', e: { f: new Date(1257894000000) } };
@@ -1111,6 +1183,28 @@ test('stringify()', function (t) {
1111
1183
  st.end();
1112
1184
  });
1113
1185
 
1186
+ t.test('strictNullHandling: applies the formatter to the encoded key (RFC1738)', function (st) {
1187
+ st.equal(
1188
+ qs.stringify(
1189
+ { 'a b': null, 'c d': 'e f' },
1190
+ { strictNullHandling: false, format: 'RFC1738' }
1191
+ ),
1192
+ 'a+b=&c+d=e+f',
1193
+ 'without: as expected'
1194
+ );
1195
+
1196
+ st.equal(
1197
+ qs.stringify(
1198
+ { 'a b': null, 'c d': 'e f' },
1199
+ { strictNullHandling: true, format: 'RFC1738' }
1200
+ ),
1201
+ 'a+b&c+d=e+f',
1202
+ 'with: as expected'
1203
+ );
1204
+
1205
+ st.end();
1206
+ });
1207
+
1114
1208
  t.test('throws if an invalid charset is specified', function (st) {
1115
1209
  st['throws'](function () {
1116
1210
  qs.stringify({ a: 'b' }, { charset: 'foobar' });
@@ -1146,6 +1240,12 @@ test('stringify()', function (t) {
1146
1240
  'adds the right sentinel when instructed to and the charset is iso-8859-1'
1147
1241
  );
1148
1242
 
1243
+ st.equal(
1244
+ qs.stringify({ a: 1, b: 2 }, { charsetSentinel: true, delimiter: ';' }),
1245
+ 'utf8=%E2%9C%93;a=1;b=2',
1246
+ 'uses the configured delimiter after the sentinel'
1247
+ );
1248
+
1149
1249
  st.end();
1150
1250
  });
1151
1251
 
@@ -1316,4 +1416,33 @@ test('stringifies empty keys', function (t) {
1316
1416
 
1317
1417
  st.end();
1318
1418
  });
1419
+
1420
+ t.test('round-trips keys containing percent-encoded bracket text', function (st) {
1421
+ var cases = [
1422
+ { 'a%5Bb': 'c' },
1423
+ { 'a%5Db': 'c' },
1424
+ { 'a%255Bb': 'c' },
1425
+ { 'a%255Db': 'c' },
1426
+ { a: { 'b%5Bc': 'd' } },
1427
+ { a: { 'b%255Bc': 'd' } },
1428
+ { 'a%5B%255Bb': 'c' }
1429
+ ];
1430
+ for (var i = 0; i < cases.length; i++) {
1431
+ st.deepEqual(
1432
+ qs.parse(qs.stringify(cases[i])),
1433
+ cases[i],
1434
+ 'round-trips ' + JSON.stringify(cases[i])
1435
+ );
1436
+ }
1437
+
1438
+ st.end();
1439
+ });
1440
+
1441
+ t.test('parses input containing percent-encoded bracket text without mangling', function (st) {
1442
+ st.deepEqual(qs.parse('a%25255Bb=c'), { 'a%255Bb': 'c' }, 'a%25255Bb decodes to a%255Bb, not a%5Bb');
1443
+ st.deepEqual(qs.parse('a%25255Db=c'), { 'a%255Db': 'c' }, 'a%25255Db decodes to a%255Db, not a%5Db');
1444
+ st.deepEqual(qs.parse('a%5Bb%25255Bc%5D=d'), { a: { 'b%255Bc': 'd' } }, 'nested %25255B decodes to %255B inside segment, not %5B');
1445
+
1446
+ st.end();
1447
+ });
1319
1448
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Local OAuth image generation studio with classic and node workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,7 @@
39
39
  "cli"
40
40
  ],
41
41
  "license": "MIT",
42
+ "homepage": "https://lidge-jun.github.io/ima2-gen/",
42
43
  "repository": {
43
44
  "type": "git",
44
45
  "url": "git+https://github.com/lidge-jun/ima2-gen.git"
@@ -57,6 +58,7 @@
57
58
  "assets/card-news/templates/",
58
59
  ".env.example",
59
60
  "README.md",
61
+ "CHANGELOG.md",
60
62
  "LICENSE",
61
63
  "server.js",
62
64
  "config.js"
@@ -65,6 +67,7 @@
65
67
  "node": ">=20"
66
68
  },
67
69
  "dependencies": {
70
+ "@openai/codex": "latest",
68
71
  "better-sqlite3": "^12.9.0",
69
72
  "dotenv": "^17.4.2",
70
73
  "express": "^5.1.0",
package/routes/agent.js CHANGED
@@ -1,5 +1,5 @@
1
- import { appendAgentTurn, compactAgentSession, createAgentSession, deleteAgentSession, getAgentGenerationSettings, getAgentSession, getAgentWorkspacePayload, renameAgentSession, setAgentCurrentImage, setAgentGenerationSettings, setAgentLocks, setAgentWebSearch, } from "../lib/agentStore.js";
2
- import { cancelAgentQueueItem, createAgentQueueItem, getAgentQueueItem, listAgentQueueItems, retryAgentQueueItem, } from "../lib/agentQueueStore.js";
1
+ import { appendAgentTurn, compactAgentSession, createAgentSession, deleteAgentSession, getAgentGenerationSettings, getAgentSession, getAgentWorkspacePayload, importAgentImage, renameAgentSession, setAgentCurrentImage, setAgentGenerationSettings, setAgentLocks, setAgentWebSearch, } from "../lib/agentStore.js";
2
+ import { cancelAgentQueueItem, createAgentQueueItem, getAgentGenerationErrors, getAgentQueueItem, listAgentQueueItems, retryAgentQueueItem, } from "../lib/agentQueueStore.js";
3
3
  import { ensureAgentQueueWorker, tickAgentQueueWorker } from "../lib/agentQueueWorker.js";
4
4
  import { parseAgentSlashCommand, formatAgentQuestionReply, formatAgentSlashHelp } from "../lib/agentCommandParser.js";
5
5
  import { requestAgentQuestionAnswer } from "../lib/agentQuestionResponder.js";
@@ -46,6 +46,11 @@ export function registerAgentRoutes(app, ctxRaw) {
46
46
  if (Object.prototype.hasOwnProperty.call(body, "generationSettings")) {
47
47
  setAgentGenerationSettings(req.params.sessionId, body.generationSettings);
48
48
  }
49
+ if (Object.prototype.hasOwnProperty.call(body, "currentImage")) {
50
+ const image = normalizeCurrentImage(body.currentImage);
51
+ if (image)
52
+ importAgentImage(req.params.sessionId, image);
53
+ }
49
54
  if (Object.prototype.hasOwnProperty.call(body, "currentImageId")) {
50
55
  const ok = setAgentCurrentImage(req.params.sessionId, body.currentImageId);
51
56
  if (!ok)
@@ -105,6 +110,18 @@ export function registerAgentRoutes(app, ctxRaw) {
105
110
  app.get("/api/agent/queue", (_req, res) => {
106
111
  res.json({ queue: listAgentQueueItems() });
107
112
  });
113
+ app.get("/api/agent/sessions/:sessionId/errors", (req, res) => {
114
+ try {
115
+ if (!getAgentSession(req.params.sessionId))
116
+ throw notFound(req.params.sessionId);
117
+ const limitRaw = Number(req.query.limit);
118
+ const limit = Number.isFinite(limitRaw) ? limitRaw : 10;
119
+ res.json({ errors: getAgentGenerationErrors(req.params.sessionId, limit) });
120
+ }
121
+ catch (error) {
122
+ sendError(res, error);
123
+ }
124
+ });
108
125
  app.get("/api/agent/sessions/:sessionId/queue", (req, res) => {
109
126
  if (!getAgentSession(req.params.sessionId))
110
127
  return sendError(res, notFound(req.params.sessionId));
package/routes/agy.js ADDED
@@ -0,0 +1,48 @@
1
+ import { spawn } from "node:child_process";
2
+ import { buildAgyPathEnv, resolveAgyBin } from "../lib/agyCli.js";
3
+ // Detect whether the Antigravity CLI (`agy`) is installed, using the same
4
+ // spawn-and-catch style as lib/agyImageAdapter.ts (no shell `which`/`where`).
5
+ // Login state cannot be probed — agy has no status command — so we only
6
+ // report installation here.
7
+ function isAgyInstalled() {
8
+ return new Promise((resolve) => {
9
+ let settled = false;
10
+ const done = (value) => {
11
+ if (settled)
12
+ return;
13
+ settled = true;
14
+ resolve(value);
15
+ };
16
+ try {
17
+ const child = spawn(resolveAgyBin(), ["--version"], {
18
+ stdio: "ignore",
19
+ env: { ...process.env, PATH: buildAgyPathEnv() },
20
+ });
21
+ child.on("error", () => done(false)); // ENOENT when not on PATH
22
+ child.on("exit", (code) => done(code === 0));
23
+ // Safety timeout so a hung binary never blocks the request.
24
+ setTimeout(() => {
25
+ try {
26
+ if (!child.killed)
27
+ child.kill();
28
+ }
29
+ catch { /* ignore */ }
30
+ done(false);
31
+ }, 3000).unref?.();
32
+ }
33
+ catch {
34
+ done(false);
35
+ }
36
+ });
37
+ }
38
+ export function registerAgyRoutes(app) {
39
+ app.get("/api/agy/status", async (_req, res) => {
40
+ try {
41
+ const installed = await isAgyInstalled();
42
+ res.json({ installed });
43
+ }
44
+ catch {
45
+ res.json({ installed: false });
46
+ }
47
+ });
48
+ }
package/routes/auth.js CHANGED
@@ -2,11 +2,15 @@ import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
6
7
  const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
7
8
  const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
8
9
  const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token";
9
10
  const CODEX_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
11
+ // Bundled @openai/codex binary (npm dependency), resolved relative to this
12
+ // module so device-auth login works even when `codex` is not on the user's PATH.
13
+ const CODEX_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "node_modules", ".bin", process.platform === "win32" ? "codex.cmd" : "codex");
10
14
  const MAX_CONCURRENT_SESSIONS = 20;
11
15
  const sessions = new Map();
12
16
  function sid() {
@@ -125,7 +129,7 @@ function startCodexDeviceCode() {
125
129
  for (const k of ["OPENAI_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "ANTHROPIC_API_KEY", "VERTEX_SERVICE_ACCOUNT_JSON"]) {
126
130
  delete childEnv[k];
127
131
  }
128
- const child = spawn("codex", ["login", "--device-auth"], {
132
+ const child = spawn(CODEX_BIN, ["login", "--device-auth"], {
129
133
  stdio: ["ignore", "pipe", "pipe"],
130
134
  env: childEnv,
131
135
  });
package/routes/edit.js CHANGED
@@ -169,6 +169,7 @@ export function registerEditRoutes(app, ctxRaw) {
169
169
  let revisedPrompt;
170
170
  let webSearchCalls = 0;
171
171
  let resultMimeFromProvider;
172
+ let providerUrl = null;
172
173
  if (activeProvider === "gemini-api") {
173
174
  const r = await generateViaGeminiApi(`Edit this image: ${prompt}`, requireRuntimeContext(ctx), {
174
175
  model: imageModel,
@@ -206,6 +207,7 @@ export function registerEditRoutes(app, ctxRaw) {
206
207
  directApiKey,
207
208
  });
208
209
  resultB64 = r.b64;
210
+ providerUrl = r.providerUrl ?? null;
209
211
  usage = r.usage;
210
212
  revisedPrompt = r.revisedPrompt;
211
213
  webSearchCalls = r.webSearchCalls;
@@ -237,6 +239,7 @@ export function registerEditRoutes(app, ctxRaw) {
237
239
  const editFilePath = join(ctx.config.storage.generatedDir, filename);
238
240
  await writeFile(editFilePath, editBuffer);
239
241
  generateImageThumbnailFromBuffer(editBuffer, editFilePath).catch(() => { });
242
+ const createdAt = Date.now();
240
243
  const meta = {
241
244
  prompt,
242
245
  userPrompt: prompt,
@@ -252,10 +255,11 @@ export function registerEditRoutes(app, ctxRaw) {
252
255
  provider: activeProvider,
253
256
  kind: "edit",
254
257
  requestId,
255
- createdAt: Date.now(),
258
+ createdAt,
256
259
  usage: usage || null,
257
260
  webSearchCalls,
258
261
  webSearchEnabled,
262
+ ...(providerUrl ? { providerUrl } : {}),
259
263
  };
260
264
  await safeWriteSidecar(join(ctx.config.storage.generatedDir, filename + ".json"), meta);
261
265
  invalidateHistoryIndex();
@@ -281,6 +285,8 @@ export function registerEditRoutes(app, ctxRaw) {
281
285
  promptMode: normalizedPromptMode,
282
286
  webSearchCalls,
283
287
  webSearchEnabled,
288
+ providerUrl,
289
+ createdAt,
284
290
  });
285
291
  }
286
292
  catch (e) {
@@ -0,0 +1,78 @@
1
+ import { subscribe, replaySince, hasReplayGap, replayOldestId, MAX_SSE_LISTENERS } from "../lib/eventBus.js";
2
+ let activeConnections = 0;
3
+ function safeWrite(res, chunk) {
4
+ if (res.writableEnded || res.destroyed)
5
+ return false;
6
+ try {
7
+ res.write(chunk);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ function formatSse(ev) {
15
+ return `id: ${ev.id}\nevent: ${ev.event}\ndata: ${JSON.stringify({ ...ev.data, jobId: ev.jobId })}\n\n`;
16
+ }
17
+ export function registerEventsRoute(app, _ctx) {
18
+ app.get("/api/events", (req, res) => {
19
+ if (activeConnections >= MAX_SSE_LISTENERS) {
20
+ return res.status(503).json({
21
+ error: { code: "SSE_CAPACITY", message: "Too many event stream connections" },
22
+ });
23
+ }
24
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
25
+ res.setHeader("Cache-Control", "no-cache, no-transform");
26
+ res.setHeader("Connection", "keep-alive");
27
+ res.setHeader("X-Accel-Buffering", "no");
28
+ res.flushHeaders?.();
29
+ activeConnections++;
30
+ const headerLastId = parseInt(req.headers["last-event-id"], 10);
31
+ const queryLastId = parseInt(String(req.query.lastEventId ?? ""), 10);
32
+ const lastId = !Number.isNaN(headerLastId) ? headerLastId : queryLastId;
33
+ if (!Number.isNaN(lastId)) {
34
+ if (hasReplayGap(lastId)) {
35
+ const gapPayload = JSON.stringify({
36
+ lastEventId: lastId,
37
+ oldestAvailableId: replayOldestId(),
38
+ });
39
+ if (!safeWrite(res, `event: replay-gap\ndata: ${gapPayload}\n\n`)) {
40
+ activeConnections = Math.max(0, activeConnections - 1);
41
+ return;
42
+ }
43
+ }
44
+ for (const ev of replaySince(lastId)) {
45
+ if (!safeWrite(res, formatSse(ev)))
46
+ break;
47
+ }
48
+ }
49
+ let cleaned = false;
50
+ const unsub = subscribe((ev) => {
51
+ if (!safeWrite(res, formatSse(ev)))
52
+ cleanup();
53
+ });
54
+ const heartbeat = setInterval(() => {
55
+ if (!safeWrite(res, ": ping\n\n"))
56
+ cleanup();
57
+ }, 15_000);
58
+ function cleanup() {
59
+ if (cleaned)
60
+ return;
61
+ cleaned = true;
62
+ unsub();
63
+ clearInterval(heartbeat);
64
+ activeConnections = Math.max(0, activeConnections - 1);
65
+ if (!res.writableEnded && !res.destroyed) {
66
+ try {
67
+ res.end();
68
+ }
69
+ catch {
70
+ /* socket already torn down */
71
+ }
72
+ }
73
+ }
74
+ req.on("close", cleanup);
75
+ res.on("close", cleanup);
76
+ res.on("error", cleanup);
77
+ });
78
+ }