codehere 0.3.4 → 0.5.0-alpha.2

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.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Gemini adapter — F2 fifth concrete AgentAdapter (v0.4 sprint Track 1a).
3
+ *
4
+ * Wraps the official Google `gemini` CLI (`@google/gemini-cli`) as a subprocess.
5
+ * Headless mode: `gemini -p "<prompt>" -o stream-json --approval-mode=yolo`.
6
+ *
7
+ * Event schema verified against the installed gemini-cli source
8
+ * (StreamJsonFormatter / JsonStreamEventType in @google/gemini-cli bundle):
9
+ * init — { session_id, model, timestamp }
10
+ * message — { role: "user"|"assistant", content, delta?: bool }
11
+ * tool_use — { tool_name, tool_id, parameters }
12
+ * tool_result — tool execution output (best-effort parse — schema sparse)
13
+ * error — { error: {...} }
14
+ * result — { status: "success"|"error", stats?, error?: { type, message } }
15
+ *
16
+ * Token usage from result.stats: { total_tokens, input_tokens, output_tokens, cached }.
17
+ *
18
+ * Sandbox: Gemini's --approval-mode=yolo enables sandboxing by default. codehere is
19
+ * the external safety layer — its PreToolUse hook (F0) is where destructive-command
20
+ * scanning happens. We DISABLE Gemini's sandbox via GEMINI_SANDBOX=false so file
21
+ * writes / shell commands aren't blocked at the wrong layer. (Note: today's hook
22
+ * still only wraps Claude Code; for Gemini, codehere observes via session events
23
+ * and audits at task-completion. Per-tool blocking parity for non-Claude adapters
24
+ * is v0.4+ work — see project_v04_sprint.md Track 3.)
25
+ *
26
+ * Same structural pattern as ClaudeCodeAdapter / CodexAdapter / AiderAdapter:
27
+ * event buffer, late-joiner replay, SIGTERM→SIGKILL escalation, known-error
28
+ * fingerprints. Finalizes on `close` (not `exit`) so trailing stdout flushes.
29
+ * Kills the process group (negative pid) so subagents under gemini exit too.
30
+ */
31
+ import { spawn, execFileSync } from 'child_process';
32
+ import { EventEmitter } from 'events';
33
+ import { randomUUID } from 'crypto';
34
+ import { existsSync } from 'fs';
35
+ import { homedir } from 'os';
36
+ import { join } from 'path';
37
+ import { ProgressAggregator } from './progress-aggregator.js';
38
+ import { applyMemoryPrefix } from './memory-injector.js';
39
+ import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
40
+ // `--approval-mode=yolo` auto-accepts every tool action without prompting (the
41
+ // older `--yolo` flag is deprecated). codehere's safety hook is the external
42
+ // gate, so gemini-side prompts would block headless tasks.
43
+ //
44
+ // `-o stream-json` — JSONL events, one per line, schema verified above.
45
+ //
46
+ // `--skip-trust` — skip the per-workspace trust prompt that gemini shows on
47
+ // first run in a new directory.
48
+ const HEADLESS_FLAGS = [
49
+ '--approval-mode',
50
+ 'yolo',
51
+ '--skip-trust',
52
+ '-o',
53
+ 'stream-json',
54
+ ];
55
+ // Error patterns scoped to STDERR / result.error / error events only — NOT
56
+ // every JSON-serialized event (which would false-positive on tool output).
57
+ const ERROR_PATTERNS = [
58
+ {
59
+ code: 'auth_missing',
60
+ regex: /please set an auth method|GEMINI_API_KEY|GOOGLE_API_KEY|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA|GOOGLE_APPLICATION_CREDENTIALS/i,
61
+ message: 'Gemini auth missing.',
62
+ recovery: 'Set GEMINI_API_KEY env var (free tier at https://aistudio.google.com/apikey) — or configure Vertex AI / Application Default Credentials per https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html',
63
+ },
64
+ {
65
+ code: 'auth_invalid',
66
+ regex: /authentication\s+failed|401\s*unauth|invalid\s+api\s+key|api\s+key\s+not\s+valid/i,
67
+ message: 'Gemini API key is rejected.',
68
+ recovery: 'Verify GEMINI_API_KEY at https://aistudio.google.com/apikey and re-export.',
69
+ },
70
+ {
71
+ code: 'rate_limit',
72
+ regex: /rate.?limit(ed)?|HTTP\s*429|too many requests/i,
73
+ message: 'Gemini rate-limited the request.',
74
+ recovery: 'Wait a minute and retry, or check your Google AI Studio quota.',
75
+ },
76
+ {
77
+ code: 'quota_exceeded',
78
+ regex: /\bRESOURCE_EXHAUSTED\b|quota[\s_]exceeded|insufficient[_\s]quota/i,
79
+ message: 'Google AI Studio / Vertex quota exhausted.',
80
+ recovery: 'Check quota at https://aistudio.google.com/ or top up your Google Cloud billing account.',
81
+ },
82
+ ];
83
+ function scanForKnownError(text) {
84
+ for (const p of ERROR_PATTERNS) {
85
+ if (p.regex.test(text))
86
+ return { code: p.code, message: p.message, recovery: p.recovery };
87
+ }
88
+ return null;
89
+ }
90
+ export function spawnGemini(prompt, options = {}) {
91
+ const sessionId = randomUUID();
92
+ const startedAt = Date.now();
93
+ const emitter = new EventEmitter();
94
+ const buffer = [];
95
+ let statusValue = 'running';
96
+ let killRequested = false;
97
+ const cwd = options.cwd ?? process.cwd();
98
+ // F3 Task 8 — memory injection (same shape as every other adapter).
99
+ const injected = applyMemoryPrefix(prompt, cwd, options);
100
+ const finalPrompt = injected.prompt;
101
+ const snapshot = snapshotCwd(cwd);
102
+ const aggregator = new ProgressAggregator('gemini');
103
+ let stepsSnapshot = [];
104
+ let artifactsSnapshot;
105
+ let textAccum = '';
106
+ let usageSnapshot;
107
+ let errorEmitted = false;
108
+ const emit = (event) => {
109
+ buffer.push(event);
110
+ emitter.emit('event', event);
111
+ if (aggregator.ingest(event)) {
112
+ stepsSnapshot = aggregator.steps.map((s) => ({ ...s }));
113
+ const snapEvt = {
114
+ type: 'codehere:steps',
115
+ timestamp: Date.now(),
116
+ data: { steps: stepsSnapshot },
117
+ };
118
+ buffer.push(snapEvt);
119
+ emitter.emit('event', snapEvt);
120
+ }
121
+ // Accumulate assistant text for the test-summary parser. Real schema:
122
+ // { type: 'message', role: 'assistant', content: '...', delta?: bool }.
123
+ // Both deltas (streaming chunks) and full messages contribute.
124
+ if (event.type === 'message') {
125
+ const data = event.data;
126
+ if (data?.role === 'assistant' && typeof data.content === 'string') {
127
+ textAccum += data.content;
128
+ // Only add a newline at non-delta boundaries to avoid breaking up streamed words
129
+ if (!event.data?.delta)
130
+ textAccum += '\n';
131
+ }
132
+ }
133
+ // tool_result event — schema sparse in source; accept common output fields.
134
+ if (event.type === 'tool_result') {
135
+ const data = event.data;
136
+ const t = data?.output ?? data?.result ?? data?.content;
137
+ if (typeof t === 'string')
138
+ textAccum += t + '\n';
139
+ }
140
+ // Token usage comes ONLY in the final `result` event with status:'success',
141
+ // shape { stats: { input_tokens, output_tokens, total_tokens, cached } }.
142
+ if (event.type === 'result') {
143
+ const data = event.data;
144
+ if (data?.status === 'success' && data.stats) {
145
+ const s = data.stats;
146
+ usageSnapshot = {
147
+ inputTokens: Number(s.input_tokens ?? 0),
148
+ outputTokens: Number(s.output_tokens ?? 0),
149
+ ...(s.cached ? { cacheReadTokens: Number(s.cached) } : {}),
150
+ };
151
+ }
152
+ }
153
+ // Scoped error matching: ONLY look at stderr lines and the FAILURE form of
154
+ // the `result` event (status:"error"). DO NOT scan `error` stream events
155
+ // because gemini's headless docs explicitly classify them as "Non-fatal
156
+ // warnings and system errors" — e.g. loop-detection warnings emit `error`
157
+ // events with severity:"warning". The terminal failure signal is the final
158
+ // `result` event with status:"error". Treating non-fatal `error` events as
159
+ // a terminal codehere:error would falsely mark successful sessions as failed.
160
+ if (!errorEmitted) {
161
+ let text = '';
162
+ const data = event.data;
163
+ if (event.type === 'stderr' && data && typeof data.line === 'string') {
164
+ text = data.line;
165
+ }
166
+ else if (event.type === 'result') {
167
+ const r = data;
168
+ if (r?.status === 'error' && r.error) {
169
+ text = `${r.error.type ?? ''} ${r.error.message ?? ''}`;
170
+ }
171
+ }
172
+ if (text) {
173
+ const hit = scanForKnownError(text);
174
+ if (hit) {
175
+ errorEmitted = true;
176
+ const errEvent = {
177
+ type: 'codehere:error',
178
+ timestamp: Date.now(),
179
+ data: { code: hit.code, message: hit.message, recovery: hit.recovery },
180
+ };
181
+ buffer.push(errEvent);
182
+ emitter.emit('event', errEvent);
183
+ }
184
+ }
185
+ }
186
+ };
187
+ // Disable Gemini's built-in sandbox — codehere is the external safety layer.
188
+ // GEMINI_SANDBOX=false trumps any sandbox-on default that --approval-mode=yolo
189
+ // would otherwise enable. (Settings precedence: env > settings.json > default.)
190
+ const spawnEnv = {
191
+ ...process.env,
192
+ GEMINI_SANDBOX: 'false',
193
+ ...(options.env ?? {}),
194
+ };
195
+ let child;
196
+ try {
197
+ child = spawn('gemini', ['-p', finalPrompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], {
198
+ cwd,
199
+ env: spawnEnv,
200
+ stdio: ['ignore', 'pipe', 'pipe'],
201
+ // Detach into its own process group so kill() can reap subagents.
202
+ detached: true,
203
+ });
204
+ // Don't keep the parent alive on this child alone.
205
+ child.unref();
206
+ }
207
+ catch (err) {
208
+ statusValue = 'error';
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ queueMicrotask(() => emit({ type: 'error', timestamp: Date.now(), data: { message: msg } }));
211
+ return {
212
+ sessionId,
213
+ startedAt,
214
+ prompt,
215
+ agent: 'gemini',
216
+ get status() {
217
+ return statusValue;
218
+ },
219
+ get steps() {
220
+ return stepsSnapshot;
221
+ },
222
+ get artifacts() {
223
+ return artifactsSnapshot;
224
+ },
225
+ subscribe(handler) {
226
+ for (const e of buffer)
227
+ handler(e);
228
+ emitter.on('event', handler);
229
+ return () => emitter.off('event', handler);
230
+ },
231
+ kill() {
232
+ /* already not running */
233
+ },
234
+ };
235
+ }
236
+ let stdoutBuf = '';
237
+ child.stdout?.on('data', (chunk) => {
238
+ stdoutBuf += chunk.toString('utf-8');
239
+ let idx;
240
+ while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
241
+ const line = stdoutBuf.slice(0, idx).trim();
242
+ stdoutBuf = stdoutBuf.slice(idx + 1);
243
+ if (!line)
244
+ continue;
245
+ try {
246
+ const data = JSON.parse(line);
247
+ emit({ type: data.type ?? 'gemini_event', timestamp: Date.now(), data });
248
+ }
249
+ catch {
250
+ emit({ type: 'stdout', timestamp: Date.now(), data: { line } });
251
+ }
252
+ }
253
+ });
254
+ child.stderr?.on('data', (chunk) => {
255
+ const text = chunk.toString('utf-8');
256
+ for (const line of text.split('\n')) {
257
+ if (line.trim())
258
+ emit({ type: 'stderr', timestamp: Date.now(), data: { line } });
259
+ }
260
+ });
261
+ // Use `close` (after all stdio has drained), not `exit` (which can fire while
262
+ // unread stdout is still in-flight). Flush any final un-newlined buffered line.
263
+ child.on('close', (code, signal) => {
264
+ if (stdoutBuf.trim()) {
265
+ const line = stdoutBuf.trim();
266
+ stdoutBuf = '';
267
+ try {
268
+ const data = JSON.parse(line);
269
+ emit({ type: data.type ?? 'gemini_event', timestamp: Date.now(), data });
270
+ }
271
+ catch {
272
+ emit({ type: 'stdout', timestamp: Date.now(), data: { line } });
273
+ }
274
+ }
275
+ statusValue =
276
+ killRequested || signal !== null
277
+ ? 'killed'
278
+ : errorEmitted
279
+ ? 'error'
280
+ : code === 0
281
+ ? 'completed'
282
+ : 'error';
283
+ if (aggregator.finalize(statusValue === 'completed' ? 'completed' : statusValue === 'killed' ? 'killed' : 'error')) {
284
+ stepsSnapshot = aggregator.steps.map((s) => ({ ...s }));
285
+ emit({ type: 'codehere:steps', timestamp: Date.now(), data: { steps: stepsSnapshot } });
286
+ }
287
+ if (statusValue === 'completed') {
288
+ try {
289
+ artifactsSnapshot = { ...extractArtifacts(snapshot, textAccum), usage: usageSnapshot };
290
+ emit({ type: 'codehere:artifacts', timestamp: Date.now(), data: artifactsSnapshot });
291
+ }
292
+ catch {
293
+ /* best-effort */
294
+ }
295
+ }
296
+ emit({
297
+ type: 'exit',
298
+ timestamp: Date.now(),
299
+ data: { code, signal, status: statusValue, durationMs: Date.now() - startedAt },
300
+ });
301
+ });
302
+ child.on('error', (err) => {
303
+ statusValue = 'error';
304
+ emit({ type: 'error', timestamp: Date.now(), data: { message: err.message } });
305
+ });
306
+ return {
307
+ sessionId,
308
+ startedAt,
309
+ prompt,
310
+ agent: 'gemini',
311
+ memoryInjection: injected.record,
312
+ get status() {
313
+ return statusValue;
314
+ },
315
+ get steps() {
316
+ return stepsSnapshot;
317
+ },
318
+ get artifacts() {
319
+ return artifactsSnapshot;
320
+ },
321
+ subscribe(handler) {
322
+ for (const e of buffer)
323
+ handler(e);
324
+ emitter.on('event', handler);
325
+ return () => emitter.off('event', handler);
326
+ },
327
+ kill() {
328
+ if (statusValue !== 'running')
329
+ return;
330
+ killRequested = true;
331
+ // Process-group kill via negative pid is POSIX-only — Node on Windows
332
+ // throws ESRCH for that. Fall back to direct child.kill on Windows.
333
+ const isPosix = process.platform !== 'win32';
334
+ const sendSignal = (sig) => {
335
+ if (isPosix && child.pid) {
336
+ try {
337
+ process.kill(-child.pid, sig);
338
+ return;
339
+ }
340
+ catch {
341
+ /* fall through to child.kill */
342
+ }
343
+ }
344
+ try {
345
+ child.kill(sig);
346
+ }
347
+ catch {
348
+ /* already dead */
349
+ }
350
+ };
351
+ sendSignal('SIGTERM');
352
+ setTimeout(() => {
353
+ if (statusValue === 'running')
354
+ sendSignal('SIGKILL');
355
+ }, 3000);
356
+ },
357
+ };
358
+ }
359
+ export const GeminiAdapter = {
360
+ id: 'gemini',
361
+ label: 'Gemini',
362
+ // Match `gemini`, `node /path/.../gemini`, and `./gemini` invocations.
363
+ processPattern: /(?:^|\/)gemini(?:\s|$)|node\s+\S*gemini/,
364
+ isInstalled() {
365
+ try {
366
+ const out = execFileSync('which', ['gemini'], { encoding: 'utf-8', timeout: 2000 });
367
+ return out.trim().length > 0;
368
+ }
369
+ catch {
370
+ return false;
371
+ }
372
+ },
373
+ spawn(prompt, options) {
374
+ return spawnGemini(prompt, options);
375
+ },
376
+ checkReadiness() {
377
+ const installed = this.isInstalled();
378
+ if (!installed) {
379
+ return {
380
+ id: this.id,
381
+ label: this.label,
382
+ state: 'needs_install',
383
+ installed: false,
384
+ installHint: {
385
+ command: 'npm install -g @google/gemini-cli',
386
+ url: 'https://github.com/google-gemini/gemini-cli',
387
+ },
388
+ };
389
+ }
390
+ // Gemini supports several auth paths:
391
+ // env: GEMINI_API_KEY (free tier), GOOGLE_API_KEY, GOOGLE_GENAI_USE_VERTEXAI,
392
+ // GOOGLE_GENAI_USE_GCA, GOOGLE_APPLICATION_CREDENTIALS
393
+ // file: ~/.gemini/settings.json (the file gemini's own error msg points at)
394
+ // file: ~/.gemini/oauth_creds.json (cached Google login)
395
+ //
396
+ // Pre-May-1 we returned 'ready' on install alone, deferring to runtime to
397
+ // catch missing-auth via ERROR_PATTERNS.auth_missing. That was dishonest:
398
+ // /api/readiness/agents would say 'ready' → user would spawn → 41s later
399
+ // the task errored with no clear next step. Surfaced May 1 2026 by the
400
+ // multi-agent dogfood test (see SECURITY_AUDIT_2026-05-01.md spirit —
401
+ // honest readiness signals are a UX security property).
402
+ const hasEnvAuth = !!process.env.GEMINI_API_KEY ||
403
+ !!process.env.GOOGLE_API_KEY ||
404
+ !!process.env.GOOGLE_GENAI_USE_VERTEXAI ||
405
+ !!process.env.GOOGLE_GENAI_USE_GCA ||
406
+ !!process.env.GOOGLE_APPLICATION_CREDENTIALS;
407
+ const hasFileAuth = existsSync(join(homedir(), '.gemini', 'settings.json')) ||
408
+ existsSync(join(homedir(), '.gemini', 'oauth_creds.json'));
409
+ if (!hasEnvAuth && !hasFileAuth) {
410
+ return {
411
+ id: this.id,
412
+ label: this.label,
413
+ state: 'needs_auth',
414
+ installed: true,
415
+ authHint: {
416
+ command: 'export GEMINI_API_KEY=... # free tier: https://aistudio.google.com/apikey',
417
+ url: 'https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html',
418
+ detail: 'Gemini found on PATH but no auth detected (no GEMINI_API_KEY env var, no ~/.gemini/settings.json, no ~/.gemini/oauth_creds.json). Free tier available at aistudio.google.com/apikey.',
419
+ },
420
+ };
421
+ }
422
+ return { id: this.id, label: this.label, state: 'ready', installed: true };
423
+ },
424
+ };
425
+ //# sourceMappingURL=gemini-adapter.js.map
@@ -37,6 +37,7 @@ import { join } from 'path';
37
37
  import { randomUUID } from 'crypto';
38
38
  import { ProgressAggregator } from './progress-aggregator.js';
39
39
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
40
+ import { applyMemoryPrefix } from './memory-injector.js';
40
41
  const CONFIG_PATH = join(homedir(), '.codehere', 'agents.yaml');
41
42
  const CONFIG_PATH_JSON = join(homedir(), '.codehere', 'agents.json');
42
43
  /** Minimal YAML parser for the narrow shape above. Intentionally NOT a full YAML
@@ -134,6 +135,12 @@ function spawnGeneric(cfg, prompt, options = {}) {
134
135
  let statusValue = 'running';
135
136
  let killRequested = false;
136
137
  const cwd = options.cwd ?? process.cwd();
138
+ // F3 Task 8 — memory injection (same shape as every other adapter).
139
+ // Apr 29: caught via the adapter-injection-wiring trip-wire — generic
140
+ // adapters were silently exempt from F3 even though Task 8 said
141
+ // "every spawned agent." Now consistent.
142
+ const injected = applyMemoryPrefix(prompt, cwd, options);
143
+ const finalPrompt = injected.prompt;
137
144
  const snapshot = snapshotCwd(cwd);
138
145
  const aggregator = new ProgressAggregator(cfg.id);
139
146
  let stepsSnapshot = [];
@@ -155,7 +162,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
155
162
  const spawnEnv = { ...process.env, ...(cfg.env ?? {}), ...(options.env ?? {}) };
156
163
  const usesStdin = cfg.prompt_via === 'stdin';
157
164
  const fixedArgs = cfg.args ?? [];
158
- const argv = usesStdin ? [...fixedArgs] : [...fixedArgs, prompt];
165
+ const argv = usesStdin ? [...fixedArgs] : [...fixedArgs, finalPrompt];
159
166
  let child;
160
167
  try {
161
168
  // SAFE · array form (not shell:true) means no shell interpretation.
@@ -172,7 +179,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
172
179
  stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
173
180
  });
174
181
  if (usesStdin) {
175
- child.stdin?.write(prompt);
182
+ child.stdin?.write(finalPrompt);
176
183
  child.stdin?.end();
177
184
  }
178
185
  }
@@ -264,6 +271,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
264
271
  startedAt,
265
272
  prompt,
266
273
  agent: cfg.id,
274
+ memoryInjection: injected.record,
267
275
  get status() {
268
276
  return statusValue;
269
277
  },