codehere 0.5.0-alpha.6 → 0.6.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 (39) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +89 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/context-handoff.js +143 -0
  17. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  18. package/dist/infrastructure/orchestration/failover-config.js +77 -0
  19. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  20. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  21. package/dist/infrastructure/orchestration/graphify-context.js +256 -0
  22. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  23. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  24. package/dist/infrastructure/orchestration/session-manager.js +515 -15
  25. package/dist/infrastructure/protocols/cloud-sync.js +208 -0
  26. package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
  27. package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
  28. package/dist/infrastructure/protocols/ui-page.js +2872 -220
  29. package/dist/infrastructure/security/permissions-config.js +234 -0
  30. package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
  31. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  32. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  33. package/dist/presentation/cli/commands/budget-command.js +79 -0
  34. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  35. package/dist/presentation/cli/commands/permission-command.js +116 -0
  36. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  37. package/dist/presentation/cli/repl.js +29 -0
  38. package/dist/utils/typo-guard.js +77 -17
  39. package/package.json +2 -1
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Failover classifier — v0.6.x rate-limit fail-over wedge (Slice A).
3
+ *
4
+ * Interprets adapter error codes to decide whether fail-over to a different
5
+ * adapter should be offered to the user. Pure function — no I/O, no adapter
6
+ * spawning. Adapters already detect rate-limit / quota errors via their
7
+ * ERROR_PATTERNS arrays and emit `codehere:error` events with codes:
8
+ * - 'rate_limit' — Anthropic / OpenAI / Google 429s
9
+ * - 'quota_exceeded' — credit balance / quota exhausted
10
+ * - 'auth_invalid' — NOT failover-eligible (different fix)
11
+ * - other codes — NOT failover-eligible (specific to provider)
12
+ *
13
+ * This module's job: take an emitted error code, return either a FailoverSignal
14
+ * (caller should ask user "switch to Codex?") or null (don't offer failover).
15
+ *
16
+ * Per `feedback_research_sharpens_not_pivots.md` — this sharpens what already
17
+ * ships (per-adapter error patterns) rather than introducing a new detection
18
+ * mechanism. Per claim_market_position v4 — the fail-over capability is the
19
+ * v0.6 outward proof point that demonstrates codehere's vendor-neutral value
20
+ * (only a vendor-neutral layer can switch you off Claude when Claude refuses).
21
+ *
22
+ * Slice A scope (this commit): the pure classifier + retry-after parser +
23
+ * tests. Slices B-F wire policy resolution, user consent, mid-task hand-off,
24
+ * UI surfacing, and end-to-end integration.
25
+ */
26
+ /**
27
+ * Error codes that should trigger a fail-over offer. Keep tight — false
28
+ * positives waste user attention with "switch?" prompts they don't want.
29
+ *
30
+ * Verified against current adapter ERROR_PATTERNS (May 2026):
31
+ * - 'rate_limit' — emitted by all 5 adapters on 429s
32
+ * - 'quota_exceeded' — emitted by codex-adapter, gemini-adapter
33
+ * - 'credit_low' — emitted by claude-code-adapter, aider-adapter
34
+ * when API credit balance is exhausted
35
+ *
36
+ * NOT included (and why):
37
+ * - 'auth_invalid' — fixing the wrong adapter doesn't help; switching to
38
+ * a different agent that ALSO needs auth doesn't fix anything
39
+ * - generic errors — no signal that another adapter would do better
40
+ * - timeouts — could be transient on either side
41
+ */
42
+ const FAILOVER_TRIGGER_CODES = new Set(['rate_limit', 'quota_exceeded', 'credit_low']);
43
+ /**
44
+ * Returns a FailoverSignal if the error code suggests we should offer
45
+ * fail-over to a different adapter, null otherwise. Pure function — no
46
+ * side effects. Caller decides how to surface the offer (CLI prompt,
47
+ * web UI banner, programmatic auto-failover).
48
+ *
49
+ * @param fromAgent Adapter id of the agent that hit the error
50
+ * @param errorCode Code from the adapter's ERROR_PATTERNS classification
51
+ * @param errorMessage Original error text (used to extract retry-after)
52
+ */
53
+ export function classifyFailoverEligibility(fromAgent, errorCode, errorMessage) {
54
+ if (!FAILOVER_TRIGGER_CODES.has(errorCode))
55
+ return null;
56
+ if (!fromAgent)
57
+ return null;
58
+ const retryAfterMs = parseRetryAfterMs(errorMessage);
59
+ const signal = {
60
+ fromAgent,
61
+ errorCode,
62
+ reason: errorMessage || `${fromAgent} hit ${errorCode}`,
63
+ };
64
+ if (retryAfterMs !== undefined)
65
+ signal.retryAfterMs = retryAfterMs;
66
+ return signal;
67
+ }
68
+ /**
69
+ * Best-effort extraction of retry-after from common error message shapes.
70
+ * Returns milliseconds or undefined. Conservative — only matches patterns
71
+ * we've seen in real provider responses; unknown shapes return undefined
72
+ * (caller treats as "unknown — show indefinite wait").
73
+ *
74
+ * Patterns recognized:
75
+ * - "Retry-After: 60"
76
+ * - "retry after 60s"
77
+ * - "retry after 60 seconds"
78
+ * - "try again in 2 minutes"
79
+ * - "wait 5 min"
80
+ * - "wait 90 seconds"
81
+ * - "in 2m 30s" (rare, treat as 2 minutes)
82
+ *
83
+ * NOT matched (intentionally — too ambiguous):
84
+ * - bare numbers ("60") — could be anything
85
+ * - "wait a moment" — no quantity
86
+ */
87
+ export function parseRetryAfterMs(message) {
88
+ if (!message)
89
+ return undefined;
90
+ // "Retry-After: <N> <unit>" — HTTP header style with EXPLICIT unit.
91
+ // Per RFC 7231, default unit is seconds; we also accept explicit unit
92
+ // suffix (some providers add one for clarity, e.g. "Retry-After: 5 minutes").
93
+ // Must NOT match HTTP-date forms ("Retry-After: Wed, 21 Oct ...").
94
+ const headerWithUnitMatch = message.match(/retry-?after[:\s]+(\d+)\s*(seconds?|secs?|s\b|minutes?|mins?|m\b|hours?|hrs?|h\b)/i);
95
+ if (headerWithUnitMatch) {
96
+ const n = Number(headerWithUnitMatch[1]);
97
+ const unit = headerWithUnitMatch[2].toLowerCase();
98
+ if (Number.isFinite(n) && n > 0)
99
+ return convertToMs(n, unit);
100
+ }
101
+ // "Retry-After: 60" — bare header with no unit, defaults to seconds per RFC.
102
+ // Negative lookahead rejects HTTP-date forms (alphabetic chars after the digits).
103
+ const headerBareMatch = message.match(/retry-?after[:\s]+(\d+)(?!\s*[A-Za-z])/i);
104
+ if (headerBareMatch) {
105
+ const sec = Number(headerBareMatch[1]);
106
+ if (Number.isFinite(sec) && sec > 0)
107
+ return sec * 1000;
108
+ }
109
+ // Order matters: try LARGER units first so compound durations like
110
+ // "2m 30s" or "1h 45m" don't get misread as the smaller unit. Without
111
+ // this, "try again in 2m 30s" would match the seconds pattern on "30s"
112
+ // and return 30 seconds instead of ~2 minutes.
113
+ //
114
+ // Each pattern accepts a fractional value (e.g. OpenAI's "try again in 1.23s")
115
+ // — bare-int regex would have matched "23s" and missed the "1." prefix,
116
+ // returning 23 seconds instead of ~1.23 seconds. Capture group is "\d+(?:\.\d+)?".
117
+ // "in 2 hours" / "try again in 1 hour" — common for daily / weekly limits
118
+ const naturalHoursMatch = message.match(/(?:retry|try again|in|wait|reset).*?(\d+(?:\.\d+)?)\s*hours?\b/i);
119
+ if (naturalHoursMatch) {
120
+ const hr = Number(naturalHoursMatch[1]);
121
+ if (Number.isFinite(hr) && hr > 0)
122
+ return Math.round(hr * 60 * 60 * 1000);
123
+ }
124
+ // "retry after 2 minutes" / "try again in 5 min" / "wait 5 min" / "in 2m 30s"
125
+ const naturalMinutesMatch = message.match(/(?:retry|try again|wait).*?(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|m\b)/i);
126
+ if (naturalMinutesMatch) {
127
+ const min = Number(naturalMinutesMatch[1]);
128
+ if (Number.isFinite(min) && min > 0)
129
+ return Math.round(min * 60 * 1000);
130
+ }
131
+ // "retry after 60s" / "try again in 60s" / "wait 60s" / "try again in 1.23s"
132
+ // (OpenAI returns fractional seconds for sub-2-second rate-limit windows).
133
+ // Last because compound durations like "2m 30s" should be read as minutes.
134
+ const naturalSecondsMatch = message.match(/(?:retry|try again|wait).*?(\d+(?:\.\d+)?)\s*(?:seconds?|secs?|s\b)/i);
135
+ if (naturalSecondsMatch) {
136
+ const sec = Number(naturalSecondsMatch[1]);
137
+ if (Number.isFinite(sec) && sec > 0)
138
+ return Math.round(sec * 1000);
139
+ }
140
+ return undefined;
141
+ }
142
+ /** Convert a number + unit string to milliseconds. Unit is one of the regex
143
+ * alternatives in the header-with-unit pattern above. */
144
+ function convertToMs(n, unit) {
145
+ if (/^h/i.test(unit))
146
+ return n * 60 * 60 * 1000;
147
+ if (/^m/i.test(unit))
148
+ return n * 60 * 1000;
149
+ return n * 1000; // seconds (default)
150
+ }
151
+ //# sourceMappingURL=failover-classifier.js.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Failover config loader — v0.6.x rate-limit fail-over wedge (Slice C).
3
+ *
4
+ * Reads `failoverChain` from `~/.codehere/config.json` and validates it
5
+ * against the known agent ids. Returns the validated chain or undefined
6
+ * if the user has not configured one (caller falls back to the default).
7
+ *
8
+ * Config shape (~/.codehere/config.json):
9
+ * {
10
+ * "version": "0.6.0",
11
+ * "failover": {
12
+ * "chain": ["codex", "claude-code", "aider"]
13
+ * }
14
+ * }
15
+ *
16
+ * If `failover.chain` is missing, empty, or invalid, returns undefined
17
+ * so the caller uses DEFAULT_FAILOVER_CHAIN from failover-policy.ts.
18
+ *
19
+ * Slice C scope: config read + validation. Slice D wires it into
20
+ * SessionManager so the resolved chain flows into the resolver.
21
+ */
22
+ import { readFileSync, existsSync } from 'fs';
23
+ import { homedir } from 'os';
24
+ import { join } from 'path';
25
+ import { validateFailoverChain } from './failover-policy.js';
26
+ const GLOBAL_CONFIG_PATH = join(homedir(), '.codehere', 'config.json');
27
+ /**
28
+ * Load failover configuration from ~/.codehere/config.json.
29
+ * Returns undefined if the config file does not exist or has no failover section.
30
+ *
31
+ * Safe to call at any time — no side effects, no caching, no I/O errors
32
+ * propagated (returns undefined on any read failure).
33
+ */
34
+ export function loadFailoverConfig() {
35
+ if (!existsSync(GLOBAL_CONFIG_PATH))
36
+ return undefined;
37
+ try {
38
+ const raw = JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, 'utf-8'));
39
+ if (!raw || typeof raw !== 'object')
40
+ return undefined;
41
+ const failover = raw.failover;
42
+ if (!failover || typeof failover !== 'object')
43
+ return undefined;
44
+ const config = {};
45
+ // Validate chain if present
46
+ if (Array.isArray(failover.chain)) {
47
+ try {
48
+ config.chain = validateFailoverChain(failover.chain);
49
+ }
50
+ catch {
51
+ // Invalid chain — skip it, caller uses default
52
+ config.chain = undefined;
53
+ }
54
+ }
55
+ // autoFailover — boolean, defaults to false
56
+ if (typeof failover.autoFailover === 'boolean') {
57
+ config.autoFailover = failover.autoFailover;
58
+ }
59
+ return config;
60
+ }
61
+ catch {
62
+ // Config file exists but is malformed — return undefined so caller
63
+ // falls back to defaults without crashing.
64
+ return undefined;
65
+ }
66
+ }
67
+ /**
68
+ * Get the resolved failover chain: user-configured if present, else default.
69
+ * Pure function — no I/O. Caller loads config and passes it in.
70
+ */
71
+ export function resolveChain(userChain) {
72
+ if (userChain && userChain.length > 0)
73
+ return userChain;
74
+ // Re-export the default from failover-policy
75
+ return ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
76
+ }
77
+ //# sourceMappingURL=failover-config.js.map
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Failover policy — v0.6.x rate-limit fail-over wedge (Slice B).
3
+ *
4
+ * Given: an adapter that hit a fail-over-eligible error (per Slice A's
5
+ * `classifyFailoverEligibility`), a user-configured preference chain, and
6
+ * the set of currently-available adapters on this machine.
7
+ * Returns: the next adapter to try, or null if no fail-over target is
8
+ * available.
9
+ *
10
+ * Pure function — no I/O, no spawning. Caller (session-manager, Slice D)
11
+ * uses the resolver output to drive the user-consent flow (Slice C) and
12
+ * the mid-task hand-off.
13
+ *
14
+ * Default chain rationale:
15
+ * claude-code → codex → aider → opencode → gemini
16
+ *
17
+ * The default is ordered by capability+reliability for general coding work
18
+ * as of May 2026:
19
+ * - claude-code: strongest at multi-file reasoning + tool use
20
+ * - codex: strongest at independent review + adversarial mode
21
+ * - aider: most reliable for surgical single-file edits
22
+ * - opencode: cross-vendor flexibility within itself
23
+ * - gemini: longest context window, but free-tier rate limits hit fast
24
+ *
25
+ * Users can override via `~/.codehere/config.json` `failoverChain` array.
26
+ * Per-task override also supported via SpawnOptions.failoverChain (Slice D
27
+ * wires this).
28
+ *
29
+ * Slice B scope (this commit): pure resolver + config schema + tests.
30
+ * Slices C-F wire user consent, mid-task hand-off, UI surfacing, e2e.
31
+ */
32
+ /** Adapter ids in the order codehere supports. Keep in sync with adapter
33
+ * registry. Used as the strict ordering for chain validation. */
34
+ export const KNOWN_AGENT_IDS = ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
35
+ /** Default fail-over chain. See module docblock for ordering rationale.
36
+ * Each entry is the agent codehere will TRY in order when the previous
37
+ * one is unavailable or has already been tried this hand-off. */
38
+ export const DEFAULT_FAILOVER_CHAIN = ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
39
+ /**
40
+ * Resolve the next fail-over target. Returns the adapter id to try, or
41
+ * null if no eligible target exists.
42
+ *
43
+ * Selection rules (in order):
44
+ * 1. Use preferredChain if provided, else DEFAULT_FAILOVER_CHAIN
45
+ * 2. Skip the failed agent itself
46
+ * 3. Skip any adapter in alreadyTried
47
+ * 4. Skip adapters not present in available[] OR with ready=false
48
+ * 5. Return the FIRST remaining adapter in chain order
49
+ * 6. If nothing remains, return null
50
+ *
51
+ * Pure — no spawn, no config read. Caller assembles the inputs.
52
+ */
53
+ export function resolveFailoverTarget(opts) {
54
+ const chain = opts.preferredChain && opts.preferredChain.length > 0 ? opts.preferredChain : DEFAULT_FAILOVER_CHAIN;
55
+ const tried = new Set([opts.failedAgent, ...(opts.alreadyTried ?? [])]);
56
+ const readyById = new Map();
57
+ for (const a of opts.available)
58
+ readyById.set(a.id, a.ready);
59
+ for (const candidate of chain) {
60
+ if (tried.has(candidate))
61
+ continue;
62
+ if (readyById.get(candidate) !== true)
63
+ continue;
64
+ return candidate;
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * Validate a user-supplied chain (e.g. read from config.json). Returns the
70
+ * chain if every entry is a known agent id; throws otherwise. Caller
71
+ * (config-loader, Slice C) uses this to give a helpful error if the user
72
+ * mistypes an adapter name.
73
+ */
74
+ export function validateFailoverChain(chain) {
75
+ if (!Array.isArray(chain)) {
76
+ throw new Error('failoverChain must be an array of agent ids');
77
+ }
78
+ const validated = [];
79
+ for (const item of chain) {
80
+ if (typeof item !== 'string') {
81
+ throw new Error(`failoverChain entries must be strings, got ${typeof item}`);
82
+ }
83
+ if (!isKnownAgentId(item)) {
84
+ throw new Error(`Unknown agent id in failoverChain: "${item}". Valid ids: ${KNOWN_AGENT_IDS.join(', ')}`);
85
+ }
86
+ if (validated.includes(item)) {
87
+ throw new Error(`Duplicate agent id in failoverChain: "${item}"`);
88
+ }
89
+ validated.push(item);
90
+ }
91
+ return validated;
92
+ }
93
+ function isKnownAgentId(id) {
94
+ return KNOWN_AGENT_IDS.includes(id);
95
+ }
96
+ /**
97
+ * Helper: convert an array of AgentAdapter (typically from an adapter
98
+ * registry) to the AdapterAvailability shape this module expects, given
99
+ * a readiness probe function. Used by callers who already have the
100
+ * adapter instances handy. Caller passes a probe like
101
+ * `adapter.checkReadiness().state === 'ready'`.
102
+ */
103
+ export function toAvailability(adapters, isReady) {
104
+ return adapters.map((a) => ({ id: a.id, ready: isReady(a) }));
105
+ }
106
+ //# sourceMappingURL=failover-policy.js.map
@@ -28,7 +28,9 @@
28
28
  * fingerprints. Finalizes on `close` (not `exit`) so trailing stdout flushes.
29
29
  * Kills the process group (negative pid) so subagents under gemini exit too.
30
30
  */
31
- import { spawn, execFileSync } from 'child_process';
31
+ import { spawn, execFile, execFileSync } from 'child_process';
32
+ import { promisify } from 'util';
33
+ const execFileAsync = promisify(execFile);
32
34
  import { EventEmitter } from 'events';
33
35
  import { randomUUID } from 'crypto';
34
36
  import { existsSync } from 'fs';
@@ -36,6 +38,7 @@ import { homedir } from 'os';
36
38
  import { join } from 'path';
37
39
  import { ProgressAggregator } from './progress-aggregator.js';
38
40
  import { applyMemoryPrefix } from './memory-injector.js';
41
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
39
42
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
40
43
  // `--approval-mode=yolo` auto-accepts every tool action without prompting (the
41
44
  // older `--yolo` flag is deprecated). codehere's safety hook is the external
@@ -87,6 +90,41 @@ function scanForKnownError(text) {
87
90
  }
88
91
  return null;
89
92
  }
93
+ /**
94
+ * Parse the plain-text output of `gemini --list-sessions` and return the
95
+ * 1-based index for a given session UUID, or null if not found.
96
+ *
97
+ * Format (verified against gemini-cli source — packages/cli/src/utils/sessions.ts):
98
+ * Available sessions for this project (3):
99
+ *
100
+ * 1. Some Title (5 minutes ago) [a1b2c3d4-...]
101
+ * 2. Other Title (1 hour ago, current) [e5f6g7h8-...]
102
+ * 3. Third One (yesterday) [9i0j1k2l-...]
103
+ *
104
+ * Indices are 1-based and stable within one run (sort-by-startTime); the UUID
105
+ * is the durable handle. We exported as a pure function so tests don't need
106
+ * the real gemini binary or a real session store.
107
+ */
108
+ export function parseGeminiSessionIndexForUuid(listOutput, uuid) {
109
+ if (!uuid)
110
+ return null;
111
+ // Match ` N. <title> ... [<uuid>]` — UUID is always the last bracketed
112
+ // segment on the line. Title can contain anything (including bracketed
113
+ // text), so we match `[uuid]` at end-of-line, not the first `[...]`.
114
+ const lineRegex = /^\s*(\d+)\.\s.*\[([0-9a-fA-F-]{8,})\]\s*$/;
115
+ for (const raw of listOutput.split('\n')) {
116
+ const line = raw.replace(/\r$/, '');
117
+ const m = line.match(lineRegex);
118
+ if (!m)
119
+ continue;
120
+ const idx = Number.parseInt(m[1], 10);
121
+ if (!Number.isFinite(idx) || idx <= 0)
122
+ continue;
123
+ if (m[2] === uuid)
124
+ return idx;
125
+ }
126
+ return null;
127
+ }
90
128
  export function spawnGemini(prompt, options = {}) {
91
129
  const sessionId = randomUUID();
92
130
  const startedAt = Date.now();
@@ -137,6 +175,27 @@ export function spawnGemini(prompt, options = {}) {
137
175
  if (typeof t === 'string')
138
176
  textAccum += t + '\n';
139
177
  }
178
+ // #110 slice 4 — mirror gemini tool calls to the audit log. Same
179
+ // shape Codex uses (post-hoc, no block). gemini emits tool_use
180
+ // BEFORE the call lands; mirror at that point so the audit row
181
+ // appears around when the action happens.
182
+ if (event.type === 'tool_use') {
183
+ const data = event.data;
184
+ const toolName = String(data?.tool_name ?? 'tool');
185
+ const params = data?.parameters ?? {};
186
+ const input = (typeof params.command === 'string' && params.command) ||
187
+ (typeof params.file_path === 'string' && params.file_path) ||
188
+ (typeof params.path === 'string' && params.path) ||
189
+ (typeof params.query === 'string' && params.query) ||
190
+ JSON.stringify(params).slice(0, 500);
191
+ mirrorToolCallToAudit({
192
+ sessionId,
193
+ agent: 'gemini',
194
+ toolName,
195
+ toolInput: String(input),
196
+ cwd,
197
+ });
198
+ }
140
199
  // Token usage comes ONLY in the final `result` event with status:'success',
141
200
  // shape { stats: { input_tokens, output_tokens, total_tokens, cached } }.
142
201
  if (event.type === 'result') {
@@ -234,6 +293,11 @@ export function spawnGemini(prompt, options = {}) {
234
293
  };
235
294
  }
236
295
  let stdoutBuf = '';
296
+ // Captured from the gemini-cli `init` event so appendMessage can resolve
297
+ // it to a session-list index (gemini's --resume only takes index or
298
+ // 'latest', NOT the UUID itself). See parseGeminiSessionIndexForUuid +
299
+ // the appendMessage block below.
300
+ let geminiSessionUuid = null;
237
301
  child.stdout?.on('data', (chunk) => {
238
302
  stdoutBuf += chunk.toString('utf-8');
239
303
  let idx;
@@ -244,6 +308,9 @@ export function spawnGemini(prompt, options = {}) {
244
308
  continue;
245
309
  try {
246
310
  const data = JSON.parse(line);
311
+ if (data.type === 'init' && typeof data.session_id === 'string') {
312
+ geminiSessionUuid = data.session_id;
313
+ }
247
314
  emit({ type: data.type ?? 'gemini_event', timestamp: Date.now(), data });
248
315
  }
249
316
  catch {
@@ -356,24 +423,58 @@ export function spawnGemini(prompt, options = {}) {
356
423
  },
357
424
  canAppendMessage: true,
358
425
  /**
359
- * v0.6.0 — Slice 2b of REPL persistence. Gemini's --resume flag accepts
360
- * either a numeric INDEX (per-cwd ordinal of past sessions) or the literal
361
- * string "latest" — NOT the session_id UUID that the init JSONL event
362
- * emits. UUID→index mapping requires --list-sessions which has its own
363
- * race condition (indices shift as sessions are added/deleted).
426
+ * v0.6.x — UUID→index resolution (replaces the prior --resume latest
427
+ * shortcut). Gemini's --resume flag accepts a numeric INDEX (per-cwd
428
+ * ordinal of past sessions) or the literal 'latest' — NOT the session_id
429
+ * UUID that the init JSONL event emits.
364
430
  *
365
- * For the v0.6 cut we use --resume latest. Limitation: if the user spawns
366
- * another gemini session in this cwd between the original spawn and this
367
- * follow-up, the follow-up routes to the wrong session. Same caveat as
368
- * opencode's --continue. Acceptable for the typical reply-within-minutes
369
- * flow; the rigorous UUID→index helper is queued for v0.6.x.
431
+ * Strategy: capture session_id on spawn (see geminiSessionUuid above) →
432
+ * here, run `gemini --list-sessions` (plain text, no --json flag), parse
433
+ * each line for `[uuid]` at end of line, find the index that matches our
434
+ * captured UUID, and use it in --resume <N>. If anything fails (binary
435
+ * missing, list times out, UUID not found), fall back to --resume latest
436
+ * — same posture as before for safety, but with a chance to nail the
437
+ * exact session in the common case.
438
+ *
439
+ * Concurrency win: if a sibling session is spawned in the same cwd between
440
+ * spawn and reply, we no longer route the reply to the wrong one.
370
441
  */
371
- appendMessage(text) {
442
+ async appendMessage(text) {
372
443
  const startedAt = Date.now();
444
+ // Resolve UUID→index ASYNCHRONOUSLY before spawning the followup. Must
445
+ // be async (not execFileSync) because this runs in the daemon's HTTP
446
+ // request path; a sync call would block the event loop and freeze every
447
+ // other in-flight session for up to the timeout.
448
+ let resumeArg = 'latest';
449
+ if (geminiSessionUuid) {
450
+ try {
451
+ // gemini-cli's --list-sessions runs an internal generateSummary()
452
+ // first, which itself has a 5000ms LLM-call abort timeout (see
453
+ // sessionSummaryService.ts DEFAULT_TIMEOUT_MS in upstream). Our
454
+ // wrapper timeout MUST be comfortably greater than that or we kill
455
+ // the child mid-summary, get empty stdout, and silently fall back
456
+ // to --resume latest — which would defeat the whole UUID→index fix.
457
+ // 15s = 5s upstream LLM cap + parse/flush/spawn overhead + slack.
458
+ // Override via CODEHERE_GEMINI_LIST_TIMEOUT_MS for slow boxes.
459
+ const timeoutMs = Number(process.env.CODEHERE_GEMINI_LIST_TIMEOUT_MS) || 15000;
460
+ const { stdout: listOut } = await execFileAsync('gemini', ['--list-sessions'], {
461
+ cwd,
462
+ env: spawnEnv,
463
+ encoding: 'utf-8',
464
+ timeout: timeoutMs,
465
+ });
466
+ const idx = parseGeminiSessionIndexForUuid(listOut, geminiSessionUuid);
467
+ if (idx !== null)
468
+ resumeArg = String(idx);
469
+ }
470
+ catch {
471
+ // Binary missing, slow auth, no sessions yet — fall through to latest.
472
+ }
473
+ }
373
474
  return new Promise((resolve, reject) => {
374
475
  let stdout = '';
375
476
  let stderr = '';
376
- const followup = spawn('gemini', ['-p', text, '--resume', 'latest', ...HEADLESS_FLAGS], {
477
+ const followup = spawn('gemini', ['-p', text, '--resume', resumeArg, ...HEADLESS_FLAGS], {
377
478
  cwd,
378
479
  env: spawnEnv,
379
480
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -387,7 +488,7 @@ export function spawnGemini(prompt, options = {}) {
387
488
  });
388
489
  followup.on('exit', (code) => {
389
490
  if (code !== 0) {
390
- reject(new Error(`gemini --resume latest exited ${code}: ${stderr.slice(0, 300)}`));
491
+ reject(new Error(`gemini --resume ${resumeArg} exited ${code}: ${stderr.slice(0, 300)}`));
391
492
  return;
392
493
  }
393
494
  // Parse stream-json — extract assistant message text from 'message'
@@ -417,6 +518,7 @@ export function spawnGemini(prompt, options = {}) {
417
518
  export const GeminiAdapter = {
418
519
  id: 'gemini',
419
520
  label: 'Gemini',
521
+ description: 'Google\'s coding agent. Has a free tier and can read very large amounts of code or docs at once. Useful when the paid agents are out of credits.',
420
522
  // Match `gemini`, `node /path/.../gemini`, and `./gemini` invocations.
421
523
  processPattern: /(?:^|\/)gemini(?:\s|$)|node\s+\S*gemini/,
422
524
  isInstalled() {