specrails-desktop 2.7.0 → 2.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,13 +29,15 @@ function isCodexBetaDisabled() {
29
29
  const v = process.env.SPECRAILS_CODEX_BETA ?? process.env.SPECRAILS_HUB_CODEX_BETA;
30
30
  return v === '0';
31
31
  }
32
- // Gemini is opt-IN (default off): unlike codex, its stream-json schema has not
33
- // yet been validated against a live binary, so it stays hidden + unselectable
34
- // until SPECRAILS_GEMINI_BETA=1 (or 'true'). The adapter is always registered
35
- // (pricing/getAdapter work); only project selection is gated.
36
- function isGeminiBetaEnabled() {
37
- const v = (process.env.SPECRAILS_GEMINI_BETA ?? '').toLowerCase();
38
- return v === '1' || v === 'true';
32
+ // Gemini is enabled by DEFAULT now that its stream-json schema and the full rails
33
+ // pipeline (architect→developer→reviewer delegation, headless agent loading, the
34
+ // MAX_TURNS resume + reviewer gate) are validated against the live binary
35
+ // (0.46/0.47). Emergency rollback: SPECRAILS_GEMINI_BETA=0 forces it back to
36
+ // "unavailable" without redeploying — parity with codex's SPECRAILS_CODEX_BETA.
37
+ // The adapter is always registered (pricing/getAdapter work); only project
38
+ // selection is gated.
39
+ function isGeminiBetaDisabled() {
40
+ return process.env.SPECRAILS_GEMINI_BETA === '0';
39
41
  }
40
42
  // Theme allow-list. Mirror of THEME_IDS in `client/src/lib/themes.ts` —
41
43
  // kept duplicated to avoid pulling client code into the server bundle.
@@ -160,10 +162,10 @@ function createDesktopRouter(registry, broadcast) {
160
162
  const gated = { ...providers };
161
163
  if (isCodexBetaDisabled())
162
164
  gated.codex = false;
163
- // Gemini is opt-in: omit it entirely (not just `false`) when the beta flag is
164
- // off, so it stays fully invisible in the UI until SPECRAILS_GEMINI_BETA=1.
165
- if (!isGeminiBetaEnabled())
166
- delete gated.gemini;
165
+ // Gemini: enabled by default; forced unavailable only when
166
+ // SPECRAILS_GEMINI_BETA=0 (emergency rollback, parity with codex).
167
+ if (isGeminiBetaDisabled())
168
+ gated.gemini = false;
167
169
  res.json({ ...gated, tiers });
168
170
  });
169
171
  router.get('/setup-prerequisites', (req, res) => {
@@ -231,9 +233,9 @@ function createDesktopRouter(registry, broadcast) {
231
233
  });
232
234
  return;
233
235
  }
234
- if (providers.includes('gemini') && !isGeminiBetaEnabled()) {
236
+ if (providers.includes('gemini') && isGeminiBetaDisabled()) {
235
237
  res.status(400).json({
236
- error: 'Gemini provider is in beta and disabled by default. Set SPECRAILS_GEMINI_BETA=1 to enable.',
238
+ error: 'Gemini provider is currently disabled (SPECRAILS_GEMINI_BETA=0). Unset or set to 1 to enable.',
237
239
  });
238
240
  return;
239
241
  }
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports._GEMINI_MIN_VERSION = exports.geminiAdapter = void 0;
30
30
  exports._compareSemver = compareSemver;
31
31
  const child_process_1 = require("child_process");
32
+ const gemini_agent_ack_1 = require("./gemini-agent-ack");
32
33
  const WHICH_CMD = process.platform === 'win32' ? 'where' : 'which';
33
34
  // Floor where `--output-format stream-json` + headless `--resume` are available.
34
35
  const GEMINI_MIN_VERSION = '0.11.0';
@@ -231,4 +232,7 @@ exports.geminiAdapter = {
231
232
  extractResult: extractGeminiResult,
232
233
  baselineAgents: () => ['sr-architect', 'sr-developer', 'sr-reviewer'],
233
234
  detectInstalled: detectGeminiInstalled,
235
+ // Pre-acknowledge the project's custom subagents so they load in headless
236
+ // `gemini -p` rail spawns (else invoke_agent reports "Subagent not found").
237
+ prepareHeadlessSpawn: gemini_agent_ack_1.acknowledgeGeminiProjectAgents,
234
238
  };
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ // Gemini headless subagent pre-acknowledgment.
3
+ //
4
+ // gemini 0.46+ DISCOVERS `<project>/.gemini/agents/*.md` but only ENABLES a
5
+ // project's custom subagents after an interactive "New Agents Discovered →
6
+ // Acknowledge and Enable" prompt. That prompt never fires in headless
7
+ // (`gemini -p`) spawns — which is how the desktop runs every rail — so
8
+ // `invoke_agent sr-architect` returns "Subagent not found" and the implement
9
+ // orchestrator silently falls back to a generic agent (the specialised
10
+ // architect/developer/reviewer personas never run in isolation).
11
+ //
12
+ // specrails-core writes the acknowledgment file at install time; this is the
13
+ // defence-in-depth copy the desktop runs right before a gemini rail spawn, so a
14
+ // project installed with an older core (or whose agents changed since install)
15
+ // is still trusted headless. The file gemini reads is
16
+ // `~/.gemini/acknowledgments/agents.json`, shaped
17
+ // { [projectRoot]: { [agentName]: <sha256-hex of the agent .md file> } }
18
+ // where the hash is sha256 of the FULL agent markdown file (verified empirically
19
+ // against gemini 0.47). Entries are MERGED so other projects (and other agents)
20
+ // survive. Best-effort — callers swallow any error; a failure only means the
21
+ // agents need the one-time interactive acknowledge.
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.acknowledgeGeminiProjectAgents = acknowledgeGeminiProjectAgents;
24
+ const crypto_1 = require("crypto");
25
+ const fs_1 = require("fs");
26
+ const os_1 = require("os");
27
+ const path_1 = require("path");
28
+ function ackFilePath() {
29
+ return (0, path_1.join)((0, os_1.homedir)(), '.gemini', 'acknowledgments', 'agents.json');
30
+ }
31
+ /**
32
+ * Pre-acknowledge every `<projectPath>/.gemini/agents/*.md` so gemini loads them
33
+ * in headless mode. No-op when the project has no `.gemini/agents` dir or no
34
+ * agent files. The `projectPath` is the key gemini uses (the spawn cwd / repo
35
+ * root), matching what `specrails-core` writes at install.
36
+ */
37
+ function acknowledgeGeminiProjectAgents(projectPath) {
38
+ const agentsDir = (0, path_1.join)(projectPath, '.gemini', 'agents');
39
+ if (!(0, fs_1.existsSync)(agentsDir))
40
+ return;
41
+ const agentFiles = (0, fs_1.readdirSync)(agentsDir).filter((f) => f.endsWith('.md') && !f.startsWith('_'));
42
+ if (agentFiles.length === 0)
43
+ return;
44
+ const ackPath = ackFilePath();
45
+ let store = {};
46
+ if ((0, fs_1.existsSync)(ackPath)) {
47
+ try {
48
+ const parsed = JSON.parse((0, fs_1.readFileSync)(ackPath, 'utf8'));
49
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
50
+ store = parsed;
51
+ }
52
+ }
53
+ catch {
54
+ // Corrupt/unreadable file — start fresh rather than crash the spawn.
55
+ }
56
+ }
57
+ const projectEntry = { ...(store[projectPath] ?? {}) };
58
+ for (const file of agentFiles) {
59
+ const content = (0, fs_1.readFileSync)((0, path_1.join)(agentsDir, file), 'utf8');
60
+ projectEntry[file.slice(0, -3)] = (0, crypto_1.createHash)('sha256').update(content).digest('hex');
61
+ }
62
+ store[projectPath] = projectEntry;
63
+ (0, fs_1.mkdirSync)((0, path_1.join)((0, os_1.homedir)(), '.gemini', 'acknowledgments'), { recursive: true });
64
+ (0, fs_1.writeFileSync)(ackPath, `${JSON.stringify(store, null, 2)}\n`);
65
+ }
@@ -1000,6 +1000,19 @@ class QueueManager {
1000
1000
  };
1001
1001
  }
1002
1002
  }
1003
+ // Provider-specific filesystem prep before a headless rail spawn. Gemini
1004
+ // uses this to pre-acknowledge the project's custom subagents so they load
1005
+ // in `gemini -p` mode (else invoke_agent reports "Subagent not found" and the
1006
+ // orchestrator silently falls back to a generic agent). No-op for claude/codex.
1007
+ if (this._cwd) {
1008
+ try {
1009
+ adapter.prepareHeadlessSpawn?.(this._cwd);
1010
+ }
1011
+ catch (err) {
1012
+ /* c8 ignore next -- best-effort prep; a failure is non-fatal */
1013
+ console.warn(`[queue-manager] headless-spawn prep failed: ${err.message}`);
1014
+ }
1015
+ }
1003
1016
  // ─── Interactive ultracode branch ──────────────────────────────────────
1004
1017
  // When the launch requested interactive mode AND the command is ultracode
1005
1018
  // AND the adapter supports persistent stdin (claude), hand off to a resident
@@ -9,7 +9,8 @@ exports.extractDisplayText = extractDisplayText;
9
9
  /**
10
10
  * Map a parsed provider stream-json frame to the single display line the Job
11
11
  * Detail log shows (or null when the frame carries no user-facing text).
12
- * Handles both Claude `--output-format stream-json` and Codex `exec --json`.
12
+ * Handles Claude `--output-format stream-json`, Codex `exec --json`, and Gemini
13
+ * `--output-format stream-json` frame shapes.
13
14
  */
14
15
  function extractDisplayText(event) {
15
16
  const type = event.type;
@@ -22,10 +23,24 @@ function extractDisplayText(event) {
22
23
  return texts.join('') || null;
23
24
  }
24
25
  if (type === 'tool_use') {
25
- const name = event.name;
26
- const input = JSON.stringify(event.input ?? {});
26
+ // Claude emits `name`/`input`; Gemini stream-json uses `tool_name`/`parameters`.
27
+ // Tolerating both keeps a single tool_use branch across providers — without
28
+ // this fallback every Gemini tool call renders as `[tool: undefined] {}`.
29
+ const e = event;
30
+ const name = e.name ?? e.tool_name ?? '<unnamed>';
31
+ const input = JSON.stringify(e.input ?? e.parameters ?? {});
27
32
  return `[tool: ${name}] ${input.slice(0, 120)}`;
28
33
  }
34
+ if (type === 'message') {
35
+ // Gemini stream-json streams assistant text as `message` delta frames whose
36
+ // `content` is a plain string; the `role:"user"` echo carries no display
37
+ // value. Without this branch the Job Detail log drops all Gemini narration.
38
+ const e = event;
39
+ if (e.role !== 'assistant')
40
+ return null;
41
+ const text = e.content ?? '';
42
+ return text.length > 0 ? text : null;
43
+ }
29
44
  if (type === 'tool_result' || type === 'system_prompt' || type === 'user' || type === 'system' || type === 'result') {
30
45
  return null;
31
46
  }