@koreanhole/opencodex 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +1 -1
- package/README.md +17 -9
- package/package.json +4 -4
- package/src/adapters/cli-agent/cline/adapter.ts +107 -90
- package/src/adapters/cli-agent/cline/json.ts +652 -0
- package/src/adapters/cli-agent/cline/settings.ts +64 -5
- package/src/adapters/cli-agent/cline/stream.ts +201 -1
- package/src/adapters/cli-agent/env.ts +2 -2
- package/src/adapters/cli-agent/gemini/adapter.ts +46 -158
- package/src/adapters/cli-agent/gemini/headless.ts +654 -0
- package/src/adapters/cli-agent/gemini/live-models.ts +21 -66
- package/src/adapters/cli-agent/gemini/runtime.ts +1 -62
- package/src/adapters/cli-agent/protocol.ts +5 -4
- package/src/adapters/cli-agent/types.ts +1 -0
- package/src/cli/entry.ts +8 -0
- package/src/server/websocket.ts +5 -0
- package/src/adapters/cli-agent/gemini/acp.ts +0 -788
|
@@ -1,788 +0,0 @@
|
|
|
1
|
-
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
-
import { link, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
5
|
-
import * as acp from "@agentclientprotocol/sdk";
|
|
6
|
-
import type { AdapterEvent, IncomingMeta, OcxUsage } from "../../../types";
|
|
7
|
-
import { commandInvocation } from "../../../lib/win-exec";
|
|
8
|
-
import { baseScopedEnv } from "../env";
|
|
9
|
-
import type { CliAgentStreamState, CliAgentTurnDeps } from "../types";
|
|
10
|
-
import { mapToolIntent, ToolIntentError } from "../tool-intent";
|
|
11
|
-
import { recordGeminiInitialize, recordGeminiUsage } from "./runtime";
|
|
12
|
-
import {
|
|
13
|
-
AcpClient,
|
|
14
|
-
AcpError,
|
|
15
|
-
parseAcpInitialize,
|
|
16
|
-
parseAcpSession,
|
|
17
|
-
safeAcpMessage,
|
|
18
|
-
type AcpDeps,
|
|
19
|
-
type AcpHooks,
|
|
20
|
-
type AcpInitialize,
|
|
21
|
-
type AcpModel,
|
|
22
|
-
type AcpSessionInfo,
|
|
23
|
-
} from "../acp-client";
|
|
24
|
-
|
|
25
|
-
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
26
|
-
const MAX_STDERR_BYTES = 8 * 1024;
|
|
27
|
-
const MAX_CAPTURE_FILE_BYTES = 4 * 1024 * 1024;
|
|
28
|
-
const CAPTURE_POLL_MS = 25;
|
|
29
|
-
const GEMINI_POLICY = [
|
|
30
|
-
"[[rule]]",
|
|
31
|
-
'toolName = "mcp_*"',
|
|
32
|
-
'decision = "allow"',
|
|
33
|
-
"priority = 999",
|
|
34
|
-
"",
|
|
35
|
-
"[[rule]]",
|
|
36
|
-
'toolName = "*"',
|
|
37
|
-
'decision = "deny"',
|
|
38
|
-
"priority = 998",
|
|
39
|
-
"",
|
|
40
|
-
].join("\n");
|
|
41
|
-
|
|
42
|
-
const GEMINI_SYSTEM_SETTINGS = JSON.stringify({
|
|
43
|
-
hooksConfig: { enabled: false },
|
|
44
|
-
skills: { enabled: false },
|
|
45
|
-
admin: {
|
|
46
|
-
secureModeEnabled: true,
|
|
47
|
-
extensions: { enabled: false },
|
|
48
|
-
skills: { enabled: false },
|
|
49
|
-
},
|
|
50
|
-
}, null, 2) + "\n";
|
|
51
|
-
|
|
52
|
-
const FORWARDED_ENV_KEYS = [
|
|
53
|
-
"XDG_CONFIG_HOME",
|
|
54
|
-
"XDG_CACHE_HOME",
|
|
55
|
-
"APPDATA",
|
|
56
|
-
"LOCALAPPDATA",
|
|
57
|
-
"GEMINI_API_KEY",
|
|
58
|
-
"GOOGLE_API_KEY",
|
|
59
|
-
"GOOGLE_GENAI_USE_VERTEXAI",
|
|
60
|
-
"GOOGLE_GENAI_USE_GCA",
|
|
61
|
-
"GOOGLE_CLOUD_PROJECT",
|
|
62
|
-
"GOOGLE_CLOUD_LOCATION",
|
|
63
|
-
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
64
|
-
"GOOGLE_GEMINI_BASE_URL",
|
|
65
|
-
"GEMINI_API_KEY_AUTH_MECHANISM",
|
|
66
|
-
"GOOGLE_GENAI_API_VERSION",
|
|
67
|
-
"GEMINI_SYSTEM_MD",
|
|
68
|
-
"HTTP_PROXY",
|
|
69
|
-
"HTTPS_PROXY",
|
|
70
|
-
"ALL_PROXY",
|
|
71
|
-
"NO_PROXY",
|
|
72
|
-
"http_proxy",
|
|
73
|
-
"https_proxy",
|
|
74
|
-
"no_proxy",
|
|
75
|
-
] as const;
|
|
76
|
-
|
|
77
|
-
export { AcpError as GeminiAcpError };
|
|
78
|
-
export type GeminiAcpModel = AcpModel;
|
|
79
|
-
export type GeminiAcpSessionInfo = AcpSessionInfo;
|
|
80
|
-
export type GeminiAcpInitialize = AcpInitialize;
|
|
81
|
-
export type GeminiAcpHooks = AcpHooks;
|
|
82
|
-
export type GeminiAcpDeps = AcpDeps;
|
|
83
|
-
|
|
84
|
-
export interface GeminiAcpMode {
|
|
85
|
-
acp: boolean;
|
|
86
|
-
}
|
|
87
|
-
const MODE_CACHE = new Map<string, { mtimeMs: number; size: number; acp: boolean }>();
|
|
88
|
-
|
|
89
|
-
function nonEmpty(value: unknown): string | undefined {
|
|
90
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
94
|
-
return value && typeof value === "object" && !Array.isArray(value)
|
|
95
|
-
? value as Record<string, unknown>
|
|
96
|
-
: undefined;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function geminiAcpEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
100
|
-
return env.OPENCODEX_GEMINI_ACP?.trim() !== "0";
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function parseGeminiAcpSession(response: unknown): GeminiAcpSessionInfo {
|
|
104
|
-
return parseAcpSession(response, "Gemini ACP", false);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function parseGeminiAcpInitialize(value: unknown): GeminiAcpInitialize {
|
|
108
|
-
return parseAcpInitialize(value, "Gemini ACP", false);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function geminiModelIds(session: GeminiAcpSessionInfo): string[] {
|
|
112
|
-
return session.availableModels.length > 0
|
|
113
|
-
? [...new Set(session.availableModels.map(model => model.modelId))]
|
|
114
|
-
: ["default"];
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Probe Gemini's documented ACP switch with a bounded help command. */
|
|
118
|
-
export async function probeGeminiAcpSupport(
|
|
119
|
-
binary: string,
|
|
120
|
-
env: Record<string, string>,
|
|
121
|
-
deps: GeminiAcpDeps = {},
|
|
122
|
-
): Promise<GeminiAcpMode> {
|
|
123
|
-
if (!geminiAcpEnabled(deps.env ?? process.env)) return { acp: false };
|
|
124
|
-
const key = resolve(binary);
|
|
125
|
-
let mtimeMs: number | undefined;
|
|
126
|
-
let size: number | undefined;
|
|
127
|
-
try {
|
|
128
|
-
const value = await stat(key);
|
|
129
|
-
mtimeMs = value.mtimeMs;
|
|
130
|
-
size = value.size;
|
|
131
|
-
} catch {
|
|
132
|
-
return { acp: false };
|
|
133
|
-
}
|
|
134
|
-
const cached = MODE_CACHE.get(key);
|
|
135
|
-
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) return { acp: cached.acp };
|
|
136
|
-
const help = await runGeminiHelp(binary, env, deps);
|
|
137
|
-
const supported = /(?:^|\s)--acp(?:\s|,|<|$)/.test(help);
|
|
138
|
-
MODE_CACHE.set(key, { mtimeMs, size, acp: supported });
|
|
139
|
-
return { acp: supported };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function runGeminiHelp(binary: string, env: Record<string, string>, deps: GeminiAcpDeps): Promise<string> {
|
|
143
|
-
return await new Promise((resolvePromise, reject) => {
|
|
144
|
-
const invocation = commandInvocation(binary, ["--help"], deps.platform ?? process.platform, { env });
|
|
145
|
-
const spawnFn = deps.spawn ?? (nodeSpawn as unknown as NonNullable<CliAgentTurnDeps["spawn"]>);
|
|
146
|
-
const child = spawnFn(invocation.file, invocation.args, {
|
|
147
|
-
...invocation.options,
|
|
148
|
-
cwd: homedir(),
|
|
149
|
-
env,
|
|
150
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
151
|
-
windowsHide: true,
|
|
152
|
-
});
|
|
153
|
-
const chunks: string[] = [];
|
|
154
|
-
let bytes = 0;
|
|
155
|
-
let settled = false;
|
|
156
|
-
const finish = (fn: () => void): void => {
|
|
157
|
-
if (settled) return;
|
|
158
|
-
settled = true;
|
|
159
|
-
clearTimeout(timer);
|
|
160
|
-
fn();
|
|
161
|
-
};
|
|
162
|
-
const timer = setTimeout(() => {
|
|
163
|
-
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
164
|
-
finish(() => reject(new Error("Gemini --help probe timed out.")));
|
|
165
|
-
}, deps.timeoutMs ?? 15_000);
|
|
166
|
-
const collect = (chunk: Buffer): void => {
|
|
167
|
-
if (bytes >= MAX_STDERR_BYTES) return;
|
|
168
|
-
const admitted = chunk.subarray(0, MAX_STDERR_BYTES - bytes);
|
|
169
|
-
chunks.push(admitted.toString("utf8"));
|
|
170
|
-
bytes += admitted.byteLength;
|
|
171
|
-
};
|
|
172
|
-
child.stdout?.on("data", collect);
|
|
173
|
-
child.stderr?.on("data", collect);
|
|
174
|
-
child.once("error", error => finish(() => reject(error)));
|
|
175
|
-
child.once("close", () => finish(() => resolvePromise(chunks.join(""))));
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Build the Gemini ACP environment while leaving vendor auth in its real home. */
|
|
180
|
-
export function buildGeminiAcpEnv(sourceEnv: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
181
|
-
const env = baseScopedEnv();
|
|
182
|
-
const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
|
|
183
|
-
env.HOME = realHome;
|
|
184
|
-
env.USERPROFILE = realHome;
|
|
185
|
-
for (const key of FORWARDED_ENV_KEYS) {
|
|
186
|
-
const value = nonEmpty(sourceEnv[key]);
|
|
187
|
-
if (value) env[key] = value;
|
|
188
|
-
}
|
|
189
|
-
Object.assign(env, {
|
|
190
|
-
GEMINI_CLI_NO_RELAUNCH: "1",
|
|
191
|
-
GEMINI_TELEMETRY_ENABLED: "false",
|
|
192
|
-
NO_UPDATE_NOTIFIER: "1",
|
|
193
|
-
NO_COLOR: "1",
|
|
194
|
-
});
|
|
195
|
-
return env;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export interface GeminiAcpWorkspace {
|
|
199
|
-
cwd: string;
|
|
200
|
-
geminiHome: string;
|
|
201
|
-
policyPath: string;
|
|
202
|
-
settingsPath: string;
|
|
203
|
-
catalogPath: string;
|
|
204
|
-
capturePath?: string;
|
|
205
|
-
cleanup: () => Promise<void>;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
export interface GeminiReasoningSelection {
|
|
209
|
-
modelId: string;
|
|
210
|
-
thinkingConfig: {
|
|
211
|
-
thinkingLevel?: string;
|
|
212
|
-
thinkingBudget?: number;
|
|
213
|
-
includeThoughts?: boolean;
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function geminiSystemSettings(reasoning?: GeminiReasoningSelection): string {
|
|
218
|
-
const settings = JSON.parse(GEMINI_SYSTEM_SETTINGS) as Record<string, unknown>;
|
|
219
|
-
if (reasoning) {
|
|
220
|
-
settings.modelConfigs = {
|
|
221
|
-
customOverrides: [{
|
|
222
|
-
match: { model: reasoning.modelId },
|
|
223
|
-
modelConfig: {
|
|
224
|
-
generateContentConfig: {
|
|
225
|
-
thinkingConfig: reasoning.thinkingConfig,
|
|
226
|
-
},
|
|
227
|
-
},
|
|
228
|
-
}],
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export async function stageGeminiAcpWorkspace(
|
|
235
|
-
catalog: readonly Record<string, unknown>[] = [],
|
|
236
|
-
reasoning?: GeminiReasoningSelection,
|
|
237
|
-
options: {
|
|
238
|
-
root?: string;
|
|
239
|
-
sourceEnv?: NodeJS.ProcessEnv;
|
|
240
|
-
} = {},
|
|
241
|
-
): Promise<GeminiAcpWorkspace> {
|
|
242
|
-
const cwd = options.root ?? await mkdtemp(join(tmpdir(), "ocx-gemini-acp-"));
|
|
243
|
-
await mkdir(cwd, { recursive: true, mode: 0o700 });
|
|
244
|
-
const geminiHome = join(cwd, "gemini-home");
|
|
245
|
-
const policyPath = join(cwd, "policy.toml");
|
|
246
|
-
const settingsPath = join(cwd, "system-settings.json");
|
|
247
|
-
const catalogPath = join(cwd, "tool-catalog.json");
|
|
248
|
-
const capturePath = join(cwd, "tool-intents.ndjson");
|
|
249
|
-
const geminiConfigDir = join(geminiHome, ".gemini");
|
|
250
|
-
const exclusive = options.root === undefined;
|
|
251
|
-
const writeOptions = { encoding: "utf8" as const, mode: 0o600, ...(exclusive ? { flag: "wx" as const } : {}) };
|
|
252
|
-
await Promise.all([
|
|
253
|
-
mkdir(geminiConfigDir, { recursive: true, mode: 0o700 }),
|
|
254
|
-
writeFile(policyPath, GEMINI_POLICY, writeOptions),
|
|
255
|
-
writeFile(settingsPath, geminiSystemSettings(reasoning), writeOptions),
|
|
256
|
-
writeFile(catalogPath, JSON.stringify(catalog), writeOptions),
|
|
257
|
-
...(catalog.length > 0
|
|
258
|
-
? [writeFile(capturePath, "", writeOptions)]
|
|
259
|
-
: []),
|
|
260
|
-
]);
|
|
261
|
-
await linkGeminiAuth(geminiConfigDir, options.sourceEnv ?? process.env);
|
|
262
|
-
return {
|
|
263
|
-
cwd,
|
|
264
|
-
geminiHome,
|
|
265
|
-
policyPath,
|
|
266
|
-
settingsPath,
|
|
267
|
-
catalogPath,
|
|
268
|
-
...(catalog.length > 0 ? { capturePath } : {}),
|
|
269
|
-
cleanup: async () => { await rm(cwd, { recursive: true, force: true }).catch(() => undefined); },
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
async function linkGeminiAuth(targetDir: string, sourceEnv: NodeJS.ProcessEnv): Promise<void> {
|
|
274
|
-
const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
|
|
275
|
-
const realGemini = join(realHome, ".gemini");
|
|
276
|
-
const names = ["oauth_creds.json", "google_accounts.json", ".env", "settings.json", "trustedFolders.json"];
|
|
277
|
-
await Promise.all(names.map(async name => {
|
|
278
|
-
const target = join(realGemini, name);
|
|
279
|
-
try {
|
|
280
|
-
await lstat(target);
|
|
281
|
-
} catch {
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
const path = join(targetDir, name);
|
|
285
|
-
try {
|
|
286
|
-
await symlink(resolve(target), path);
|
|
287
|
-
} catch {
|
|
288
|
-
try {
|
|
289
|
-
await link(resolve(target), path);
|
|
290
|
-
} catch {
|
|
291
|
-
// Best-effort; environment-provided auth remains the primary contract.
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}));
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export function geminiAcpArgs(policyPath: string): string[] {
|
|
298
|
-
return [
|
|
299
|
-
"--acp",
|
|
300
|
-
"--skip-trust",
|
|
301
|
-
"--approval-mode",
|
|
302
|
-
"default",
|
|
303
|
-
"--policy",
|
|
304
|
-
policyPath,
|
|
305
|
-
"--allowed-mcp-server-names",
|
|
306
|
-
"opencodex",
|
|
307
|
-
"-e",
|
|
308
|
-
"none",
|
|
309
|
-
];
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
export interface GeminiAcpTurnInput {
|
|
313
|
-
binary: string;
|
|
314
|
-
env: Record<string, string>;
|
|
315
|
-
runtimeEnv?: NodeJS.ProcessEnv;
|
|
316
|
-
cwd: string;
|
|
317
|
-
args: readonly string[];
|
|
318
|
-
modelId: string;
|
|
319
|
-
fingerprint?: string;
|
|
320
|
-
prompt: string;
|
|
321
|
-
deltaPrompt?: string;
|
|
322
|
-
resumeSessionId?: string;
|
|
323
|
-
sessionKeySource?: string;
|
|
324
|
-
hadPreviousResponseId?: boolean;
|
|
325
|
-
images?: readonly { mimeType: string; data: string }[];
|
|
326
|
-
toolNames: ReadonlyMap<string, string>;
|
|
327
|
-
mcpServers: readonly Record<string, unknown>[];
|
|
328
|
-
capturePath?: string;
|
|
329
|
-
maxToolCalls: number;
|
|
330
|
-
requireToolCall: boolean;
|
|
331
|
-
incoming: IncomingMeta;
|
|
332
|
-
onVendorSession?: (sessionId: string) => void;
|
|
333
|
-
onResumeFailed?: () => void;
|
|
334
|
-
deps?: GeminiAcpDeps;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function createGeminiStreamState(): CliAgentStreamState {
|
|
338
|
-
return {
|
|
339
|
-
sawText: false,
|
|
340
|
-
sawThinking: false,
|
|
341
|
-
terminal: false,
|
|
342
|
-
toolCalls: 0,
|
|
343
|
-
nextToolOrdinal: 0,
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function usageFrom(value: unknown, observedModelId?: string): OcxUsage | undefined {
|
|
348
|
-
const record = asRecord(value);
|
|
349
|
-
if (!record) return undefined;
|
|
350
|
-
const count = (input: unknown): number =>
|
|
351
|
-
typeof input === "number" && Number.isFinite(input) && input >= 0 ? Math.floor(input) : 0;
|
|
352
|
-
const inputTokens = count(record.inputTokens);
|
|
353
|
-
const outputTokens = count(record.outputTokens);
|
|
354
|
-
const cacheRead = count(record.cachedReadTokens);
|
|
355
|
-
if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0) return undefined;
|
|
356
|
-
return {
|
|
357
|
-
inputTokens,
|
|
358
|
-
outputTokens,
|
|
359
|
-
totalTokens: inputTokens + outputTokens,
|
|
360
|
-
...(cacheRead > 0 ? { cachedInputTokens: cacheRead, cacheReadInputTokens: cacheRead } : {}),
|
|
361
|
-
...(observedModelId ? { modelId: observedModelId } : {}),
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function observedModelId(response: acp.PromptResponse, expected: string): string | undefined {
|
|
366
|
-
const meta = asRecord(response._meta);
|
|
367
|
-
const quota = asRecord(meta?.quota);
|
|
368
|
-
const usage = Array.isArray(quota?.model_usage) ? quota.model_usage : [];
|
|
369
|
-
const ids = [...new Set(usage.flatMap(item => {
|
|
370
|
-
const model = nonEmpty(asRecord(item)?.model);
|
|
371
|
-
return model ? [model] : [];
|
|
372
|
-
}))];
|
|
373
|
-
return ids.includes(expected) ? expected : ids.length === 1 ? ids[0] : ids[0];
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function unsupportedModel(modelId: string): AcpError {
|
|
377
|
-
return new AcpError(
|
|
378
|
-
`Gemini ACP cannot select model "${modelId}".`,
|
|
379
|
-
400,
|
|
380
|
-
"invalid_request_error",
|
|
381
|
-
"gemini_model_unsupported",
|
|
382
|
-
false,
|
|
383
|
-
false,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
export async function applyGeminiModel(
|
|
388
|
-
client: AcpClient,
|
|
389
|
-
session: GeminiAcpSessionInfo,
|
|
390
|
-
modelId: string,
|
|
391
|
-
): Promise<void> {
|
|
392
|
-
if (modelId === "default") return;
|
|
393
|
-
if (!session.availableModels.some(model => model.modelId === modelId)) throw unsupportedModel(modelId);
|
|
394
|
-
try {
|
|
395
|
-
await client.setModel(session.sessionId, modelId);
|
|
396
|
-
} catch (error) {
|
|
397
|
-
if (error instanceof AcpError && error.code === "timeout") throw error;
|
|
398
|
-
throw unsupportedModel(modelId);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
async function readCapturedIntents(path: string): Promise<unknown[]> {
|
|
403
|
-
let bytes: Buffer;
|
|
404
|
-
try {
|
|
405
|
-
bytes = await readFile(path);
|
|
406
|
-
} catch {
|
|
407
|
-
return [];
|
|
408
|
-
}
|
|
409
|
-
if (bytes.byteLength > MAX_CAPTURE_FILE_BYTES) {
|
|
410
|
-
throw new ToolIntentError("Gemini tool-intent capture exceeded the byte limit.");
|
|
411
|
-
}
|
|
412
|
-
const text = bytes.toString("utf8");
|
|
413
|
-
if (!text.endsWith("\n")) return [];
|
|
414
|
-
return text.split("\n").filter(Boolean).flatMap(line => {
|
|
415
|
-
try {
|
|
416
|
-
const value = JSON.parse(line) as unknown;
|
|
417
|
-
return value && typeof value === "object" && !Array.isArray(value) ? [value] : [];
|
|
418
|
-
} catch {
|
|
419
|
-
throw new ToolIntentError("Gemini returned a malformed captured tool intent.");
|
|
420
|
-
}
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/** Run one Gemini ACP turn and return any tool intent to the external client. */
|
|
425
|
-
export async function runGeminiAcpTurn(
|
|
426
|
-
input: GeminiAcpTurnInput,
|
|
427
|
-
emit: (event: AdapterEvent) => void,
|
|
428
|
-
): Promise<void> {
|
|
429
|
-
const deps = input.deps ?? {};
|
|
430
|
-
const fingerprint = input.fingerprint ?? "unknown";
|
|
431
|
-
const state = createGeminiStreamState();
|
|
432
|
-
let client: AcpClient | undefined;
|
|
433
|
-
let session: GeminiAcpSessionInfo | undefined;
|
|
434
|
-
let terminalEmitted = false;
|
|
435
|
-
let aborted = false;
|
|
436
|
-
let timedOut = false;
|
|
437
|
-
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
438
|
-
let turnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
439
|
-
let captureResolve: ((records: unknown[]) => void) | undefined;
|
|
440
|
-
let captureReject: ((error: unknown) => void) | undefined;
|
|
441
|
-
let resolveAbort: (() => void) | undefined;
|
|
442
|
-
let loadingSession = false;
|
|
443
|
-
let resumed = false;
|
|
444
|
-
let sentPromptChars = input.prompt.length;
|
|
445
|
-
const seenToolCalls = new Set<string>();
|
|
446
|
-
|
|
447
|
-
const deliver = (event: AdapterEvent): void => {
|
|
448
|
-
if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
|
|
449
|
-
if (terminalEmitted) return;
|
|
450
|
-
terminalEmitted = true;
|
|
451
|
-
}
|
|
452
|
-
emit(event);
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
const capturePromise = input.capturePath
|
|
456
|
-
? new Promise<unknown[]>((resolvePromise, reject) => {
|
|
457
|
-
captureResolve = resolvePromise;
|
|
458
|
-
captureReject = reject;
|
|
459
|
-
})
|
|
460
|
-
: new Promise<never>(() => undefined);
|
|
461
|
-
|
|
462
|
-
const captureTool = (toolCall: Record<string, unknown>): void => {
|
|
463
|
-
const id = nonEmpty(toolCall.toolCallId);
|
|
464
|
-
if (id && seenToolCalls.has(id)) return;
|
|
465
|
-
if (id) seenToolCalls.add(id);
|
|
466
|
-
const name = nonEmpty(toolCall.name);
|
|
467
|
-
if (!name) return;
|
|
468
|
-
try {
|
|
469
|
-
const events = mapToolIntent("Gemini", name, id, toolCall.rawInput, state, {
|
|
470
|
-
toolNames: input.toolNames,
|
|
471
|
-
maxToolCalls: input.maxToolCalls,
|
|
472
|
-
});
|
|
473
|
-
for (const event of events) deliver(event);
|
|
474
|
-
if (pollTimer) {
|
|
475
|
-
clearInterval(pollTimer);
|
|
476
|
-
pollTimer = undefined;
|
|
477
|
-
}
|
|
478
|
-
if (session) void client?.cancel(session.sessionId).catch(() => undefined);
|
|
479
|
-
} catch (error) {
|
|
480
|
-
deliver({
|
|
481
|
-
type: "error",
|
|
482
|
-
message: safeAcpMessage(error, "Gemini returned an invalid tool call."),
|
|
483
|
-
status: 502,
|
|
484
|
-
errorType: "upstream_error",
|
|
485
|
-
code: "cli_protocol_error",
|
|
486
|
-
retryable: false,
|
|
487
|
-
});
|
|
488
|
-
if (session) void client?.cancel(session.sessionId).catch(() => undefined);
|
|
489
|
-
}
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
const hooks: GeminiAcpHooks = {
|
|
493
|
-
onSessionUpdate: notification => {
|
|
494
|
-
if (loadingSession) return;
|
|
495
|
-
const update = notification.update;
|
|
496
|
-
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text" && update.content.text) {
|
|
497
|
-
state.sawText = true;
|
|
498
|
-
deliver({ type: "text_delta", text: update.content.text });
|
|
499
|
-
} else if (update.sessionUpdate === "agent_thought_chunk" && update.content.type === "text" && update.content.text) {
|
|
500
|
-
state.sawThinking = true;
|
|
501
|
-
deliver({ type: "thinking_delta", thinking: update.content.text });
|
|
502
|
-
} else if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
|
|
503
|
-
captureTool(update as unknown as Record<string, unknown>);
|
|
504
|
-
} else if (update.sessionUpdate === "usage_update") {
|
|
505
|
-
if (typeof update.used === "number" && typeof update.size === "number") {
|
|
506
|
-
void recordGeminiUsage(
|
|
507
|
-
fingerprint,
|
|
508
|
-
input.modelId,
|
|
509
|
-
update.used,
|
|
510
|
-
update.size,
|
|
511
|
-
sentPromptChars,
|
|
512
|
-
input.runtimeEnv ?? process.env,
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
},
|
|
517
|
-
onPermission: params => {
|
|
518
|
-
const toolCall = asRecord(params.toolCall);
|
|
519
|
-
if (toolCall) captureTool(toolCall);
|
|
520
|
-
if (session) void client?.cancel(session.sessionId).catch(() => undefined);
|
|
521
|
-
},
|
|
522
|
-
};
|
|
523
|
-
|
|
524
|
-
const onAbort = (): void => {
|
|
525
|
-
aborted = true;
|
|
526
|
-
resolveAbort?.();
|
|
527
|
-
if (session) void client?.cancel(session.sessionId).catch(() => undefined);
|
|
528
|
-
};
|
|
529
|
-
|
|
530
|
-
try {
|
|
531
|
-
client = await AcpClient.connect({
|
|
532
|
-
binary: input.binary,
|
|
533
|
-
env: input.env,
|
|
534
|
-
cwd: input.cwd,
|
|
535
|
-
args: input.args,
|
|
536
|
-
hooks,
|
|
537
|
-
label: "Gemini ACP",
|
|
538
|
-
fallbackAllowed: false,
|
|
539
|
-
}, deps);
|
|
540
|
-
if (client.initializeResult) {
|
|
541
|
-
await recordGeminiInitialize(
|
|
542
|
-
fingerprint,
|
|
543
|
-
client.initializeResult,
|
|
544
|
-
input.runtimeEnv ?? process.env,
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
loadingSession = Boolean(input.resumeSessionId && client.initializeResult?.capabilities.loadSession);
|
|
548
|
-
if (loadingSession) {
|
|
549
|
-
try {
|
|
550
|
-
session = await client.loadSession({
|
|
551
|
-
sessionId: input.resumeSessionId!,
|
|
552
|
-
cwd: input.cwd,
|
|
553
|
-
mcpServers: input.mcpServers,
|
|
554
|
-
});
|
|
555
|
-
resumed = true;
|
|
556
|
-
} catch {
|
|
557
|
-
input.onResumeFailed?.();
|
|
558
|
-
session = undefined;
|
|
559
|
-
} finally {
|
|
560
|
-
loadingSession = false;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
if (!session) session = await client.newSession({ cwd: input.cwd, mcpServers: input.mcpServers });
|
|
564
|
-
await applyGeminiModel(client, session, input.modelId);
|
|
565
|
-
input.onVendorSession?.(session.sessionId);
|
|
566
|
-
const promptText = resumed && input.deltaPrompt ? input.deltaPrompt : input.prompt;
|
|
567
|
-
sentPromptChars = promptText.length;
|
|
568
|
-
|
|
569
|
-
if (input.incoming.abortSignal?.aborted) {
|
|
570
|
-
deliver({
|
|
571
|
-
type: "error",
|
|
572
|
-
message: "Gemini ACP turn was aborted before the prompt.",
|
|
573
|
-
status: 499,
|
|
574
|
-
errorType: "invalid_request_error",
|
|
575
|
-
code: "client_cancelled",
|
|
576
|
-
retryable: false,
|
|
577
|
-
});
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
const abortOutcome = new Promise<{ kind: "abort" }>(resolvePromise => {
|
|
582
|
-
resolveAbort = () => resolvePromise({ kind: "abort" });
|
|
583
|
-
if (input.incoming.abortSignal?.aborted) resolveAbort();
|
|
584
|
-
else input.incoming.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
585
|
-
});
|
|
586
|
-
if (input.capturePath) {
|
|
587
|
-
let reading = false;
|
|
588
|
-
pollTimer = setInterval(() => {
|
|
589
|
-
if (reading || terminalEmitted) return;
|
|
590
|
-
reading = true;
|
|
591
|
-
void readCapturedIntents(input.capturePath!)
|
|
592
|
-
.then(records => {
|
|
593
|
-
if (records.length > 0 && captureResolve) {
|
|
594
|
-
clearInterval(pollTimer);
|
|
595
|
-
pollTimer = undefined;
|
|
596
|
-
captureResolve(records);
|
|
597
|
-
}
|
|
598
|
-
})
|
|
599
|
-
.catch(error => {
|
|
600
|
-
if (captureReject) {
|
|
601
|
-
clearInterval(pollTimer);
|
|
602
|
-
pollTimer = undefined;
|
|
603
|
-
captureReject(error);
|
|
604
|
-
}
|
|
605
|
-
})
|
|
606
|
-
.finally(() => { reading = false; });
|
|
607
|
-
}, CAPTURE_POLL_MS);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const promptContent: acp.ContentBlock[] = [
|
|
611
|
-
{ type: "text", text: promptText },
|
|
612
|
-
...(input.images ?? []).map(image => ({
|
|
613
|
-
type: "image" as const,
|
|
614
|
-
data: image.data,
|
|
615
|
-
mimeType: image.mimeType,
|
|
616
|
-
})),
|
|
617
|
-
];
|
|
618
|
-
const promptOutcome = client.prompt(session.sessionId, promptContent, input.incoming.abortSignal)
|
|
619
|
-
.then(response => ({ kind: "prompt" as const, response }))
|
|
620
|
-
.catch(error => ({ kind: "error" as const, error }));
|
|
621
|
-
const captureOutcome = capturePromise
|
|
622
|
-
.then(records => ({ kind: "capture" as const, records }))
|
|
623
|
-
.catch(error => ({ kind: "error" as const, error }));
|
|
624
|
-
const timeoutOutcome = new Promise<{ kind: "timeout" }>(resolvePromise => {
|
|
625
|
-
turnTimer = setTimeout(() => {
|
|
626
|
-
timedOut = true;
|
|
627
|
-
resolvePromise({ kind: "timeout" });
|
|
628
|
-
}, deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
const outcome = await Promise.race([promptOutcome, captureOutcome, abortOutcome, timeoutOutcome]);
|
|
632
|
-
if (pollTimer) clearInterval(pollTimer);
|
|
633
|
-
pollTimer = undefined;
|
|
634
|
-
|
|
635
|
-
if (outcome.kind === "capture") {
|
|
636
|
-
try {
|
|
637
|
-
for (const raw of outcome.records) {
|
|
638
|
-
const record = asRecord(raw);
|
|
639
|
-
const name = nonEmpty(record?.name);
|
|
640
|
-
const id = nonEmpty(record?.id);
|
|
641
|
-
if (!name || !id) throw new ToolIntentError("Gemini returned a malformed captured tool intent.");
|
|
642
|
-
const events = mapToolIntent("Gemini", name, id, record?.arguments, state, {
|
|
643
|
-
toolNames: input.toolNames,
|
|
644
|
-
maxToolCalls: input.maxToolCalls,
|
|
645
|
-
});
|
|
646
|
-
for (const event of events) deliver(event);
|
|
647
|
-
}
|
|
648
|
-
if (state.toolCalls > 0) {
|
|
649
|
-
deliver({ type: "done", stopReason: "tool_use", ...(state.usage ? { usage: state.usage } : {}) });
|
|
650
|
-
} else if (input.requireToolCall) {
|
|
651
|
-
deliver({
|
|
652
|
-
type: "error",
|
|
653
|
-
message: "Gemini ACP returned no tool call for a request that requires one.",
|
|
654
|
-
status: 502,
|
|
655
|
-
errorType: "upstream_error",
|
|
656
|
-
code: "tool_call_required",
|
|
657
|
-
retryable: false,
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
} catch (error) {
|
|
661
|
-
deliver({
|
|
662
|
-
type: "error",
|
|
663
|
-
message: safeAcpMessage(error, "Gemini returned an invalid tool call."),
|
|
664
|
-
status: 502,
|
|
665
|
-
errorType: "upstream_error",
|
|
666
|
-
code: "cli_protocol_error",
|
|
667
|
-
retryable: false,
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
if (session) void client.cancel(session.sessionId).catch(() => undefined);
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
if (outcome.kind === "abort" || aborted) {
|
|
675
|
-
deliver({
|
|
676
|
-
type: "error",
|
|
677
|
-
message: timedOut ? "Gemini ACP turn timed out." : "Gemini ACP turn was cancelled.",
|
|
678
|
-
status: timedOut ? 504 : 499,
|
|
679
|
-
errorType: timedOut ? "upstream_error" : "invalid_request_error",
|
|
680
|
-
code: timedOut ? "timeout" : "client_cancelled",
|
|
681
|
-
retryable: timedOut,
|
|
682
|
-
});
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
if (outcome.kind === "timeout") {
|
|
687
|
-
deliver({
|
|
688
|
-
type: "error",
|
|
689
|
-
message: "Gemini ACP turn timed out.",
|
|
690
|
-
status: 504,
|
|
691
|
-
errorType: "upstream_error",
|
|
692
|
-
code: "timeout",
|
|
693
|
-
retryable: true,
|
|
694
|
-
});
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
if (outcome.kind === "error") {
|
|
699
|
-
const error = outcome.error;
|
|
700
|
-
const failure = error instanceof AcpError
|
|
701
|
-
? error
|
|
702
|
-
: new AcpError(safeAcpMessage(error, "Gemini ACP failed."), 502, "upstream_error", "acp_transport_error", false, false);
|
|
703
|
-
deliver({
|
|
704
|
-
type: "error",
|
|
705
|
-
message: failure.message,
|
|
706
|
-
status: timedOut ? 504 : aborted ? 499 : failure.status,
|
|
707
|
-
errorType: timedOut || aborted ? (timedOut ? "upstream_error" : "invalid_request_error") : failure.errorType,
|
|
708
|
-
code: timedOut ? "timeout" : aborted ? "client_cancelled" : failure.code,
|
|
709
|
-
retryable: timedOut || failure.retryable,
|
|
710
|
-
});
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
const response = outcome.response;
|
|
715
|
-
const actualModel = observedModelId(response, input.modelId);
|
|
716
|
-
const usage = usageFrom(response.usage, actualModel) ?? state.usage;
|
|
717
|
-
if (usage) state.usage = usage;
|
|
718
|
-
if (aborted) {
|
|
719
|
-
deliver({
|
|
720
|
-
type: "error",
|
|
721
|
-
message: "Gemini ACP turn was cancelled.",
|
|
722
|
-
status: 499,
|
|
723
|
-
errorType: "invalid_request_error",
|
|
724
|
-
code: "client_cancelled",
|
|
725
|
-
retryable: false,
|
|
726
|
-
});
|
|
727
|
-
} else if (timedOut) {
|
|
728
|
-
deliver({
|
|
729
|
-
type: "error",
|
|
730
|
-
message: "Gemini ACP turn timed out.",
|
|
731
|
-
status: 504,
|
|
732
|
-
errorType: "upstream_error",
|
|
733
|
-
code: "timeout",
|
|
734
|
-
retryable: true,
|
|
735
|
-
});
|
|
736
|
-
} else if (state.toolCalls > 0) {
|
|
737
|
-
deliver({ type: "done", stopReason: "tool_use", ...(usage ? { usage } : {}) });
|
|
738
|
-
} else if (input.requireToolCall) {
|
|
739
|
-
deliver({
|
|
740
|
-
type: "error",
|
|
741
|
-
message: "Gemini ACP returned no tool call for a request that requires one.",
|
|
742
|
-
status: 502,
|
|
743
|
-
errorType: "upstream_error",
|
|
744
|
-
code: "tool_call_required",
|
|
745
|
-
retryable: false,
|
|
746
|
-
});
|
|
747
|
-
} else if (response.stopReason === "end_turn") {
|
|
748
|
-
deliver({ type: "done", stopReason: "stop", ...(usage ? { usage } : {}) });
|
|
749
|
-
} else if (response.stopReason === "cancelled") {
|
|
750
|
-
deliver({
|
|
751
|
-
type: "error",
|
|
752
|
-
message: "Gemini ACP cancelled the turn.",
|
|
753
|
-
status: 502,
|
|
754
|
-
errorType: "upstream_error",
|
|
755
|
-
code: "acp_cancelled",
|
|
756
|
-
retryable: false,
|
|
757
|
-
});
|
|
758
|
-
} else {
|
|
759
|
-
deliver({
|
|
760
|
-
type: "error",
|
|
761
|
-
message: `Gemini ACP finished with ${response.stopReason}.`,
|
|
762
|
-
status: response.stopReason === "max_tokens" ? 400 : 502,
|
|
763
|
-
errorType: response.stopReason === "max_tokens" ? "invalid_request_error" : "upstream_error",
|
|
764
|
-
code: "cli_protocol_error",
|
|
765
|
-
retryable: false,
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
} catch (error) {
|
|
769
|
-
if (!terminalEmitted) {
|
|
770
|
-
const failure = error instanceof AcpError
|
|
771
|
-
? error
|
|
772
|
-
: new AcpError(safeAcpMessage(error, "Gemini ACP failed."), 502, "upstream_error", "acp_transport_error", false, false);
|
|
773
|
-
deliver({
|
|
774
|
-
type: "error",
|
|
775
|
-
message: aborted && !timedOut ? "Gemini ACP turn was cancelled." : failure.message,
|
|
776
|
-
status: timedOut ? 504 : aborted ? 499 : failure.status,
|
|
777
|
-
errorType: timedOut || aborted ? (timedOut ? "upstream_error" : "invalid_request_error") : failure.errorType,
|
|
778
|
-
code: timedOut ? "timeout" : aborted ? "client_cancelled" : failure.code,
|
|
779
|
-
retryable: timedOut || failure.retryable,
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
} finally {
|
|
783
|
-
if (pollTimer) clearInterval(pollTimer);
|
|
784
|
-
if (turnTimer) clearTimeout(turnTimer);
|
|
785
|
-
input.incoming.abortSignal?.removeEventListener("abort", onAbort);
|
|
786
|
-
await client?.terminate();
|
|
787
|
-
}
|
|
788
|
-
}
|