specrails-desktop 2.5.0 → 2.7.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.
Files changed (76) hide show
  1. package/client/dist/assets/{ActivityFeedPage-BTYWMRwB.js → ActivityFeedPage-LKqd18-G.js} +1 -1
  2. package/client/dist/assets/{AgentsPage-BfOCeHHt.js → AgentsPage-Cb-b-6Ot.js} +1 -1
  3. package/client/dist/assets/AnalyticsPage-HVxQQ1wy.js +1 -0
  4. package/client/dist/assets/{BarChart-DlshJN3Z.js → BarChart-BOyHB0dw.js} +1 -1
  5. package/client/dist/assets/{CodePage-DJCjDG4I.js → CodePage-DnOnwKGB.js} +1 -1
  6. package/client/dist/assets/{DesktopAnalyticsPage-CTqZ9mbB.js → DesktopAnalyticsPage-D2auU39x.js} +1 -1
  7. package/client/dist/assets/{DocsDialog-KiJOSRvX.js → DocsDialog-CTuDX3GK.js} +1 -1
  8. package/client/dist/assets/{DocsPage-B17CR54A.js → DocsPage-DRyMmu0Z.js} +1 -1
  9. package/client/dist/assets/{ExportDropdown-BAu6z3b6.js → ExportDropdown-DO-GGiMh.js} +1 -1
  10. package/client/dist/assets/{IntegrationsPage-CCG64Q-6.js → IntegrationsPage-BhbO4jFT.js} +1 -1
  11. package/client/dist/assets/{JobDetailPage-BnGJSMiS.js → JobDetailPage-DJooEg1s.js} +1 -1
  12. package/client/dist/assets/{JobsPage-B-tn4CIf.js → JobsPage-BbaC-YOg.js} +1 -1
  13. package/client/dist/assets/{addspec-DeDOztDr.js → addspec-B-BKlvDj.js} +1 -1
  14. package/client/dist/assets/{addspec-v8j6A7CD.js → addspec-BErjOdNK.js} +1 -1
  15. package/client/dist/assets/{addspec-B1FTtI2a.js → addspec-CIGb34PS.js} +1 -1
  16. package/client/dist/assets/{addspec-GWm4ffKl.js → addspec-C_3NBarY.js} +1 -1
  17. package/client/dist/assets/{addspec-Dw-0Dg-4.js → addspec-DDvvnE6N.js} +1 -1
  18. package/client/dist/assets/{addspec-DpRgmfmx.js → addspec-RuL8Zd7w.js} +1 -1
  19. package/client/dist/assets/{addspec-rp496P_F.js → addspec-rmhOaH7N.js} +1 -1
  20. package/client/dist/assets/{addspec-BCT9vm_c.js → addspec-xjDbYZWL.js} +1 -1
  21. package/client/dist/assets/{dist-js-B16c3VyT.js → dist-js-CiIVMsx3.js} +1 -1
  22. package/client/dist/assets/{dist-js-P2FkJ6fA.js → dist-js-Xc2lRKp2.js} +1 -1
  23. package/client/dist/assets/{index-AfVF6BgE.js → index-DK214dak.js} +45 -45
  24. package/client/dist/assets/index-DgKfQFcf.css +2 -0
  25. package/client/dist/assets/{lib-rNNmltMb.js → lib-Bo5s6xpe.js} +1 -1
  26. package/client/dist/assets/{settings-D3LurcR5.js → settings-BI_cVCqN.js} +1 -1
  27. package/client/dist/assets/{settings-5tzo0Rn3.js → settings-BRaLLSVi.js} +1 -1
  28. package/client/dist/assets/{settings-BEWv3VEu.js → settings-BcqH0oea.js} +1 -1
  29. package/client/dist/assets/settings-C0-7Fpxg.js +1 -0
  30. package/client/dist/assets/{settings-BORg56um.js → settings-D6QMBlGQ.js} +1 -1
  31. package/client/dist/assets/{settings-DcqWIEM6.js → settings-GOBKOTGl.js} +1 -1
  32. package/client/dist/assets/{settings-BDAW3trC.js → settings-pT3MzfRu.js} +1 -1
  33. package/client/dist/assets/{settings-Dfz8QbZS.js → settings-u-16ISHt.js} +1 -1
  34. package/client/dist/assets/{setup-D3rNZA9A.js → setup-BIIkb-_K.js} +1 -1
  35. package/client/dist/assets/{setup-C1IA-9YS.js → setup-BeQxu9kD.js} +1 -1
  36. package/client/dist/assets/{setup-pjgmYHx6.js → setup-CPa6GnlI.js} +1 -1
  37. package/client/dist/assets/{setup-gzLG8T6F.js → setup-CZl4OEJx.js} +1 -1
  38. package/client/dist/assets/{setup-C0dzw8j4.js → setup-ChpodNfn.js} +1 -1
  39. package/client/dist/assets/{setup-WP6WOYQh.js → setup-D_fjJH6u.js} +1 -1
  40. package/client/dist/assets/{setup-UD2aanGs.js → setup-YzD8DX4O.js} +1 -1
  41. package/client/dist/assets/{setup-CpfjaNut.js → setup-fRpDozmq.js} +1 -1
  42. package/client/dist/assets/{useProjectCache-Cid_GxRM.js → useProjectCache-DVNypkmR.js} +1 -1
  43. package/client/dist/index.html +5 -5
  44. package/docs/adding-a-provider.md +107 -0
  45. package/docs/agy-cli-provider-study.md +179 -0
  46. package/docs/gemini-cli-provider-study.md +301 -0
  47. package/docs/gemini-core-support-evaluation.md +160 -0
  48. package/docs/gemini.md +106 -0
  49. package/docs/internals/api-reference.md +4 -7
  50. package/package.json +2 -2
  51. package/server/dist/chat-manager.js +1 -1
  52. package/server/dist/core-package.js +6 -1
  53. package/server/dist/desktop-router.js +27 -8
  54. package/server/dist/explore-cwd-manager.js +1 -1
  55. package/server/dist/mobile/index.js +5 -5
  56. package/server/dist/mobile/mobile-admin-router.js +28 -35
  57. package/server/dist/mobile/mobile-datachannel.js +228 -0
  58. package/server/dist/mobile/mobile-gateway.js +72 -98
  59. package/server/dist/mobile/mobile-router.js +4 -35
  60. package/server/dist/mobile/mobile-signal-reconnect.js +84 -0
  61. package/server/dist/mobile/mobile-types.js +5 -5
  62. package/server/dist/mobile/mobile-webrtc-peer.js +129 -0
  63. package/server/dist/mobile/mobile-webrtc.js +117 -0
  64. package/server/dist/pricing.js +13 -0
  65. package/server/dist/project-router-tickets.js +63 -18
  66. package/server/dist/providers/gemini-adapter.js +234 -0
  67. package/server/dist/providers/index.js +4 -1
  68. package/server/dist/setup-manager.js +13 -7
  69. package/server/dist/setup-prerequisites.js +4 -0
  70. package/server/dist/spec-models.js +17 -3
  71. package/server/dist/util/cli-prompt.js +17 -1
  72. package/client/dist/assets/AnalyticsPage-AbVXKh9v.js +0 -1
  73. package/client/dist/assets/index-NlH5BbXJ.css +0 -2
  74. package/client/dist/assets/settings-yMubjqYw.js +0 -1
  75. package/server/dist/mobile/mobile-mdns.js +0 -81
  76. package/server/dist/mobile/mobile-pairing.js +0 -179
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MobileWebrtcGateway = void 0;
4
+ const werift_1 = require("werift");
5
+ const auth_1 = require("../auth");
6
+ const mobile_datachannel_1 = require("./mobile-datachannel");
7
+ class MobileWebrtcGateway {
8
+ _deps;
9
+ _pending = null;
10
+ _active = new Set();
11
+ constructor(_deps) {
12
+ this._deps = _deps;
13
+ }
14
+ /** Drop the open (un-answered) offer. */
15
+ clearOffer() {
16
+ const p = this._pending;
17
+ this._pending = null;
18
+ if (p)
19
+ this._close(p.pc);
20
+ }
21
+ /** Create a fresh pairing offer (peer + DataChannel + gathered offer SDP).
22
+ * Replaces any previously-open offer. Returns the SDP to embed in the QR. */
23
+ async createOffer(secret) {
24
+ this.clearOffer();
25
+ const pc = new werift_1.RTCPeerConnection({ iceServers: [] });
26
+ const dc = pc.createDataChannel('mobile');
27
+ // The companion peer activates when the channel opens. It validates `hello`
28
+ // against THIS connection's offer secret (captured in this closure) — not a
29
+ // global pending secret, which is already null by the time hello arrives.
30
+ new mobile_datachannel_1.DataChannelPeer(toChannelLike(dc), {
31
+ registerDevice: async (input) => input.secret && (0, auth_1.safeEqual)(input.secret, secret)
32
+ ? this._deps.registerDevice({ deviceName: input.deviceName, platform: input.platform, token: input.token })
33
+ : { ok: false },
34
+ rpcDispatch: this._deps.rpcDispatch,
35
+ bridge: this._deps.bridge,
36
+ });
37
+ pc.connectionStateChange.subscribe((s) => {
38
+ console.log('[webrtc] pc connectionState:', s);
39
+ if (s === 'failed' || s === 'closed' || s === 'disconnected') {
40
+ this._active.delete(pc);
41
+ if (this._pending?.pc === pc)
42
+ this._pending = null;
43
+ }
44
+ });
45
+ const offer = await pc.createOffer();
46
+ await pc.setLocalDescription(offer);
47
+ await waitIceComplete(pc);
48
+ const sdp = pc.localDescription?.sdp;
49
+ if (!sdp) {
50
+ this._close(pc);
51
+ throw new Error('no local description after ICE gathering');
52
+ }
53
+ console.log(`[webrtc] offer ready: ${(sdp.match(/a=candidate/g) ?? []).length} ICE candidates`);
54
+ this._pending = { pc, secret };
55
+ return { sdp };
56
+ }
57
+ /** Apply the companion's scanned answer SDP to the open offer and keep the
58
+ * connection alive for the session. */
59
+ async acceptAnswer(sdp) {
60
+ const p = this._pending;
61
+ if (!p)
62
+ return { ok: false };
63
+ try {
64
+ console.log(`[webrtc] applying answer: ${(sdp.match(/a=candidate/g) ?? []).length} ICE candidates`);
65
+ await p.pc.setRemoteDescription({ type: 'answer', sdp });
66
+ this._active.add(p.pc);
67
+ this._pending = null;
68
+ return { ok: true };
69
+ }
70
+ catch {
71
+ return { ok: false };
72
+ }
73
+ }
74
+ /** Tear down every peer (gateway stopping / disabled). */
75
+ stop() {
76
+ this.clearOffer();
77
+ for (const pc of this._active)
78
+ this._close(pc);
79
+ this._active.clear();
80
+ }
81
+ _close(pc) {
82
+ try {
83
+ void pc.close();
84
+ }
85
+ catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ }
90
+ exports.MobileWebrtcGateway = MobileWebrtcGateway;
91
+ function toChannelLike(dc) {
92
+ dc.stateChanged.subscribe((s) => console.log('[webrtc] datachannel state:', s));
93
+ return {
94
+ get readyState() {
95
+ return dc.readyState;
96
+ },
97
+ send: (data) => dc.send(data),
98
+ onMessage: (cb) => {
99
+ dc.onMessage.subscribe((d) => cb(typeof d === 'string' ? d : d.toString('utf8')));
100
+ },
101
+ onClose: (cb) => {
102
+ dc.stateChanged.subscribe((s) => {
103
+ if (s === 'closed')
104
+ cb();
105
+ });
106
+ },
107
+ };
108
+ }
109
+ /** Vanilla ICE: resolve once gathering completes so the offer SDP carries all
110
+ * candidates (there's no trickle channel — the SDP travels by QR). */
111
+ function waitIceComplete(pc) {
112
+ if (pc.iceGatheringState === 'complete')
113
+ return Promise.resolve();
114
+ return new Promise((resolve) => {
115
+ let done = false;
116
+ const finish = () => {
117
+ if (!done) {
118
+ done = true;
119
+ resolve();
120
+ }
121
+ };
122
+ pc.iceGatheringStateChange.subscribe((s) => {
123
+ if (s === 'complete')
124
+ finish();
125
+ });
126
+ const t = setTimeout(finish, 3000);
127
+ t.unref?.();
128
+ });
129
+ }
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildRegisterDevice = buildRegisterDevice;
7
+ exports.buildRpcDispatch = buildRpcDispatch;
8
+ exports.createLoopbackFetch = createLoopbackFetch;
9
+ const https_1 = __importDefault(require("https"));
10
+ const crypto_1 = require("crypto");
11
+ const mobile_devices_1 = require("./mobile-devices");
12
+ // Glue for the serverless WebRTC companion, kept dependency-injected so the
13
+ // security-relevant bits (secret check, device creation, /v1 dispatch) are
14
+ // unit-tested without a real WebRTC peer, the DB driver, or HTTP. The werift
15
+ // runtime peer lives in ./mobile-webrtc-peer.ts and consumes these.
16
+ function toPlatform(p) {
17
+ return p === 'android' ? 'android' : p === 'ios' ? 'ios' : 'web';
18
+ }
19
+ /** Persists a paired device + issues its token. The single-use pairing-secret
20
+ * check is done per-connection by [MobileWebrtcGateway] (which holds each
21
+ * offer's secret for the life of that connection — the secret must NOT live on
22
+ * the global "pending offer", which is cleared the moment the answer is
23
+ * accepted, well before the companion's `hello` arrives). Approval is implicit:
24
+ * the desktop user actively scanned the phone's answer QR. */
25
+ function buildRegisterDevice(deps) {
26
+ const genToken = deps.genToken ?? (() => (0, crypto_1.randomBytes)(32).toString('hex'));
27
+ return async (input) => {
28
+ // Reconnect: a known, non-revoked device token → reuse that device (no new
29
+ // row, no growing the paired-devices list on every reconnect).
30
+ if (input.token) {
31
+ const existing = (0, mobile_devices_1.getActiveDeviceByTokenHash)(deps.db, (0, mobile_devices_1.hashToken)(input.token));
32
+ if (existing) {
33
+ return {
34
+ ok: true,
35
+ deviceId: existing.id,
36
+ token: input.token,
37
+ hubName: deps.desktopName(),
38
+ hubInstanceId: deps.desktopInstanceId(),
39
+ };
40
+ }
41
+ }
42
+ const token = genToken();
43
+ const row = (0, mobile_devices_1.createDevice)(deps.db, {
44
+ name: (input.deviceName || 'Web companion').slice(0, 80),
45
+ platform: toPlatform(input.platform),
46
+ tokenHash: (0, mobile_devices_1.hashToken)(token),
47
+ certFingerprint: deps.currentFingerprint(),
48
+ });
49
+ return {
50
+ ok: true,
51
+ deviceId: row.id,
52
+ token,
53
+ hubName: deps.desktopName(),
54
+ hubInstanceId: deps.desktopInstanceId(),
55
+ };
56
+ };
57
+ }
58
+ /** Builds the `rpcDispatch` used by [DataChannelPeer] on `rpc`. A thin proxy to
59
+ * the EXISTING `/v1` allow-list (which already validates params, forwards to the
60
+ * internal API, and redacts), authenticated with the device's bearer token. */
61
+ function buildRpcDispatch(deps) {
62
+ return async (input) => {
63
+ // Only the /v1 surface is reachable; the gateway enforces the allow-list.
64
+ if (input.path !== '/v1' && !input.path.startsWith('/v1/')) {
65
+ return { status: 404, json: { error: 'not found' } };
66
+ }
67
+ const method = input.method.toUpperCase();
68
+ const headers = { Authorization: `Bearer ${input.token}` };
69
+ let body;
70
+ if (input.body !== undefined && method !== 'GET' && method !== 'DELETE') {
71
+ headers['Content-Type'] = 'application/json';
72
+ body = JSON.stringify(input.body);
73
+ }
74
+ try {
75
+ const res = await deps.doFetch(deps.gatewayBase + input.path, { method, headers, body });
76
+ const text = await res.text();
77
+ let json = {};
78
+ try {
79
+ json = text ? JSON.parse(text) : {};
80
+ }
81
+ catch {
82
+ json = {};
83
+ }
84
+ return { status: res.status, json };
85
+ }
86
+ catch {
87
+ return { status: 502, json: { error: 'gateway unreachable' } };
88
+ }
89
+ };
90
+ }
91
+ /** A loopback `fetch` for the local /v1 gateway. It deliberately ignores the
92
+ * gateway's self-signed cert — the connection never leaves 127.0.0.1, and the
93
+ * device bearer token (not the TLS cert) is the auth boundary. */
94
+ function createLoopbackFetch() {
95
+ return (url, init) => new Promise((resolve, reject) => {
96
+ const u = new URL(url);
97
+ const req = https_1.default.request({
98
+ hostname: u.hostname,
99
+ port: u.port,
100
+ path: u.pathname + u.search,
101
+ method: init.method,
102
+ headers: init.headers,
103
+ rejectUnauthorized: false,
104
+ }, (res) => {
105
+ let data = '';
106
+ res.setEncoding('utf8');
107
+ res.on('data', (c) => {
108
+ data += c;
109
+ });
110
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, text: async () => data }));
111
+ });
112
+ req.on('error', reject);
113
+ if (init.body)
114
+ req.write(init.body);
115
+ req.end();
116
+ });
117
+ }
@@ -32,6 +32,19 @@ exports.PRICING = {
32
32
  'codex:gpt-5.4': { inputPer1M: 2.50, outputPer1M: 10.00, cacheReadPer1M: 0.25, lastReviewedAt: '2026-05-17' },
33
33
  'codex:gpt-5.4-mini': { inputPer1M: 0.25, outputPer1M: 2.00, cacheReadPer1M: 0.025, lastReviewedAt: '2026-05-17' },
34
34
  'codex:gpt-5.3-codex': { inputPer1M: 1.50, outputPer1M: 6.00, cacheReadPer1M: 0.15, lastReviewedAt: '2026-05-17' },
35
+ // Gemini (Google). Reference: https://ai.google.dev/gemini-api/docs/pricing
36
+ // Standard paid tier, <=200k-context prices (Gemini Pro is context-tiered;
37
+ // prompts >200k are under-estimated in v1 — see docs/gemini-cli-provider-study.md §2).
38
+ // Current catalog (June 2026): 3.5-flash flagship default, 3.1-pro-preview max,
39
+ // 3.1-flash-lite budget, 2.5-flash-lite cheapest. Older 2.5-pro/2.5-flash rows
40
+ // are retained so historic invocations still price.
41
+ 'gemini:gemini-3.5-flash': { inputPer1M: 1.50, outputPer1M: 9.00, cacheReadPer1M: 0.15, lastReviewedAt: '2026-06-17' },
42
+ 'gemini:gemini-3.1-pro-preview': { inputPer1M: 2.00, outputPer1M: 12.00, cacheReadPer1M: 0.20, lastReviewedAt: '2026-06-17' },
43
+ 'gemini:gemini-3.1-flash-lite': { inputPer1M: 0.25, outputPer1M: 1.50, cacheReadPer1M: 0.025, lastReviewedAt: '2026-06-17' },
44
+ 'gemini:gemini-3-flash-preview': { inputPer1M: 0.50, outputPer1M: 3.00, cacheReadPer1M: 0.05, lastReviewedAt: '2026-06-17' },
45
+ 'gemini:gemini-2.5-pro': { inputPer1M: 1.25, outputPer1M: 10.00, cacheReadPer1M: 0.125, lastReviewedAt: '2026-06-17' },
46
+ 'gemini:gemini-2.5-flash': { inputPer1M: 0.30, outputPer1M: 2.50, cacheReadPer1M: 0.03, lastReviewedAt: '2026-06-17' },
47
+ 'gemini:gemini-2.5-flash-lite': { inputPer1M: 0.10, outputPer1M: 0.40, cacheReadPer1M: 0.01, lastReviewedAt: '2026-06-17' },
35
48
  };
36
49
  /**
37
50
  * Estimate cost in USD from a token usage breakdown. Returns `null` when:
@@ -393,24 +393,44 @@ function registerTicketsRoutes(deps) {
393
393
  broadcast(msg);
394
394
  return;
395
395
  }
396
- // Claude path.
397
- if (parsed.type === 'result') {
396
+ if (provider === 'claude') {
397
+ // Claude path.
398
+ if (parsed.type === 'result') {
399
+ lastResultEvent = parsed;
400
+ }
401
+ if (parsed.type === 'assistant') {
402
+ const msg = parsed.message;
403
+ const texts = (msg?.content ?? [])
404
+ .filter((c) => c.type === 'text')
405
+ .map((c) => c.text ?? '');
406
+ const newText = texts.join('');
407
+ if (newText) {
408
+ buffer += newText;
409
+ const wsMsg = {
410
+ type: 'spec_gen_stream', projectId, requestId,
411
+ delta: newText, timestamp: new Date().toISOString(),
412
+ };
413
+ broadcast(wsMsg);
414
+ }
415
+ }
416
+ return;
417
+ }
418
+ // Adapter-driven path (gemini + future providers): the adapter already
419
+ // parsed this line into a structured AdapterEvent above (`adapterEv`).
420
+ // Accumulate assistant text deltas into the buffer and capture the result
421
+ // event for usage extraction — mirrors the ai-edit endpoint's else branch.
422
+ // Without this, gemini (which emits `type:'message'`, not `type:'assistant'`)
423
+ // would never accumulate text → empty buffer → "Empty response from AI".
424
+ if (adapterEv?.kind === 'result') {
398
425
  lastResultEvent = parsed;
399
426
  }
400
- if (parsed.type === 'assistant') {
401
- const msg = parsed.message;
402
- const texts = (msg?.content ?? [])
403
- .filter((c) => c.type === 'text')
404
- .map((c) => c.text ?? '');
405
- const newText = texts.join('');
406
- if (newText) {
407
- buffer += newText;
408
- const wsMsg = {
409
- type: 'spec_gen_stream', projectId, requestId,
410
- delta: newText, timestamp: new Date().toISOString(),
411
- };
412
- broadcast(wsMsg);
413
- }
427
+ if (adapterEv?.kind === 'text-delta' && adapterEv.text) {
428
+ buffer += adapterEv.text;
429
+ const wsMsg = {
430
+ type: 'spec_gen_stream', projectId, requestId,
431
+ delta: adapterEv.text, timestamp: new Date().toISOString(),
432
+ };
433
+ broadcast(wsMsg);
414
434
  }
415
435
  });
416
436
  child.on('close', async (code) => {
@@ -1476,7 +1496,7 @@ function registerTicketsRoutes(deps) {
1476
1496
  // Use gpt-5.5 (default for Codex per CODEX_MODELS/PRESET_DEFAULTS in ModelSelector); never hardcode o4-mini
1477
1497
  args = ['exec', `${systemPrompt}\n\n${userPrompt}`, '--model', 'gpt-5.5'];
1478
1498
  }
1479
- else {
1499
+ else if (provider === 'claude') {
1480
1500
  binary = 'claude';
1481
1501
  args = [
1482
1502
  '--dangerously-skip-permissions',
@@ -1489,6 +1509,18 @@ function registerTicketsRoutes(deps) {
1489
1509
  '-p', userPrompt,
1490
1510
  ];
1491
1511
  }
1512
+ else {
1513
+ // Adapter-driven path (gemini + any future provider): binary, argv, and
1514
+ // stream parsing all come from the registered adapter — no per-provider
1515
+ // hardcoding. The claude/codex shapes above are kept byte-identical.
1516
+ const adapter = (0, providers_1.getAdapter)(provider);
1517
+ binary = adapter.binary;
1518
+ args = adapter.buildArgs('agent-refine', {
1519
+ prompt: userPrompt,
1520
+ systemPrompt,
1521
+ model: adapter.defaultModel(),
1522
+ });
1523
+ }
1492
1524
  // spawnAiCli reroutes multi-line argv values through stdin on Windows.
1493
1525
  console.log(`[project-router] ai-edit spawn: ${binary} (cwd=${project.path}, requestId=${requestId})`);
1494
1526
  const child = (0, cli_prompt_1.spawnAiCli)(binary, args, {
@@ -1534,7 +1566,7 @@ function registerTicketsRoutes(deps) {
1534
1566
  broadcast(msg);
1535
1567
  }
1536
1568
  }
1537
- else {
1569
+ else if (provider === 'claude') {
1538
1570
  let parsed = null;
1539
1571
  try {
1540
1572
  parsed = JSON.parse(line);
@@ -1558,6 +1590,19 @@ function registerTicketsRoutes(deps) {
1558
1590
  }
1559
1591
  }
1560
1592
  }
1593
+ else {
1594
+ // Adapter-driven parse (gemini + future providers): uniform AdapterEvent
1595
+ // stream — accumulate assistant text deltas.
1596
+ const ev = (0, providers_1.getAdapter)(provider).parseStreamLine(line);
1597
+ if (ev?.kind === 'text-delta' && ev.text) {
1598
+ buffer += ev.text;
1599
+ const wsMsg = {
1600
+ type: 'ticket_ai_edit_stream', projectId, ticketId: Number(ticketId),
1601
+ requestId, delta: ev.text, timestamp: new Date().toISOString(),
1602
+ };
1603
+ broadcast(wsMsg);
1604
+ }
1605
+ }
1561
1606
  });
1562
1607
  child.on('close', (code) => {
1563
1608
  _aiEditProcesses.delete(requestId);
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ // Gemini CLI (Google) adapter for gemini-cli 0.11.0+.
3
+ //
4
+ // Stream format (gemini `-p "<prompt>" --output-format stream-json`), one minified
5
+ // JSON object per line — pinned to gemini-cli >= 0.11 and locked by the fixtures
6
+ // under __fixtures__/gemini-*.ndjson:
7
+ // {"type":"init","session_id":"<UUID>","model":"gemini-3.5-flash"}
8
+ // {"type":"message","role":"assistant","content":"...","delta":true}
9
+ // {"type":"tool_use","tool_name":"read_file","tool_id":"t0","parameters":{...}}
10
+ // {"type":"tool_result","tool_id":"t0","status":"ok","output":"..."}
11
+ // {"type":"result","status":"success","stats":{"input_tokens":N,"output_tokens":N,
12
+ // "cached":N,"total_tokens":N,"duration_ms":N}}
13
+ //
14
+ // Differences vs codex: Gemini reports tokens but NOT cost, so `nativeCostUsd`
15
+ // is false and cost is estimated downstream via server/pricing.ts. Gemini DOES
16
+ // emit OTLP natively via `GEMINI_TELEMETRY_*` env (set by QueueManager), so
17
+ // `nativeOtelEnv` is true — no synthetic bridge. There is no `--system-prompt`
18
+ // flag (the CLI uses the `GEMINI_SYSTEM_MD` env), so `systemPromptArg` is false
19
+ // and the system prompt is folded for non-Explore actions; Explore turns trust
20
+ // the app-managed GEMINI.md in explore-cwd, exactly like codex/AGENTS.md.
21
+ //
22
+ // NOTE (pre-1.0): gemini-cli changes weekly. The exact argv/event field names
23
+ // here follow the documented 0.11 contract; they are guarded by the beta flag
24
+ // (SPECRAILS_GEMINI_BETA) and MUST be re-validated against a real binary via the
25
+ // fixtures before the gate is removed. Parsing is tolerant of missing fields.
26
+ //
27
+ // Spec: openspec/specs/multi-provider-architecture/spec.md
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports._GEMINI_MIN_VERSION = exports.geminiAdapter = void 0;
30
+ exports._compareSemver = compareSemver;
31
+ const child_process_1 = require("child_process");
32
+ const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
33
+ // Floor where `--output-format stream-json` + headless `--resume` are available.
34
+ const GEMINI_MIN_VERSION = '0.11.0';
35
+ exports._GEMINI_MIN_VERSION = GEMINI_MIN_VERSION;
36
+ // Curated GA-id catalog (see docs/gemini-cli-provider-study.md §2). Preview ids
37
+ // rotate, so we pin concrete ids that pricing.ts has rows for.
38
+ const GEMINI_MODELS = [
39
+ { value: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', default: true },
40
+ { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro (preview)' },
41
+ { value: 'gemini-3.1-flash-lite', label: 'Gemini 3.1 Flash Lite' },
42
+ { value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
43
+ ];
44
+ const STREAM_JSON_FLAGS = ['--output-format', 'stream-json'];
45
+ // Auto-approve tool calls so headless turns never block on an approval prompt.
46
+ // Parity with codex's workspace-write/full-access sandbox; the non-destructive
47
+ // stance for Explore is carried by GEMINI.md + the system prompt, not a sandbox.
48
+ const YOLO_FLAG = '--yolo';
49
+ /** Fold system prompt into the user prompt for providers without --system-prompt. */
50
+ function fold(systemPrompt, prompt) {
51
+ if (!systemPrompt)
52
+ return prompt;
53
+ return `${systemPrompt}\n\n---\n\n${prompt}`;
54
+ }
55
+ function buildGeminiArgs(action, opts) {
56
+ const model = ['--model', opts.model];
57
+ switch (action) {
58
+ case 'chat-turn': {
59
+ // Explore turns spawn from the app-managed explore-cwd, which ships a
60
+ // GEMINI.md with the Explore stance. Folding the long system prompt into a
61
+ // short user message ("quiero hacer un tetris") makes the model answer the
62
+ // system instructions instead of the user — so pass user text only and
63
+ // trust GEMINI.md, exactly like the codex adapter trusts AGENTS.md.
64
+ return ['-p', opts.prompt, ...model, ...STREAM_JSON_FLAGS, YOLO_FLAG, ...(opts.extraArgs ?? [])];
65
+ }
66
+ case 'chat-resume': {
67
+ if (!opts.sessionId)
68
+ throw new Error(`${action} requires sessionId`);
69
+ return ['-p', opts.prompt, ...model, ...STREAM_JSON_FLAGS, '--resume', opts.sessionId, YOLO_FLAG, ...(opts.extraArgs ?? [])];
70
+ }
71
+ case 'chat-stream': {
72
+ // Gemini has no persistent-stdin multi-turn transport; the Explore
73
+ // fast-path gates on capabilities.persistentStdin (omitted), so this is
74
+ // never reached. Throw defensively rather than emit a broken argv.
75
+ throw new Error('gemini does not support persistent stdin streaming (chat-stream)');
76
+ }
77
+ case 'spec-gen':
78
+ case 'agent-refine':
79
+ case 'auto-title':
80
+ case 'setup-enrich':
81
+ case 'rail-job': {
82
+ return ['-p', fold(opts.systemPrompt, opts.prompt), ...model, ...STREAM_JSON_FLAGS, YOLO_FLAG, ...(opts.extraArgs ?? [])];
83
+ }
84
+ case 'setup-enrich-resume': {
85
+ if (!opts.sessionId)
86
+ throw new Error(`${action} requires sessionId`);
87
+ return ['-p', fold(opts.systemPrompt, opts.prompt), ...model, ...STREAM_JSON_FLAGS, '--resume', opts.sessionId, YOLO_FLAG, ...(opts.extraArgs ?? [])];
88
+ }
89
+ }
90
+ }
91
+ function parseGeminiStreamLine(line) {
92
+ if (line.length === 0)
93
+ return null;
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(line);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ const type = parsed.type;
102
+ if (!type)
103
+ return { kind: 'other', type: '<missing>', raw: parsed };
104
+ if (type === 'init') {
105
+ const sid = parsed.session_id;
106
+ if (sid)
107
+ return { kind: 'session-started', sessionId: sid };
108
+ return { kind: 'other', type, raw: parsed };
109
+ }
110
+ if (type === 'result') {
111
+ return { kind: 'result', payload: parsed };
112
+ }
113
+ if (type === 'message') {
114
+ // Only assistant text is a delta; the user echo (role 'user') is ignored.
115
+ const role = parsed.role;
116
+ if (role === 'assistant') {
117
+ const text = parsed.content ?? '';
118
+ if (text)
119
+ return { kind: 'text-delta', text };
120
+ }
121
+ return { kind: 'other', type, raw: parsed };
122
+ }
123
+ if (type === 'tool_use') {
124
+ const name = parsed.tool_name ?? '<unnamed>';
125
+ const params = parsed.parameters;
126
+ const inputPreview = params !== undefined ? JSON.stringify(params).slice(0, 200) : '';
127
+ return { kind: 'tool-use', name, inputPreview };
128
+ }
129
+ // tool_result, error, and any unknown event type carry no normalised meaning.
130
+ return { kind: 'other', type, raw: parsed };
131
+ }
132
+ function extractGeminiResult(events) {
133
+ let sessionId;
134
+ let resultPayload = null;
135
+ let turnCount = 0;
136
+ for (const ev of events) {
137
+ if (ev.kind === 'session-started')
138
+ sessionId = ev.sessionId;
139
+ else if (ev.kind === 'result') {
140
+ resultPayload = ev.payload;
141
+ turnCount += 1;
142
+ }
143
+ }
144
+ if (!resultPayload) {
145
+ return { session_id: sessionId };
146
+ }
147
+ const stats = resultPayload.stats;
148
+ return {
149
+ tokens_in: stats?.input_tokens,
150
+ tokens_out: stats?.output_tokens,
151
+ // Gemini reports a single `cached` read tier; map it to cache_read. No
152
+ // separate cache-creation tier — `cached` is counted as a subset of
153
+ // input_tokens (consistent with codex `cached_input_tokens`), so pricing.ts
154
+ // must not double-count.
155
+ tokens_cache_read: stats?.cached,
156
+ tokens_cache_create: undefined,
157
+ // total_cost_usd intentionally absent — estimated via pricing.ts.
158
+ num_turns: turnCount || 1,
159
+ // Stream events do not carry the model; the manager wrapper stamps the
160
+ // requested model on the row.
161
+ model: undefined,
162
+ duration_ms: typeof stats?.duration_ms === 'number' ? stats.duration_ms : undefined,
163
+ duration_api_ms: undefined,
164
+ session_id: sessionId,
165
+ };
166
+ }
167
+ function compareSemver(a, b) {
168
+ const aParts = a.split('.').map((n) => parseInt(n, 10));
169
+ const bParts = b.split('.').map((n) => parseInt(n, 10));
170
+ for (let i = 0; i < 3; i++) {
171
+ const av = aParts[i] ?? 0;
172
+ const bv = bParts[i] ?? 0;
173
+ if (av > bv)
174
+ return 1;
175
+ if (av < bv)
176
+ return -1;
177
+ }
178
+ return 0;
179
+ }
180
+ async function detectGeminiInstalled() {
181
+ try {
182
+ (0, child_process_1.execSync)(`${WHICH_CMD} gemini`, { stdio: 'ignore' });
183
+ }
184
+ catch {
185
+ return { installed: false, executable: false };
186
+ }
187
+ try {
188
+ const raw = (0, child_process_1.execSync)('gemini --version', {
189
+ encoding: 'utf-8',
190
+ stdio: ['pipe', 'pipe', 'ignore'],
191
+ timeout: 3000,
192
+ }).trim();
193
+ const match = raw.match(/\d+\.\d+\.\d+/);
194
+ const version = match ? match[0] : raw;
195
+ const meetsMinimum = match ? compareSemver(version, GEMINI_MIN_VERSION) >= 0 : false;
196
+ const result = {
197
+ installed: true,
198
+ executable: true,
199
+ version,
200
+ meetsMinimum,
201
+ };
202
+ if (!meetsMinimum) {
203
+ result.error = `gemini ${version} is older than required ${GEMINI_MIN_VERSION}. Upgrade with: npm i -g @google/gemini-cli (or see https://github.com/google-gemini/gemini-cli).`;
204
+ }
205
+ return result;
206
+ }
207
+ catch {
208
+ return { installed: true, executable: false };
209
+ }
210
+ }
211
+ exports.geminiAdapter = {
212
+ id: 'gemini',
213
+ displayName: 'Gemini CLI',
214
+ binary: 'gemini',
215
+ minCliVersion: GEMINI_MIN_VERSION,
216
+ projectDirName: '.gemini',
217
+ instructionsFilename: 'GEMINI.md',
218
+ mcpRegistration: 'project-json',
219
+ capabilities: {
220
+ nativeResume: true,
221
+ nativeStreamJson: true,
222
+ nativeCostUsd: false,
223
+ nativeOtelEnv: true,
224
+ profileEnvSupport: true,
225
+ systemPromptArg: false,
226
+ },
227
+ modelCatalog: () => GEMINI_MODELS,
228
+ defaultModel: () => 'gemini-3.5-flash',
229
+ buildArgs: buildGeminiArgs,
230
+ parseStreamLine: parseGeminiStreamLine,
231
+ extractResult: extractGeminiResult,
232
+ baselineAgents: () => ['sr-architect', 'sr-developer', 'sr-reviewer'],
233
+ detectInstalled: detectGeminiInstalled,
234
+ };
@@ -7,14 +7,17 @@
7
7
  //
8
8
  // Spec: openspec/specs/multi-provider-architecture/spec.md
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.UnknownProviderError = exports.codexAdapter = exports.claudeAdapter = exports.listAdapters = exports.hasAdapter = exports.getAdapter = void 0;
10
+ exports.UnknownProviderError = exports.geminiAdapter = exports.codexAdapter = exports.claudeAdapter = exports.listAdapters = exports.hasAdapter = exports.getAdapter = void 0;
11
11
  const registry_1 = require("./registry");
12
12
  const claude_adapter_1 = require("./claude-adapter");
13
13
  Object.defineProperty(exports, "claudeAdapter", { enumerable: true, get: function () { return claude_adapter_1.claudeAdapter; } });
14
14
  const codex_adapter_1 = require("./codex-adapter");
15
15
  Object.defineProperty(exports, "codexAdapter", { enumerable: true, get: function () { return codex_adapter_1.codexAdapter; } });
16
+ const gemini_adapter_1 = require("./gemini-adapter");
17
+ Object.defineProperty(exports, "geminiAdapter", { enumerable: true, get: function () { return gemini_adapter_1.geminiAdapter; } });
16
18
  (0, registry_1.register)(claude_adapter_1.claudeAdapter);
17
19
  (0, registry_1.register)(codex_adapter_1.codexAdapter);
20
+ (0, registry_1.register)(gemini_adapter_1.geminiAdapter);
18
21
  var registry_2 = require("./registry");
19
22
  Object.defineProperty(exports, "getAdapter", { enumerable: true, get: function () { return registry_2.getAdapter; } });
20
23
  Object.defineProperty(exports, "hasAdapter", { enumerable: true, get: function () { return registry_2.hasAdapter; } });