@botcord/daemon 0.2.88 → 0.2.89

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.
@@ -1,15 +1,67 @@
1
+ import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
1
2
  import { type ProbeDeps } from "./probe.js";
2
- import type { RuntimeAdapter, RuntimeProbeResult, RuntimeRunOptions, RuntimeRunResult } from "../types.js";
3
+ import type { RuntimeProbeResult, RuntimeRunOptions } from "../types.js";
3
4
  /** Resolve the Gemini CLI executable on PATH. */
4
5
  export declare function resolveGeminiCommand(deps?: ProbeDeps): string | null;
5
6
  /** Probe whether the Gemini CLI is installed and report its version. */
6
7
  export declare function probeGemini(deps?: ProbeDeps): RuntimeProbeResult;
7
8
  /**
8
- * Gemini adapter stub probe() is wired up so `botcord-daemon doctor` can report it.
9
- * run() is not implemented yet; routing a turn here will surface the error upstream.
9
+ * Gemini adapter — spawns `gemini -p "<text>" --output-format stream-json
10
+ * --yolo` (with `--resume <sid>` for continuing sessions) and parses the
11
+ * newline-delimited JSON stream.
12
+ *
13
+ * stream-json event shape (abridged, sourced from `@google/gemini-cli`
14
+ * bundle `nonInteractiveCliAgentSession.ts`):
15
+ *
16
+ * {type:"init", timestamp, session_id, model}
17
+ * {type:"message", timestamp, role:"user", content} // echo of input
18
+ * {type:"message", timestamp, role:"assistant", content, delta:true}
19
+ * {type:"tool_use", timestamp, tool_name, tool_id, parameters}
20
+ * {type:"tool_result", timestamp, tool_id, status, output?, error?}
21
+ * {type:"error", timestamp, severity, message} // non-fatal warning
22
+ * {type:"result", timestamp, status:"success", stats} // terminal
23
+ * {type:"result", timestamp, status:"error", error:{type,message}, stats}
24
+ *
25
+ * Unlike Claude Code's `result` event, gemini's terminal event carries NO
26
+ * final assistant text — the reply must be assembled by concatenating every
27
+ * `message` event with `role:"assistant"` (the CLI emits them as deltas).
28
+ *
29
+ * ## systemContext
30
+ *
31
+ * Gemini's headless mode has no `--append-system-prompt` equivalent and
32
+ * `GEMINI_SYSTEM_MD` replaces the entire core system prompt (which would
33
+ * brick the agent — that core prompt scaffolds tool use). For v1 the
34
+ * adapter prepends `systemContext` directly to the positional prompt. Each
35
+ * turn re-injects the dynamic context so memory / digest updates take
36
+ * effect immediately; the trade-off is the resumed session transcript
37
+ * accumulates one prompt prefix per turn. Acceptable while we ship the
38
+ * connectivity layer — a follow-up can move systemContext into a
39
+ * daemon-managed `GEMINI.md` once we decide where to isolate it.
40
+ *
41
+ * ## Session continuity
42
+ *
43
+ * `gemini --session-id <uuid>` is for FRESH sessions only — it errors if
44
+ * the id already exists. `gemini --resume <uuid>` resolves the UUID against
45
+ * the project's existing session pool (gemini stores sessions per
46
+ * cwd-derived project hash, so the per-agent workspace already isolates
47
+ * them from the user's interactive sessions). We therefore:
48
+ * - new turn (sessionId=null): omit both flags; capture `init.session_id`.
49
+ * - continuation: pass `--resume <uuid>`. If gemini cannot resolve the id
50
+ * it exits with `FATAL_INPUT_ERROR` and stderr; we surface that as
51
+ * `errorText` and wipe `newSessionId` so the dispatcher discards the
52
+ * stale entry.
10
53
  */
11
- export declare class GeminiAdapter implements RuntimeAdapter {
54
+ export declare class GeminiAdapter extends NdjsonStreamAdapter {
12
55
  readonly id: "gemini";
56
+ private readonly explicitBinary;
57
+ private resolvedBinary;
58
+ constructor(opts?: {
59
+ binary?: string;
60
+ });
13
61
  probe(): RuntimeProbeResult;
14
- run(_opts: RuntimeRunOptions): Promise<RuntimeRunResult>;
62
+ run(opts: RuntimeRunOptions): Promise<import("../types.js").RuntimeRunResult>;
63
+ protected resolveBinary(): string;
64
+ protected buildArgs(opts: RuntimeRunOptions): string[];
65
+ protected spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv;
66
+ protected handleEvent(raw: unknown, ctx: NdjsonEventCtx): void;
15
67
  }
@@ -1,4 +1,79 @@
1
+ import { NdjsonStreamAdapter } from "./ndjson-stream.js";
1
2
  import { readCommandVersion, resolveCommandOnPath, } from "./probe.js";
3
+ /**
4
+ * Gemini's `--session-id` / `--resume` accept only `[A-Za-z0-9-_]+` and we
5
+ * forward whatever the CLI emitted in its `init` event. Rejecting anything
6
+ * else keeps argv safe even if the upstream session id format ever changes.
7
+ */
8
+ const GEMINI_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
9
+ function isValidGeminiSessionId(id) {
10
+ return GEMINI_SESSION_ID_RE.test(id);
11
+ }
12
+ function invalidGeminiSessionIdError(id) {
13
+ return `gemini: invalid sessionId ${JSON.stringify(id)} (expected [A-Za-z0-9_-]+)`;
14
+ }
15
+ /**
16
+ * Drop adapter-foreign flags inherited from other runtimes' route configs
17
+ * (claude-code, codex). Each entry that takes a value also swallows the
18
+ * value that follows. Anything else is forwarded verbatim so operators can
19
+ * still push gemini-native flags through.
20
+ */
21
+ const GEMINI_FOREIGN_FLAGS_WITH_VALUE = new Set([
22
+ "--append-system-prompt",
23
+ "--permission-mode",
24
+ "--setting-sources",
25
+ "--sandbox",
26
+ "-c",
27
+ ]);
28
+ const GEMINI_FOREIGN_BOOLEAN_FLAGS = new Set([
29
+ "--dangerously-bypass-approvals-and-sandbox",
30
+ "--full-auto",
31
+ "--skip-git-repo-check",
32
+ "--json",
33
+ "--verbose",
34
+ ]);
35
+ function extraFlagName(arg) {
36
+ if (!arg.startsWith("-"))
37
+ return arg;
38
+ const eq = arg.indexOf("=");
39
+ return eq === -1 ? arg : arg.slice(0, eq);
40
+ }
41
+ function nextExtraValue(args, index) {
42
+ const next = args[index + 1];
43
+ if (typeof next !== "string")
44
+ return undefined;
45
+ if (!next.startsWith("-"))
46
+ return next;
47
+ return /^-\d/.test(next) ? next : undefined;
48
+ }
49
+ function sanitizeGeminiExtraArgs(extraArgs) {
50
+ if (!extraArgs?.length)
51
+ return [];
52
+ const out = [];
53
+ for (let i = 0; i < extraArgs.length; i += 1) {
54
+ const arg = extraArgs[i];
55
+ const name = extraFlagName(arg);
56
+ if (GEMINI_FOREIGN_FLAGS_WITH_VALUE.has(name)) {
57
+ if (!arg.includes("=") && nextExtraValue(extraArgs, i) !== undefined)
58
+ i += 1;
59
+ continue;
60
+ }
61
+ if (GEMINI_FOREIGN_BOOLEAN_FLAGS.has(name)) {
62
+ continue;
63
+ }
64
+ out.push(arg);
65
+ }
66
+ return out;
67
+ }
68
+ function hasFlag(args, name) {
69
+ for (const arg of args) {
70
+ if (arg === name)
71
+ return true;
72
+ if (arg.startsWith(`${name}=`))
73
+ return true;
74
+ }
75
+ return false;
76
+ }
2
77
  /** Resolve the Gemini CLI executable on PATH. */
3
78
  export function resolveGeminiCommand(deps = {}) {
4
79
  return resolveCommandOnPath("gemini", deps);
@@ -15,15 +90,201 @@ export function probeGemini(deps = {}) {
15
90
  };
16
91
  }
17
92
  /**
18
- * Gemini adapter stub probe() is wired up so `botcord-daemon doctor` can report it.
19
- * run() is not implemented yet; routing a turn here will surface the error upstream.
93
+ * Gemini adapter — spawns `gemini -p "<text>" --output-format stream-json
94
+ * --yolo` (with `--resume <sid>` for continuing sessions) and parses the
95
+ * newline-delimited JSON stream.
96
+ *
97
+ * stream-json event shape (abridged, sourced from `@google/gemini-cli`
98
+ * bundle `nonInteractiveCliAgentSession.ts`):
99
+ *
100
+ * {type:"init", timestamp, session_id, model}
101
+ * {type:"message", timestamp, role:"user", content} // echo of input
102
+ * {type:"message", timestamp, role:"assistant", content, delta:true}
103
+ * {type:"tool_use", timestamp, tool_name, tool_id, parameters}
104
+ * {type:"tool_result", timestamp, tool_id, status, output?, error?}
105
+ * {type:"error", timestamp, severity, message} // non-fatal warning
106
+ * {type:"result", timestamp, status:"success", stats} // terminal
107
+ * {type:"result", timestamp, status:"error", error:{type,message}, stats}
108
+ *
109
+ * Unlike Claude Code's `result` event, gemini's terminal event carries NO
110
+ * final assistant text — the reply must be assembled by concatenating every
111
+ * `message` event with `role:"assistant"` (the CLI emits them as deltas).
112
+ *
113
+ * ## systemContext
114
+ *
115
+ * Gemini's headless mode has no `--append-system-prompt` equivalent and
116
+ * `GEMINI_SYSTEM_MD` replaces the entire core system prompt (which would
117
+ * brick the agent — that core prompt scaffolds tool use). For v1 the
118
+ * adapter prepends `systemContext` directly to the positional prompt. Each
119
+ * turn re-injects the dynamic context so memory / digest updates take
120
+ * effect immediately; the trade-off is the resumed session transcript
121
+ * accumulates one prompt prefix per turn. Acceptable while we ship the
122
+ * connectivity layer — a follow-up can move systemContext into a
123
+ * daemon-managed `GEMINI.md` once we decide where to isolate it.
124
+ *
125
+ * ## Session continuity
126
+ *
127
+ * `gemini --session-id <uuid>` is for FRESH sessions only — it errors if
128
+ * the id already exists. `gemini --resume <uuid>` resolves the UUID against
129
+ * the project's existing session pool (gemini stores sessions per
130
+ * cwd-derived project hash, so the per-agent workspace already isolates
131
+ * them from the user's interactive sessions). We therefore:
132
+ * - new turn (sessionId=null): omit both flags; capture `init.session_id`.
133
+ * - continuation: pass `--resume <uuid>`. If gemini cannot resolve the id
134
+ * it exits with `FATAL_INPUT_ERROR` and stderr; we surface that as
135
+ * `errorText` and wipe `newSessionId` so the dispatcher discards the
136
+ * stale entry.
20
137
  */
21
- export class GeminiAdapter {
138
+ export class GeminiAdapter extends NdjsonStreamAdapter {
22
139
  id = "gemini";
140
+ explicitBinary;
141
+ resolvedBinary = null;
142
+ constructor(opts) {
143
+ super();
144
+ this.explicitBinary = opts?.binary ?? process.env.BOTCORD_GEMINI_BIN;
145
+ }
23
146
  probe() {
24
147
  return probeGemini();
25
148
  }
26
- async run(_opts) {
27
- throw new Error("gemini adapter not implemented");
149
+ async run(opts) {
150
+ if (opts.sessionId && !isValidGeminiSessionId(opts.sessionId)) {
151
+ throw new Error(invalidGeminiSessionIdError(opts.sessionId));
152
+ }
153
+ return super.run(opts);
154
+ }
155
+ resolveBinary() {
156
+ if (this.explicitBinary)
157
+ return this.explicitBinary;
158
+ if (this.resolvedBinary)
159
+ return this.resolvedBinary;
160
+ this.resolvedBinary = resolveGeminiCommand() ?? "gemini";
161
+ return this.resolvedBinary;
162
+ }
163
+ buildArgs(opts) {
164
+ const extraArgs = sanitizeGeminiExtraArgs(opts.extraArgs);
165
+ const args = [
166
+ "-p",
167
+ composePrompt(opts.text, opts.systemContext),
168
+ "--output-format",
169
+ "stream-json",
170
+ ];
171
+ // Daemon-driven gemini turns are non-interactive. Auto-approve all tool
172
+ // use to avoid deadlocks; operators with stricter requirements can
173
+ // override via extraArgs `--approval-mode plan` etc.
174
+ if (!hasFlag(extraArgs, "--approval-mode") &&
175
+ !hasFlag(extraArgs, "-y") &&
176
+ !hasFlag(extraArgs, "--yolo")) {
177
+ args.push("--yolo");
178
+ }
179
+ // Trust the workspace so gemini doesn't downgrade the approval mode the
180
+ // moment cwd isn't in `~/.gemini/trustedFolders.json`. Without this the
181
+ // CLI silently flips back to "default" approval — which then deadlocks
182
+ // on tool calls because we have no prompt relay.
183
+ if (!hasFlag(extraArgs, "--skip-trust")) {
184
+ args.push("--skip-trust");
185
+ }
186
+ if (opts.sessionId) {
187
+ if (!isValidGeminiSessionId(opts.sessionId)) {
188
+ throw new Error(invalidGeminiSessionIdError(opts.sessionId));
189
+ }
190
+ args.push("--resume", opts.sessionId);
191
+ }
192
+ if (extraArgs.length)
193
+ args.push(...extraArgs);
194
+ return args;
195
+ }
196
+ spawnEnv(opts) {
197
+ return {
198
+ ...super.spawnEnv(opts),
199
+ // Keep stream-json clean regardless of the user's terminal settings.
200
+ FORCE_COLOR: "0",
201
+ NO_COLOR: "1",
202
+ // Prevent gemini's launcher from re-spawning itself with --max-old-space
203
+ // tuning; the relaunch races with our stdio piping in tests and shaves
204
+ // ~200ms off every spawn in production.
205
+ GEMINI_CLI_NO_RELAUNCH: "1",
206
+ };
207
+ }
208
+ handleEvent(raw, ctx) {
209
+ const obj = raw;
210
+ const status = geminiStatusEvent(obj);
211
+ if (status)
212
+ ctx.emitStatus(status);
213
+ ctx.emitBlock(normalizeBlock(obj, ctx.seq));
214
+ if (obj.type === "init" && typeof obj.session_id === "string") {
215
+ ctx.state.newSessionId = obj.session_id;
216
+ return;
217
+ }
218
+ if (obj.type === "message" && obj.role === "assistant" && typeof obj.content === "string") {
219
+ ctx.appendAssistantText(obj.content);
220
+ return;
221
+ }
222
+ if (obj.type === "result") {
223
+ if (obj.status === "error") {
224
+ const errMsg = obj.error?.message;
225
+ ctx.state.errorText =
226
+ typeof errMsg === "string" && errMsg ? errMsg : "gemini run failed";
227
+ // Drop the captured session id so the dispatcher doesn't try to
228
+ // resume a session that may not have been persisted to disk.
229
+ ctx.state.newSessionId = "";
230
+ ctx.state.finalText = "";
231
+ ctx.state.assistantTextChunks = [];
232
+ ctx.state.assistantTextBytes = 0;
233
+ }
234
+ return;
235
+ }
236
+ if (obj.type === "error" && obj.severity === "error" && typeof obj.message === "string") {
237
+ // Severity "error" is fatal in gemini's classification; "warning" is
238
+ // recoverable and shouldn't override the assistant's output.
239
+ ctx.state.errorText = obj.message;
240
+ }
241
+ }
242
+ }
243
+ /**
244
+ * Prepend systemContext to the user prompt. Empty systemContext (the common
245
+ * case for direct DMs) returns the prompt unchanged so we don't bloat the
246
+ * token count with a marker prefix that conveys nothing.
247
+ */
248
+ function composePrompt(text, systemContext) {
249
+ if (!systemContext || !systemContext.trim())
250
+ return text;
251
+ return `${systemContext.trim()}\n\n---\n\n${text}`;
252
+ }
253
+ /**
254
+ * Map a gemini stream-json event to a `RuntimeStatusEvent`. Only the
255
+ * lifecycle transitions the dispatcher can't infer from `StreamBlock.kind`
256
+ * land here; everything else is left to auto-synthesis.
257
+ */
258
+ function geminiStatusEvent(obj) {
259
+ if (obj.type === "init") {
260
+ return { kind: "thinking", phase: "started", label: "Starting session" };
261
+ }
262
+ if (obj.type === "tool_use") {
263
+ const name = typeof obj.tool_name === "string" && obj.tool_name ? obj.tool_name : "tool";
264
+ return { kind: "thinking", phase: "updated", label: name };
265
+ }
266
+ if (obj.type === "message" && obj.role === "assistant") {
267
+ return { kind: "thinking", phase: "stopped" };
268
+ }
269
+ if (obj.type === "result") {
270
+ return { kind: "thinking", phase: "stopped" };
271
+ }
272
+ return undefined;
273
+ }
274
+ function normalizeBlock(obj, seq) {
275
+ let kind = "other";
276
+ const type = obj?.type;
277
+ if (type === "message" && obj?.role === "assistant") {
278
+ kind = "assistant_text";
279
+ }
280
+ else if (type === "tool_use") {
281
+ kind = "tool_use";
282
+ }
283
+ else if (type === "tool_result") {
284
+ kind = "tool_result";
285
+ }
286
+ else if (type === "init" || type === "result") {
287
+ kind = "system";
28
288
  }
289
+ return { raw: obj, kind, seq };
29
290
  }
@@ -39,7 +39,7 @@ export declare const deepseekTuiModule: RuntimeModule;
39
39
  export declare const kimiModule: RuntimeModule;
40
40
  /** Built-in runtime module entry for Hermes Agent (ACP stdio). */
41
41
  export declare const hermesAgentModule: RuntimeModule;
42
- /** Built-in runtime module entry for Gemini (probe-only stub). */
42
+ /** Built-in runtime module entry for Gemini CLI. */
43
43
  export declare const geminiModule: RuntimeModule;
44
44
  /** Built-in runtime module entry for OpenClaw (ACP). */
45
45
  export declare const openclawAcpModule: RuntimeModule;
@@ -51,14 +51,15 @@ export const hermesAgentModule = {
51
51
  create: () => new HermesAgentAdapter(),
52
52
  installHint: 'Install: pip install "hermes-agent[acp]" (or set BOTCORD_HERMES_AGENT_BIN to the absolute path of hermes-acp)',
53
53
  };
54
- /** Built-in runtime module entry for Gemini (probe-only stub). */
54
+ /** Built-in runtime module entry for Gemini CLI. */
55
55
  export const geminiModule = {
56
56
  id: "gemini",
57
57
  displayName: "Gemini CLI",
58
58
  binary: "gemini",
59
+ envVar: "BOTCORD_GEMINI_BIN",
59
60
  probe: () => probeGemini(),
60
61
  create: () => new GeminiAdapter(),
61
- supportsRun: false,
62
+ installHint: "Install with `npm install -g @google/gemini-cli` (or `brew install gemini-cli`) and run `gemini` once to complete authentication. Override the binary with BOTCORD_GEMINI_BIN.",
62
63
  };
63
64
  /** Built-in runtime module entry for OpenClaw (ACP). */
64
65
  export const openclawAcpModule = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.88",
3
+ "version": "0.2.89",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }
@@ -91,14 +91,16 @@ export const hermesAgentModule: RuntimeModule = {
91
91
  'Install: pip install "hermes-agent[acp]" (or set BOTCORD_HERMES_AGENT_BIN to the absolute path of hermes-acp)',
92
92
  };
93
93
 
94
- /** Built-in runtime module entry for Gemini (probe-only stub). */
94
+ /** Built-in runtime module entry for Gemini CLI. */
95
95
  export const geminiModule: RuntimeModule = {
96
96
  id: "gemini",
97
97
  displayName: "Gemini CLI",
98
98
  binary: "gemini",
99
+ envVar: "BOTCORD_GEMINI_BIN",
99
100
  probe: () => probeGemini(),
100
101
  create: () => new GeminiAdapter(),
101
- supportsRun: false,
102
+ installHint:
103
+ "Install with `npm install -g @google/gemini-cli` (or `brew install gemini-cli`) and run `gemini` once to complete authentication. Override the binary with BOTCORD_GEMINI_BIN.",
102
104
  };
103
105
 
104
106
  /** Built-in runtime module entry for OpenClaw (ACP). */