@botcord/daemon 0.2.88 → 0.2.90

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,357 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { GeminiAdapter } from "../runtimes/gemini.js";
6
+ import { geminiModule } from "../runtimes/registry.js";
7
+
8
+ // The adapter spawns whatever binary we point it at; we point it at a small
9
+ // Node script so we control stdout/stderr/exit precisely without needing the
10
+ // real `gemini` CLI.
11
+ const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-gemini-"));
12
+
13
+ const originalHome = process.env.HOME;
14
+ const agentHomeRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-gemini-home-"));
15
+
16
+ beforeAll(() => {
17
+ process.env.HOME = agentHomeRoot;
18
+ });
19
+
20
+ afterAll(() => {
21
+ if (originalHome === undefined) delete process.env.HOME;
22
+ else process.env.HOME = originalHome;
23
+ rmSync(tmpRoot, { recursive: true, force: true });
24
+ rmSync(agentHomeRoot, { recursive: true, force: true });
25
+ });
26
+
27
+ function makeScript(name: string, body: string): string {
28
+ const p = path.join(tmpRoot, name);
29
+ writeFileSync(p, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
30
+ chmodSync(p, 0o755);
31
+ return p;
32
+ }
33
+
34
+ function runAdapter(
35
+ script: string,
36
+ opts: {
37
+ sessionId?: string | null;
38
+ systemContext?: string;
39
+ extraArgs?: string[];
40
+ onBlock?: (kind: string) => void;
41
+ onStatus?: (e: { phase: string; label?: string }) => void;
42
+ } = {},
43
+ ) {
44
+ const adapter = new GeminiAdapter({ binary: script });
45
+ const ctrl = new AbortController();
46
+ return adapter.run({
47
+ text: "hi",
48
+ sessionId: opts.sessionId ?? null,
49
+ accountId: "ag_test",
50
+ cwd: tmpRoot,
51
+ signal: ctrl.signal,
52
+ trustLevel: "owner",
53
+ systemContext: opts.systemContext,
54
+ extraArgs: opts.extraArgs,
55
+ onBlock: opts.onBlock ? (b) => opts.onBlock!(b.kind) : undefined,
56
+ onStatus: opts.onStatus
57
+ ? (e) => {
58
+ if (e.kind === "thinking") opts.onStatus!({ phase: e.phase, label: e.label });
59
+ }
60
+ : undefined,
61
+ });
62
+ }
63
+
64
+ describe("GeminiAdapter", () => {
65
+ it("captures session_id from init and concatenates assistant deltas as final text", async () => {
66
+ const script = makeScript(
67
+ "happy.js",
68
+ `
69
+ const lines = [
70
+ {type:"init", timestamp:"t0", session_id:"abc-123-session", model:"gemini-2.5-pro"},
71
+ {type:"message", timestamp:"t1", role:"user", content:"hi"},
72
+ {type:"message", timestamp:"t2", role:"assistant", content:"hello ", delta:true},
73
+ {type:"message", timestamp:"t3", role:"assistant", content:"from gemini", delta:true},
74
+ {type:"result", timestamp:"t4", status:"success", stats:{}},
75
+ ];
76
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
77
+ process.exit(0);
78
+ `,
79
+ );
80
+ const res = await runAdapter(script);
81
+ expect(res.newSessionId).toBe("abc-123-session");
82
+ expect(res.text).toBe("hello from gemini");
83
+ expect(res.error).toBeUndefined();
84
+ });
85
+
86
+ it("emits StreamBlocks for assistant_text / tool_use / tool_result / system kinds", async () => {
87
+ const script = makeScript(
88
+ "blocks.js",
89
+ `
90
+ const lines = [
91
+ {type:"init", timestamp:"t0", session_id:"sess1", model:"gemini-2.5-pro"},
92
+ {type:"tool_use", timestamp:"t1", tool_name:"read_file", tool_id:"t_0", parameters:{path:"x"}},
93
+ {type:"tool_result", timestamp:"t2", tool_id:"t_0", status:"success", output:"ok"},
94
+ {type:"message", timestamp:"t3", role:"assistant", content:"done", delta:true},
95
+ {type:"result", timestamp:"t4", status:"success", stats:{}},
96
+ ];
97
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
98
+ `,
99
+ );
100
+ const seen: string[] = [];
101
+ const res = await runAdapter(script, { onBlock: (k) => seen.push(k) });
102
+ expect(res.text).toBe("done");
103
+ expect(seen).toContain("system"); // init + result
104
+ expect(seen).toContain("tool_use");
105
+ expect(seen).toContain("tool_result");
106
+ expect(seen).toContain("assistant_text");
107
+ });
108
+
109
+ it("emits thinking onStatus events for init, tool_use, assistant message, and result", async () => {
110
+ const script = makeScript(
111
+ "thinkflow.js",
112
+ `
113
+ const lines = [
114
+ {type:"init", timestamp:"t0", session_id:"sess2"},
115
+ {type:"tool_use", timestamp:"t1", tool_name:"shell", tool_id:"t_0", parameters:{}},
116
+ {type:"tool_result", timestamp:"t2", tool_id:"t_0", status:"success"},
117
+ {type:"message", timestamp:"t3", role:"assistant", content:"ok", delta:true},
118
+ {type:"result", timestamp:"t4", status:"success", stats:{}},
119
+ ];
120
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
121
+ `,
122
+ );
123
+ const status: Array<{ phase: string; label?: string }> = [];
124
+ await runAdapter(script, { onStatus: (e) => status.push(e) });
125
+ expect(status).toEqual([
126
+ { phase: "started", label: "Starting session" },
127
+ { phase: "updated", label: "shell" },
128
+ { phase: "stopped", label: undefined },
129
+ { phase: "stopped", label: undefined },
130
+ ]);
131
+ });
132
+
133
+ it("no sessionId → spawn without --resume", async () => {
134
+ const script = makeScript(
135
+ "fresh-argv.js",
136
+ `
137
+ const argv = process.argv.slice(2);
138
+ process.stdout.write(JSON.stringify({type:"init", session_id:"new-sess"}) + "\\n");
139
+ process.stdout.write(JSON.stringify({type:"message", role:"assistant", content:JSON.stringify(argv), delta:true}) + "\\n");
140
+ process.stdout.write(JSON.stringify({type:"result", status:"success", stats:{}}) + "\\n");
141
+ `,
142
+ );
143
+ const res = await runAdapter(script, { sessionId: null });
144
+ const argv = JSON.parse(res.text) as string[];
145
+ expect(argv).not.toContain("--resume");
146
+ expect(argv).toContain("-p");
147
+ expect(argv).toContain("hi");
148
+ expect(argv).toContain("--output-format");
149
+ expect(argv).toContain("stream-json");
150
+ expect(argv).toContain("--yolo");
151
+ expect(argv).toContain("--skip-trust");
152
+ });
153
+
154
+ it("with sessionId → spawn with `--resume <id>`", async () => {
155
+ const script = makeScript(
156
+ "resume-argv.js",
157
+ `
158
+ const argv = process.argv.slice(2);
159
+ process.stdout.write(JSON.stringify({type:"init", session_id:"continued"}) + "\\n");
160
+ process.stdout.write(JSON.stringify({type:"message", role:"assistant", content:JSON.stringify(argv), delta:true}) + "\\n");
161
+ process.stdout.write(JSON.stringify({type:"result", status:"success", stats:{}}) + "\\n");
162
+ `,
163
+ );
164
+ const sid = "abc-123-session";
165
+ const res = await runAdapter(script, { sessionId: sid });
166
+ const argv = JSON.parse(res.text) as string[];
167
+ expect(argv).toContain("--resume");
168
+ expect(argv[argv.indexOf("--resume") + 1]).toBe(sid);
169
+ });
170
+
171
+ it("rejects invalid sessionId before spawn", async () => {
172
+ const script = makeScript("noop.js", "process.exit(0);");
173
+ const adapter = new GeminiAdapter({ binary: script });
174
+ const ctrl = new AbortController();
175
+ await expect(
176
+ adapter.run({
177
+ text: "x",
178
+ sessionId: "../bad space",
179
+ accountId: "ag_test",
180
+ cwd: tmpRoot,
181
+ signal: ctrl.signal,
182
+ trustLevel: "owner",
183
+ }),
184
+ ).rejects.toThrow(/invalid sessionId/);
185
+ });
186
+
187
+ it("prepends systemContext to the positional prompt", async () => {
188
+ const script = makeScript(
189
+ "echo-prompt.js",
190
+ `
191
+ const argv = process.argv.slice(2);
192
+ const pIdx = argv.indexOf("-p");
193
+ const prompt = pIdx >= 0 ? argv[pIdx + 1] : "";
194
+ process.stdout.write(JSON.stringify({type:"init", session_id:"s1"}) + "\\n");
195
+ process.stdout.write(JSON.stringify({type:"message", role:"assistant", content:prompt, delta:true}) + "\\n");
196
+ process.stdout.write(JSON.stringify({type:"result", status:"success", stats:{}}) + "\\n");
197
+ `,
198
+ );
199
+ const res = await runAdapter(script, {
200
+ systemContext: "MEMORY: remember X\nDIGEST: room Y was active",
201
+ });
202
+ expect(res.text).toContain("MEMORY: remember X");
203
+ expect(res.text).toContain("DIGEST: room Y was active");
204
+ expect(res.text).toContain("---");
205
+ expect(res.text.endsWith("hi")).toBe(true);
206
+ });
207
+
208
+ it("empty systemContext leaves the prompt unchanged", async () => {
209
+ const script = makeScript(
210
+ "echo-prompt-empty.js",
211
+ `
212
+ const argv = process.argv.slice(2);
213
+ const pIdx = argv.indexOf("-p");
214
+ const prompt = pIdx >= 0 ? argv[pIdx + 1] : "";
215
+ process.stdout.write(JSON.stringify({type:"init", session_id:"s2"}) + "\\n");
216
+ process.stdout.write(JSON.stringify({type:"message", role:"assistant", content:prompt, delta:true}) + "\\n");
217
+ process.stdout.write(JSON.stringify({type:"result", status:"success", stats:{}}) + "\\n");
218
+ `,
219
+ );
220
+ const res = await runAdapter(script, { systemContext: " \n\n " });
221
+ expect(res.text).toBe("hi");
222
+ });
223
+
224
+ it("does not double-add --yolo when extraArgs already supplies --approval-mode", async () => {
225
+ const script = makeScript(
226
+ "echo-yolo.js",
227
+ `
228
+ const argv = process.argv.slice(2);
229
+ process.stdout.write(JSON.stringify({type:"init", session_id:"s3"}) + "\\n");
230
+ process.stdout.write(JSON.stringify({type:"message", role:"assistant", content:JSON.stringify(argv), delta:true}) + "\\n");
231
+ process.stdout.write(JSON.stringify({type:"result", status:"success", stats:{}}) + "\\n");
232
+ `,
233
+ );
234
+ const res = await runAdapter(script, {
235
+ extraArgs: ["--approval-mode", "plan"],
236
+ });
237
+ const argv = JSON.parse(res.text) as string[];
238
+ expect(argv).toContain("--approval-mode");
239
+ expect(argv[argv.indexOf("--approval-mode") + 1]).toBe("plan");
240
+ expect(argv).not.toContain("--yolo");
241
+ });
242
+
243
+ it("strips claude-code / codex foreign flags from extraArgs", async () => {
244
+ const script = makeScript(
245
+ "echo-strip.js",
246
+ `
247
+ const argv = process.argv.slice(2);
248
+ process.stdout.write(JSON.stringify({type:"init", session_id:"s4"}) + "\\n");
249
+ process.stdout.write(JSON.stringify({type:"message", role:"assistant", content:JSON.stringify(argv), delta:true}) + "\\n");
250
+ process.stdout.write(JSON.stringify({type:"result", status:"success", stats:{}}) + "\\n");
251
+ `,
252
+ );
253
+ const res = await runAdapter(script, {
254
+ extraArgs: [
255
+ "--append-system-prompt",
256
+ "ignored content",
257
+ "--permission-mode",
258
+ "bypassPermissions",
259
+ "--setting-sources=project",
260
+ "--dangerously-bypass-approvals-and-sandbox",
261
+ "--model",
262
+ "gemini-2.5-pro",
263
+ ],
264
+ });
265
+ const argv = JSON.parse(res.text) as string[];
266
+ // Foreign flags + their values are dropped …
267
+ expect(argv).not.toContain("--append-system-prompt");
268
+ expect(argv).not.toContain("ignored content");
269
+ expect(argv).not.toContain("--permission-mode");
270
+ expect(argv).not.toContain("bypassPermissions");
271
+ expect(argv).not.toContain("--setting-sources=project");
272
+ expect(argv).not.toContain("--dangerously-bypass-approvals-and-sandbox");
273
+ // … but gemini-native flags survive.
274
+ expect(argv).toContain("--model");
275
+ expect(argv[argv.indexOf("--model") + 1]).toBe("gemini-2.5-pro");
276
+ });
277
+
278
+ it("surfaces result.status=error as the run error and wipes session id", async () => {
279
+ const script = makeScript(
280
+ "failed.js",
281
+ `
282
+ const lines = [
283
+ {type:"init", timestamp:"t0", session_id:"will-be-wiped"},
284
+ {type:"message", timestamp:"t1", role:"assistant", content:"partial", delta:true},
285
+ {type:"result", timestamp:"t2", status:"error", error:{type:"AUTH", message:"please run gemini auth login"}, stats:{}},
286
+ ];
287
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
288
+ process.exit(1);
289
+ `,
290
+ );
291
+ const res = await runAdapter(script);
292
+ expect(res.error).toMatch(/gemini auth login/);
293
+ expect(res.newSessionId).toBe("");
294
+ expect(res.text).toBe("");
295
+ });
296
+
297
+ it("surfaces non-zero exit + stderr when no result event is emitted", async () => {
298
+ const script = makeScript(
299
+ "boom.js",
300
+ `
301
+ process.stderr.write("FATAL: gemini setup incomplete\\n");
302
+ process.exit(7);
303
+ `,
304
+ );
305
+ const res = await runAdapter(script);
306
+ expect(res.error).toBeDefined();
307
+ expect(res.error).toMatch(/code 7/);
308
+ expect(res.error).toMatch(/setup incomplete/);
309
+ });
310
+
311
+ it("non-fatal `error` events with severity=warning do not override assistant text", async () => {
312
+ const script = makeScript(
313
+ "warning.js",
314
+ `
315
+ const lines = [
316
+ {type:"init", timestamp:"t0", session_id:"sess-warn"},
317
+ {type:"error", timestamp:"t1", severity:"warning", message:"loop detected, retrying"},
318
+ {type:"message", timestamp:"t2", role:"assistant", content:"final answer", delta:true},
319
+ {type:"result", timestamp:"t3", status:"success", stats:{}},
320
+ ];
321
+ for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
322
+ `,
323
+ );
324
+ const res = await runAdapter(script);
325
+ expect(res.text).toBe("final answer");
326
+ expect(res.error).toBeUndefined();
327
+ });
328
+
329
+ it("returns early when signal is already aborted", async () => {
330
+ const script = makeScript("noop.js", "process.exit(0);");
331
+ const adapter = new GeminiAdapter({ binary: script });
332
+ const ctrl = new AbortController();
333
+ ctrl.abort();
334
+ const res = await adapter.run({
335
+ text: "x",
336
+ sessionId: null,
337
+ accountId: "ag_test",
338
+ cwd: tmpRoot,
339
+ signal: ctrl.signal,
340
+ trustLevel: "owner",
341
+ });
342
+ expect(res.text).toBe("");
343
+ expect(res.error).toMatch(/aborted before spawn/);
344
+ });
345
+ });
346
+
347
+ describe("geminiModule registration", () => {
348
+ it("declares supportsRun (via default) so the dispatcher routes turns to it", () => {
349
+ // The registry treats `supportsRun === undefined` as true. We assert
350
+ // the field isn't set to `false` so a regression on registry.ts shows
351
+ // up here rather than as a confusing runtime "probe-only stub" error.
352
+ expect(geminiModule.supportsRun).not.toBe(false);
353
+ expect(geminiModule.id).toBe("gemini");
354
+ expect(geminiModule.envVar).toBe("BOTCORD_GEMINI_BIN");
355
+ expect(geminiModule.installHint).toBeDefined();
356
+ });
357
+ });
@@ -1,15 +1,91 @@
1
+ import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
1
2
  import {
2
3
  readCommandVersion,
3
4
  resolveCommandOnPath,
4
5
  type ProbeDeps,
5
6
  } from "./probe.js";
6
7
  import type {
7
- RuntimeAdapter,
8
8
  RuntimeProbeResult,
9
9
  RuntimeRunOptions,
10
- RuntimeRunResult,
10
+ RuntimeStatusEvent,
11
+ StreamBlock,
11
12
  } from "../types.js";
12
13
 
14
+ /**
15
+ * Gemini's `--session-id` / `--resume` accept only `[A-Za-z0-9-_]+` and we
16
+ * forward whatever the CLI emitted in its `init` event. Rejecting anything
17
+ * else keeps argv safe even if the upstream session id format ever changes.
18
+ */
19
+ const GEMINI_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
20
+
21
+ function isValidGeminiSessionId(id: string): boolean {
22
+ return GEMINI_SESSION_ID_RE.test(id);
23
+ }
24
+
25
+ function invalidGeminiSessionIdError(id: string): string {
26
+ return `gemini: invalid sessionId ${JSON.stringify(id)} (expected [A-Za-z0-9_-]+)`;
27
+ }
28
+
29
+ /**
30
+ * Drop adapter-foreign flags inherited from other runtimes' route configs
31
+ * (claude-code, codex). Each entry that takes a value also swallows the
32
+ * value that follows. Anything else is forwarded verbatim so operators can
33
+ * still push gemini-native flags through.
34
+ */
35
+ const GEMINI_FOREIGN_FLAGS_WITH_VALUE = new Set([
36
+ "--append-system-prompt",
37
+ "--permission-mode",
38
+ "--setting-sources",
39
+ "--sandbox",
40
+ "-c",
41
+ ]);
42
+ const GEMINI_FOREIGN_BOOLEAN_FLAGS = new Set([
43
+ "--dangerously-bypass-approvals-and-sandbox",
44
+ "--full-auto",
45
+ "--skip-git-repo-check",
46
+ "--json",
47
+ "--verbose",
48
+ ]);
49
+
50
+ function extraFlagName(arg: string): string {
51
+ if (!arg.startsWith("-")) return arg;
52
+ const eq = arg.indexOf("=");
53
+ return eq === -1 ? arg : arg.slice(0, eq);
54
+ }
55
+
56
+ function nextExtraValue(args: string[], index: number): string | undefined {
57
+ const next = args[index + 1];
58
+ if (typeof next !== "string") return undefined;
59
+ if (!next.startsWith("-")) return next;
60
+ return /^-\d/.test(next) ? next : undefined;
61
+ }
62
+
63
+ function sanitizeGeminiExtraArgs(extraArgs: string[] | undefined): string[] {
64
+ if (!extraArgs?.length) return [];
65
+ const out: string[] = [];
66
+ for (let i = 0; i < extraArgs.length; i += 1) {
67
+ const arg = extraArgs[i];
68
+ const name = extraFlagName(arg);
69
+ if (GEMINI_FOREIGN_FLAGS_WITH_VALUE.has(name)) {
70
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined) i += 1;
71
+ continue;
72
+ }
73
+ if (GEMINI_FOREIGN_BOOLEAN_FLAGS.has(name)) {
74
+ continue;
75
+ }
76
+ out.push(arg);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function hasFlag(args: string[], name: string): boolean {
82
+ for (const arg of args) {
83
+ if (arg === name) return true;
84
+ if (arg.startsWith(`${name}=`)) return true;
85
+ }
86
+ return false;
87
+ }
88
+
13
89
  /** Resolve the Gemini CLI executable on PATH. */
14
90
  export function resolveGeminiCommand(deps: ProbeDeps = {}): string | null {
15
91
  return resolveCommandOnPath("gemini", deps);
@@ -27,17 +103,235 @@ export function probeGemini(deps: ProbeDeps = {}): RuntimeProbeResult {
27
103
  }
28
104
 
29
105
  /**
30
- * Gemini adapter stub probe() is wired up so `botcord-daemon doctor` can report it.
31
- * run() is not implemented yet; routing a turn here will surface the error upstream.
106
+ * Gemini adapter — spawns `gemini -p "<text>" --output-format stream-json
107
+ * --yolo` (with `--resume <sid>` for continuing sessions) and parses the
108
+ * newline-delimited JSON stream.
109
+ *
110
+ * stream-json event shape (abridged, sourced from `@google/gemini-cli`
111
+ * bundle `nonInteractiveCliAgentSession.ts`):
112
+ *
113
+ * {type:"init", timestamp, session_id, model}
114
+ * {type:"message", timestamp, role:"user", content} // echo of input
115
+ * {type:"message", timestamp, role:"assistant", content, delta:true}
116
+ * {type:"tool_use", timestamp, tool_name, tool_id, parameters}
117
+ * {type:"tool_result", timestamp, tool_id, status, output?, error?}
118
+ * {type:"error", timestamp, severity, message} // non-fatal warning
119
+ * {type:"result", timestamp, status:"success", stats} // terminal
120
+ * {type:"result", timestamp, status:"error", error:{type,message}, stats}
121
+ *
122
+ * Unlike Claude Code's `result` event, gemini's terminal event carries NO
123
+ * final assistant text — the reply must be assembled by concatenating every
124
+ * `message` event with `role:"assistant"` (the CLI emits them as deltas).
125
+ *
126
+ * ## systemContext
127
+ *
128
+ * Gemini's headless mode has no `--append-system-prompt` equivalent and
129
+ * `GEMINI_SYSTEM_MD` replaces the entire core system prompt (which would
130
+ * brick the agent — that core prompt scaffolds tool use). For v1 the
131
+ * adapter prepends `systemContext` directly to the positional prompt. Each
132
+ * turn re-injects the dynamic context so memory / digest updates take
133
+ * effect immediately; the trade-off is the resumed session transcript
134
+ * accumulates one prompt prefix per turn. Acceptable while we ship the
135
+ * connectivity layer — a follow-up can move systemContext into a
136
+ * daemon-managed `GEMINI.md` once we decide where to isolate it.
137
+ *
138
+ * ## Session continuity
139
+ *
140
+ * `gemini --session-id <uuid>` is for FRESH sessions only — it errors if
141
+ * the id already exists. `gemini --resume <uuid>` resolves the UUID against
142
+ * the project's existing session pool (gemini stores sessions per
143
+ * cwd-derived project hash, so the per-agent workspace already isolates
144
+ * them from the user's interactive sessions). We therefore:
145
+ * - new turn (sessionId=null): omit both flags; capture `init.session_id`.
146
+ * - continuation: pass `--resume <uuid>`. If gemini cannot resolve the id
147
+ * it exits with `FATAL_INPUT_ERROR` and stderr; we surface that as
148
+ * `errorText` and wipe `newSessionId` so the dispatcher discards the
149
+ * stale entry.
32
150
  */
33
- export class GeminiAdapter implements RuntimeAdapter {
151
+ export class GeminiAdapter extends NdjsonStreamAdapter {
34
152
  readonly id = "gemini" as const;
35
153
 
154
+ private readonly explicitBinary: string | undefined;
155
+ private resolvedBinary: string | null = null;
156
+
157
+ constructor(opts?: { binary?: string }) {
158
+ super();
159
+ this.explicitBinary = opts?.binary ?? process.env.BOTCORD_GEMINI_BIN;
160
+ }
161
+
36
162
  probe(): RuntimeProbeResult {
37
163
  return probeGemini();
38
164
  }
39
165
 
40
- async run(_opts: RuntimeRunOptions): Promise<RuntimeRunResult> {
41
- throw new Error("gemini adapter not implemented");
166
+ override async run(opts: RuntimeRunOptions) {
167
+ if (opts.sessionId && !isValidGeminiSessionId(opts.sessionId)) {
168
+ throw new Error(invalidGeminiSessionIdError(opts.sessionId));
169
+ }
170
+ return super.run(opts);
171
+ }
172
+
173
+ protected resolveBinary(): string {
174
+ if (this.explicitBinary) return this.explicitBinary;
175
+ if (this.resolvedBinary) return this.resolvedBinary;
176
+ this.resolvedBinary = resolveGeminiCommand() ?? "gemini";
177
+ return this.resolvedBinary;
178
+ }
179
+
180
+ protected buildArgs(opts: RuntimeRunOptions): string[] {
181
+ const extraArgs = sanitizeGeminiExtraArgs(opts.extraArgs);
182
+
183
+ const args: string[] = [
184
+ "-p",
185
+ composePrompt(opts.text, opts.systemContext),
186
+ "--output-format",
187
+ "stream-json",
188
+ ];
189
+
190
+ // Daemon-driven gemini turns are non-interactive. Auto-approve all tool
191
+ // use to avoid deadlocks; operators with stricter requirements can
192
+ // override via extraArgs `--approval-mode plan` etc.
193
+ if (
194
+ !hasFlag(extraArgs, "--approval-mode") &&
195
+ !hasFlag(extraArgs, "-y") &&
196
+ !hasFlag(extraArgs, "--yolo")
197
+ ) {
198
+ args.push("--yolo");
199
+ }
200
+
201
+ // Trust the workspace so gemini doesn't downgrade the approval mode the
202
+ // moment cwd isn't in `~/.gemini/trustedFolders.json`. Without this the
203
+ // CLI silently flips back to "default" approval — which then deadlocks
204
+ // on tool calls because we have no prompt relay.
205
+ if (!hasFlag(extraArgs, "--skip-trust")) {
206
+ args.push("--skip-trust");
207
+ }
208
+
209
+ if (opts.sessionId) {
210
+ if (!isValidGeminiSessionId(opts.sessionId)) {
211
+ throw new Error(invalidGeminiSessionIdError(opts.sessionId));
212
+ }
213
+ args.push("--resume", opts.sessionId);
214
+ }
215
+
216
+ if (extraArgs.length) args.push(...extraArgs);
217
+ return args;
218
+ }
219
+
220
+ protected override spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv {
221
+ return {
222
+ ...super.spawnEnv(opts),
223
+ // Keep stream-json clean regardless of the user's terminal settings.
224
+ FORCE_COLOR: "0",
225
+ NO_COLOR: "1",
226
+ // Prevent gemini's launcher from re-spawning itself with --max-old-space
227
+ // tuning; the relaunch races with our stdio piping in tests and shaves
228
+ // ~200ms off every spawn in production.
229
+ GEMINI_CLI_NO_RELAUNCH: "1",
230
+ };
231
+ }
232
+
233
+ protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void {
234
+ const obj = raw as {
235
+ type?: string;
236
+ session_id?: string;
237
+ role?: string;
238
+ content?: string;
239
+ delta?: boolean;
240
+ tool_name?: string;
241
+ tool_id?: string;
242
+ status?: string;
243
+ severity?: string;
244
+ message?: string;
245
+ error?: { type?: string; message?: string };
246
+ };
247
+
248
+ const status = geminiStatusEvent(obj);
249
+ if (status) ctx.emitStatus(status);
250
+
251
+ ctx.emitBlock(normalizeBlock(obj, ctx.seq));
252
+
253
+ if (obj.type === "init" && typeof obj.session_id === "string") {
254
+ ctx.state.newSessionId = obj.session_id;
255
+ return;
256
+ }
257
+
258
+ if (obj.type === "message" && obj.role === "assistant" && typeof obj.content === "string") {
259
+ ctx.appendAssistantText(obj.content);
260
+ return;
261
+ }
262
+
263
+ if (obj.type === "result") {
264
+ if (obj.status === "error") {
265
+ const errMsg = obj.error?.message;
266
+ ctx.state.errorText =
267
+ typeof errMsg === "string" && errMsg ? errMsg : "gemini run failed";
268
+ // Drop the captured session id so the dispatcher doesn't try to
269
+ // resume a session that may not have been persisted to disk.
270
+ ctx.state.newSessionId = "";
271
+ ctx.state.finalText = "";
272
+ ctx.state.assistantTextChunks = [];
273
+ ctx.state.assistantTextBytes = 0;
274
+ }
275
+ return;
276
+ }
277
+
278
+ if (obj.type === "error" && obj.severity === "error" && typeof obj.message === "string") {
279
+ // Severity "error" is fatal in gemini's classification; "warning" is
280
+ // recoverable and shouldn't override the assistant's output.
281
+ ctx.state.errorText = obj.message;
282
+ }
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Prepend systemContext to the user prompt. Empty systemContext (the common
288
+ * case for direct DMs) returns the prompt unchanged so we don't bloat the
289
+ * token count with a marker prefix that conveys nothing.
290
+ */
291
+ function composePrompt(text: string, systemContext: string | undefined): string {
292
+ if (!systemContext || !systemContext.trim()) return text;
293
+ return `${systemContext.trim()}\n\n---\n\n${text}`;
294
+ }
295
+
296
+ /**
297
+ * Map a gemini stream-json event to a `RuntimeStatusEvent`. Only the
298
+ * lifecycle transitions the dispatcher can't infer from `StreamBlock.kind`
299
+ * land here; everything else is left to auto-synthesis.
300
+ */
301
+ function geminiStatusEvent(obj: {
302
+ type?: string;
303
+ role?: string;
304
+ delta?: boolean;
305
+ status?: string;
306
+ tool_name?: string;
307
+ }): RuntimeStatusEvent | undefined {
308
+ if (obj.type === "init") {
309
+ return { kind: "thinking", phase: "started", label: "Starting session" };
310
+ }
311
+ if (obj.type === "tool_use") {
312
+ const name = typeof obj.tool_name === "string" && obj.tool_name ? obj.tool_name : "tool";
313
+ return { kind: "thinking", phase: "updated", label: name };
314
+ }
315
+ if (obj.type === "message" && obj.role === "assistant") {
316
+ return { kind: "thinking", phase: "stopped" };
317
+ }
318
+ if (obj.type === "result") {
319
+ return { kind: "thinking", phase: "stopped" };
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+ function normalizeBlock(obj: any, seq: number): StreamBlock {
325
+ let kind: StreamBlock["kind"] = "other";
326
+ const type: string | undefined = obj?.type;
327
+ if (type === "message" && obj?.role === "assistant") {
328
+ kind = "assistant_text";
329
+ } else if (type === "tool_use") {
330
+ kind = "tool_use";
331
+ } else if (type === "tool_result") {
332
+ kind = "tool_result";
333
+ } else if (type === "init" || type === "result") {
334
+ kind = "system";
42
335
  }
336
+ return { raw: obj, kind, seq };
43
337
  }