@botcord/daemon 0.2.87 → 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.
- package/dist/gateway/runtimes/gemini.d.ts +57 -5
- package/dist/gateway/runtimes/gemini.js +266 -5
- package/dist/gateway/runtimes/registry.d.ts +1 -1
- package/dist/gateway/runtimes/registry.js +3 -2
- package/package.json +1 -1
- package/src/gateway/__tests__/gemini-adapter.test.ts +357 -0
- package/src/gateway/runtimes/gemini.ts +301 -7
- package/src/gateway/runtimes/registry.ts +4 -2
|
@@ -1,15 +1,67 @@
|
|
|
1
|
+
import { NdjsonStreamAdapter, type NdjsonEventCtx } from "./ndjson-stream.js";
|
|
1
2
|
import { type ProbeDeps } from "./probe.js";
|
|
2
|
-
import type {
|
|
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
|
|
9
|
-
*
|
|
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
|
|
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(
|
|
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
|
|
19
|
-
*
|
|
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(
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
31
|
-
*
|
|
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
|
|
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(
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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). */
|