@koreanhole/opencodex 2.95.10 → 2.96.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.
@@ -0,0 +1,629 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm, stat, 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 {
12
+ AcpClient,
13
+ AcpError,
14
+ parseAcpInitialize,
15
+ parseAcpSession,
16
+ safeAcpMessage,
17
+ type AcpDeps,
18
+ type AcpHooks,
19
+ type AcpInitialize,
20
+ type AcpModel,
21
+ type AcpSessionInfo,
22
+ } from "../acp-client";
23
+
24
+ const DEFAULT_TIMEOUT_MS = 300_000;
25
+ const MAX_STDERR_BYTES = 8 * 1024;
26
+ const MAX_CAPTURE_FILE_BYTES = 4 * 1024 * 1024;
27
+ const CAPTURE_POLL_MS = 25;
28
+ const GEMINI_POLICY = [
29
+ "[[rule]]",
30
+ 'toolName = "mcp_*"',
31
+ 'decision = "allow"',
32
+ "priority = 999",
33
+ "",
34
+ "[[rule]]",
35
+ 'toolName = "*"',
36
+ 'decision = "deny"',
37
+ "priority = 998",
38
+ "",
39
+ ].join("\n");
40
+
41
+ const GEMINI_SYSTEM_SETTINGS = JSON.stringify({
42
+ hooksConfig: { enabled: false },
43
+ skills: { enabled: false },
44
+ admin: {
45
+ secureModeEnabled: true,
46
+ extensions: { enabled: false },
47
+ skills: { enabled: false },
48
+ },
49
+ }, null, 2) + "\n";
50
+
51
+ const FORWARDED_ENV_KEYS = [
52
+ "XDG_CONFIG_HOME",
53
+ "XDG_CACHE_HOME",
54
+ "APPDATA",
55
+ "LOCALAPPDATA",
56
+ "GEMINI_API_KEY",
57
+ "GOOGLE_API_KEY",
58
+ "GOOGLE_GENAI_USE_VERTEXAI",
59
+ "GOOGLE_GENAI_USE_GCA",
60
+ "GOOGLE_CLOUD_PROJECT",
61
+ "GOOGLE_CLOUD_LOCATION",
62
+ "GOOGLE_APPLICATION_CREDENTIALS",
63
+ "GOOGLE_GEMINI_BASE_URL",
64
+ "GEMINI_API_KEY_AUTH_MECHANISM",
65
+ "GOOGLE_GENAI_API_VERSION",
66
+ "GEMINI_SYSTEM_MD",
67
+ "HTTP_PROXY",
68
+ "HTTPS_PROXY",
69
+ "ALL_PROXY",
70
+ "NO_PROXY",
71
+ "http_proxy",
72
+ "https_proxy",
73
+ "no_proxy",
74
+ ] as const;
75
+
76
+ export { AcpError as GeminiAcpError };
77
+ export type GeminiAcpModel = AcpModel;
78
+ export type GeminiAcpSessionInfo = AcpSessionInfo;
79
+ export type GeminiAcpInitialize = AcpInitialize;
80
+ export type GeminiAcpHooks = AcpHooks;
81
+ export type GeminiAcpDeps = AcpDeps;
82
+
83
+ export interface GeminiAcpMode {
84
+ acp: boolean;
85
+ }
86
+ const MODE_CACHE = new Map<string, { mtimeMs: number; size: number; acp: boolean }>();
87
+
88
+ function nonEmpty(value: unknown): string | undefined {
89
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
90
+ }
91
+
92
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
93
+ return value && typeof value === "object" && !Array.isArray(value)
94
+ ? value as Record<string, unknown>
95
+ : undefined;
96
+ }
97
+
98
+ export function geminiAcpEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
99
+ return env.OPENCODEX_GEMINI_ACP?.trim() !== "0";
100
+ }
101
+
102
+ export function parseGeminiAcpSession(response: unknown): GeminiAcpSessionInfo {
103
+ return parseAcpSession(response, "Gemini ACP", false);
104
+ }
105
+
106
+ export function parseGeminiAcpInitialize(value: unknown): GeminiAcpInitialize {
107
+ return parseAcpInitialize(value, "Gemini ACP", false);
108
+ }
109
+
110
+ export function geminiModelIds(session: GeminiAcpSessionInfo): string[] {
111
+ return session.availableModels.length > 0
112
+ ? [...new Set(session.availableModels.map(model => model.modelId))]
113
+ : ["default"];
114
+ }
115
+
116
+ /** Probe Gemini's documented ACP switch with a bounded help command. */
117
+ export async function probeGeminiAcpSupport(
118
+ binary: string,
119
+ env: Record<string, string>,
120
+ deps: GeminiAcpDeps = {},
121
+ ): Promise<GeminiAcpMode> {
122
+ if (!geminiAcpEnabled(deps.env ?? process.env)) return { acp: false };
123
+ const key = resolve(binary);
124
+ let mtimeMs: number | undefined;
125
+ let size: number | undefined;
126
+ try {
127
+ const value = await stat(key);
128
+ mtimeMs = value.mtimeMs;
129
+ size = value.size;
130
+ } catch {
131
+ return { acp: false };
132
+ }
133
+ const cached = MODE_CACHE.get(key);
134
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) return { acp: cached.acp };
135
+ const help = await runGeminiHelp(binary, env, deps);
136
+ const supported = /(?:^|\s)--acp(?:\s|,|<|$)/.test(help);
137
+ MODE_CACHE.set(key, { mtimeMs, size, acp: supported });
138
+ return { acp: supported };
139
+ }
140
+
141
+ async function runGeminiHelp(binary: string, env: Record<string, string>, deps: GeminiAcpDeps): Promise<string> {
142
+ return await new Promise((resolvePromise, reject) => {
143
+ const invocation = commandInvocation(binary, ["--help"], deps.platform ?? process.platform, { env });
144
+ const spawnFn = deps.spawn ?? (nodeSpawn as unknown as NonNullable<CliAgentTurnDeps["spawn"]>);
145
+ const child = spawnFn(invocation.file, invocation.args, {
146
+ ...invocation.options,
147
+ cwd: homedir(),
148
+ env,
149
+ stdio: ["ignore", "pipe", "pipe"],
150
+ windowsHide: true,
151
+ });
152
+ const chunks: string[] = [];
153
+ let bytes = 0;
154
+ let settled = false;
155
+ const finish = (fn: () => void): void => {
156
+ if (settled) return;
157
+ settled = true;
158
+ clearTimeout(timer);
159
+ fn();
160
+ };
161
+ const timer = setTimeout(() => {
162
+ try { child.kill("SIGKILL"); } catch { /* ignore */ }
163
+ finish(() => reject(new Error("Gemini --help probe timed out.")));
164
+ }, deps.timeoutMs ?? 15_000);
165
+ const collect = (chunk: Buffer): void => {
166
+ if (bytes >= MAX_STDERR_BYTES) return;
167
+ const admitted = chunk.subarray(0, MAX_STDERR_BYTES - bytes);
168
+ chunks.push(admitted.toString("utf8"));
169
+ bytes += admitted.byteLength;
170
+ };
171
+ child.stdout?.on("data", collect);
172
+ child.stderr?.on("data", collect);
173
+ child.once("error", error => finish(() => reject(error)));
174
+ child.once("close", () => finish(() => resolvePromise(chunks.join(""))));
175
+ });
176
+ }
177
+
178
+ /** Build the Gemini ACP environment while leaving vendor auth in its real home. */
179
+ export function buildGeminiAcpEnv(sourceEnv: NodeJS.ProcessEnv = process.env): Record<string, string> {
180
+ const env = baseScopedEnv();
181
+ const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
182
+ env.HOME = realHome;
183
+ env.USERPROFILE = realHome;
184
+ for (const key of FORWARDED_ENV_KEYS) {
185
+ const value = nonEmpty(sourceEnv[key]);
186
+ if (value) env[key] = value;
187
+ }
188
+ Object.assign(env, {
189
+ GEMINI_CLI_NO_RELAUNCH: "1",
190
+ GEMINI_TELEMETRY_ENABLED: "false",
191
+ NO_UPDATE_NOTIFIER: "1",
192
+ NO_COLOR: "1",
193
+ });
194
+ return env;
195
+ }
196
+
197
+ export interface GeminiAcpWorkspace {
198
+ cwd: string;
199
+ policyPath: string;
200
+ settingsPath: string;
201
+ catalogPath: string;
202
+ capturePath?: string;
203
+ cleanup: () => Promise<void>;
204
+ }
205
+
206
+ export async function stageGeminiAcpWorkspace(
207
+ catalog: readonly Record<string, unknown>[] = [],
208
+ ): Promise<GeminiAcpWorkspace> {
209
+ const cwd = await mkdtemp(join(tmpdir(), "ocx-gemini-acp-"));
210
+ const policyPath = join(cwd, "policy.toml");
211
+ const settingsPath = join(cwd, "system-settings.json");
212
+ const catalogPath = join(cwd, "tool-catalog.json");
213
+ const capturePath = join(cwd, "tool-intents.ndjson");
214
+ await Promise.all([
215
+ writeFile(policyPath, GEMINI_POLICY, { encoding: "utf8", mode: 0o600, flag: "wx" }),
216
+ writeFile(settingsPath, GEMINI_SYSTEM_SETTINGS, { encoding: "utf8", mode: 0o600, flag: "wx" }),
217
+ writeFile(catalogPath, JSON.stringify(catalog), { encoding: "utf8", mode: 0o600, flag: "wx" }),
218
+ ...(catalog.length > 0
219
+ ? [writeFile(capturePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" })]
220
+ : []),
221
+ ]);
222
+ return {
223
+ cwd,
224
+ policyPath,
225
+ settingsPath,
226
+ catalogPath,
227
+ ...(catalog.length > 0 ? { capturePath } : {}),
228
+ cleanup: async () => { await rm(cwd, { recursive: true, force: true }).catch(() => undefined); },
229
+ };
230
+ }
231
+
232
+ export function geminiAcpArgs(policyPath: string): string[] {
233
+ return [
234
+ "--acp",
235
+ "--skip-trust",
236
+ "--approval-mode",
237
+ "default",
238
+ "--policy",
239
+ policyPath,
240
+ "--allowed-mcp-server-names",
241
+ "opencodex",
242
+ "-e",
243
+ "none",
244
+ ];
245
+ }
246
+
247
+ export interface GeminiAcpTurnInput {
248
+ binary: string;
249
+ env: Record<string, string>;
250
+ cwd: string;
251
+ args: readonly string[];
252
+ modelId: string;
253
+ prompt: string;
254
+ toolNames: ReadonlyMap<string, string>;
255
+ mcpServers: readonly Record<string, unknown>[];
256
+ capturePath?: string;
257
+ maxToolCalls: number;
258
+ requireToolCall: boolean;
259
+ incoming: IncomingMeta;
260
+ deps?: GeminiAcpDeps;
261
+ }
262
+
263
+ function createGeminiStreamState(): CliAgentStreamState {
264
+ return {
265
+ sawText: false,
266
+ sawThinking: false,
267
+ terminal: false,
268
+ toolCalls: 0,
269
+ nextToolOrdinal: 0,
270
+ };
271
+ }
272
+
273
+ function usageFrom(value: unknown, observedModelId?: string): OcxUsage | undefined {
274
+ const record = asRecord(value);
275
+ if (!record) return undefined;
276
+ const count = (input: unknown): number =>
277
+ typeof input === "number" && Number.isFinite(input) && input >= 0 ? Math.floor(input) : 0;
278
+ const inputTokens = count(record.inputTokens);
279
+ const outputTokens = count(record.outputTokens);
280
+ const cacheRead = count(record.cachedReadTokens);
281
+ if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0) return undefined;
282
+ return {
283
+ inputTokens,
284
+ outputTokens,
285
+ totalTokens: inputTokens + outputTokens,
286
+ ...(cacheRead > 0 ? { cachedInputTokens: cacheRead, cacheReadInputTokens: cacheRead } : {}),
287
+ ...(observedModelId ? { modelId: observedModelId } : {}),
288
+ };
289
+ }
290
+
291
+ function observedModelId(response: acp.PromptResponse, expected: string): string | undefined {
292
+ const meta = asRecord(response._meta);
293
+ const quota = asRecord(meta?.quota);
294
+ const usage = Array.isArray(quota?.model_usage) ? quota.model_usage : [];
295
+ const ids = [...new Set(usage.flatMap(item => {
296
+ const model = nonEmpty(asRecord(item)?.model);
297
+ return model ? [model] : [];
298
+ }))];
299
+ return ids.includes(expected) ? expected : ids.length === 1 ? ids[0] : ids[0];
300
+ }
301
+
302
+ function unsupportedModel(modelId: string): AcpError {
303
+ return new AcpError(
304
+ `Gemini ACP cannot select model "${modelId}".`,
305
+ 400,
306
+ "invalid_request_error",
307
+ "gemini_model_unsupported",
308
+ false,
309
+ false,
310
+ );
311
+ }
312
+
313
+ export async function applyGeminiModel(
314
+ client: AcpClient,
315
+ session: GeminiAcpSessionInfo,
316
+ modelId: string,
317
+ ): Promise<void> {
318
+ if (modelId === "default") return;
319
+ if (!session.availableModels.some(model => model.modelId === modelId)) throw unsupportedModel(modelId);
320
+ try {
321
+ await client.setModel(session.sessionId, modelId);
322
+ } catch (error) {
323
+ if (error instanceof AcpError && error.code === "timeout") throw error;
324
+ throw unsupportedModel(modelId);
325
+ }
326
+ }
327
+
328
+ async function readCapturedIntents(path: string): Promise<unknown[]> {
329
+ let bytes: Buffer;
330
+ try {
331
+ bytes = await readFile(path);
332
+ } catch {
333
+ return [];
334
+ }
335
+ if (bytes.byteLength > MAX_CAPTURE_FILE_BYTES) {
336
+ throw new ToolIntentError("Gemini tool-intent capture exceeded the byte limit.");
337
+ }
338
+ const text = bytes.toString("utf8");
339
+ if (!text.endsWith("\n")) return [];
340
+ return text.split("\n").filter(Boolean).flatMap(line => {
341
+ try {
342
+ const value = JSON.parse(line) as unknown;
343
+ return value && typeof value === "object" && !Array.isArray(value) ? [value] : [];
344
+ } catch {
345
+ throw new ToolIntentError("Gemini returned a malformed captured tool intent.");
346
+ }
347
+ });
348
+ }
349
+
350
+ /** Run one Gemini ACP turn and return any tool intent to the external client. */
351
+ export async function runGeminiAcpTurn(
352
+ input: GeminiAcpTurnInput,
353
+ emit: (event: AdapterEvent) => void,
354
+ ): Promise<void> {
355
+ const deps = input.deps ?? {};
356
+ const state = createGeminiStreamState();
357
+ let client: AcpClient | undefined;
358
+ let session: GeminiAcpSessionInfo | undefined;
359
+ let terminalEmitted = false;
360
+ let aborted = false;
361
+ let timedOut = false;
362
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
363
+ let turnTimer: ReturnType<typeof setTimeout> | undefined;
364
+ let captureResolve: ((records: unknown[]) => void) | undefined;
365
+ let captureReject: ((error: unknown) => void) | undefined;
366
+ let resolveAbort: (() => void) | undefined;
367
+
368
+ const deliver = (event: AdapterEvent): void => {
369
+ if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
370
+ if (terminalEmitted) return;
371
+ terminalEmitted = true;
372
+ }
373
+ emit(event);
374
+ };
375
+
376
+ const capturePromise = input.capturePath
377
+ ? new Promise<unknown[]>((resolvePromise, reject) => {
378
+ captureResolve = resolvePromise;
379
+ captureReject = reject;
380
+ })
381
+ : new Promise<never>(() => undefined);
382
+
383
+ const hooks: GeminiAcpHooks = {
384
+ onSessionUpdate: notification => {
385
+ const update = notification.update;
386
+ if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text" && update.content.text) {
387
+ state.sawText = true;
388
+ deliver({ type: "text_delta", text: update.content.text });
389
+ } else if (update.sessionUpdate === "agent_thought_chunk" && update.content.type === "text" && update.content.text) {
390
+ state.sawThinking = true;
391
+ deliver({ type: "thinking_delta", thinking: update.content.text });
392
+ }
393
+ },
394
+ onPermission: () => {
395
+ if (session) void client?.cancel(session.sessionId).catch(() => undefined);
396
+ },
397
+ };
398
+
399
+ const onAbort = (): void => {
400
+ aborted = true;
401
+ resolveAbort?.();
402
+ if (session) void client?.cancel(session.sessionId).catch(() => undefined);
403
+ };
404
+
405
+ try {
406
+ client = await AcpClient.connect({
407
+ binary: input.binary,
408
+ env: input.env,
409
+ cwd: input.cwd,
410
+ args: input.args,
411
+ hooks,
412
+ label: "Gemini ACP",
413
+ fallbackAllowed: false,
414
+ }, deps);
415
+ session = await client.newSession({ cwd: input.cwd, mcpServers: input.mcpServers });
416
+ await applyGeminiModel(client, session, input.modelId);
417
+
418
+ if (input.incoming.abortSignal?.aborted) {
419
+ deliver({
420
+ type: "error",
421
+ message: "Gemini ACP turn was aborted before the prompt.",
422
+ status: 499,
423
+ errorType: "invalid_request_error",
424
+ code: "client_cancelled",
425
+ retryable: false,
426
+ });
427
+ return;
428
+ }
429
+
430
+ const abortOutcome = new Promise<{ kind: "abort" }>(resolvePromise => {
431
+ resolveAbort = () => resolvePromise({ kind: "abort" });
432
+ if (input.incoming.abortSignal?.aborted) resolveAbort();
433
+ else input.incoming.abortSignal?.addEventListener("abort", onAbort, { once: true });
434
+ });
435
+ if (input.capturePath) {
436
+ let reading = false;
437
+ pollTimer = setInterval(() => {
438
+ if (reading || terminalEmitted) return;
439
+ reading = true;
440
+ void readCapturedIntents(input.capturePath!)
441
+ .then(records => {
442
+ if (records.length > 0 && captureResolve) {
443
+ clearInterval(pollTimer);
444
+ pollTimer = undefined;
445
+ captureResolve(records);
446
+ }
447
+ })
448
+ .catch(error => {
449
+ if (captureReject) {
450
+ clearInterval(pollTimer);
451
+ pollTimer = undefined;
452
+ captureReject(error);
453
+ }
454
+ })
455
+ .finally(() => { reading = false; });
456
+ }, CAPTURE_POLL_MS);
457
+ }
458
+
459
+ const promptOutcome = client.prompt(session.sessionId, input.prompt, input.incoming.abortSignal)
460
+ .then(response => ({ kind: "prompt" as const, response }))
461
+ .catch(error => ({ kind: "error" as const, error }));
462
+ const captureOutcome = capturePromise
463
+ .then(records => ({ kind: "capture" as const, records }))
464
+ .catch(error => ({ kind: "error" as const, error }));
465
+ const timeoutOutcome = new Promise<{ kind: "timeout" }>(resolvePromise => {
466
+ turnTimer = setTimeout(() => {
467
+ timedOut = true;
468
+ resolvePromise({ kind: "timeout" });
469
+ }, deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
470
+ });
471
+
472
+ const outcome = await Promise.race([promptOutcome, captureOutcome, abortOutcome, timeoutOutcome]);
473
+ if (pollTimer) clearInterval(pollTimer);
474
+ pollTimer = undefined;
475
+
476
+ if (outcome.kind === "capture") {
477
+ try {
478
+ for (const raw of outcome.records) {
479
+ const record = asRecord(raw);
480
+ const name = nonEmpty(record?.name);
481
+ const id = nonEmpty(record?.id);
482
+ if (!name || !id) throw new ToolIntentError("Gemini returned a malformed captured tool intent.");
483
+ const events = mapToolIntent("Gemini", name, id, record?.arguments, state, {
484
+ toolNames: input.toolNames,
485
+ maxToolCalls: input.maxToolCalls,
486
+ });
487
+ for (const event of events) deliver(event);
488
+ }
489
+ if (state.toolCalls > 0) {
490
+ deliver({ type: "done", stopReason: "tool_use", ...(state.usage ? { usage: state.usage } : {}) });
491
+ } else if (input.requireToolCall) {
492
+ deliver({
493
+ type: "error",
494
+ message: "Gemini ACP returned no tool call for a request that requires one.",
495
+ status: 502,
496
+ errorType: "upstream_error",
497
+ code: "tool_call_required",
498
+ retryable: false,
499
+ });
500
+ }
501
+ } catch (error) {
502
+ deliver({
503
+ type: "error",
504
+ message: safeAcpMessage(error, "Gemini returned an invalid tool call."),
505
+ status: 502,
506
+ errorType: "upstream_error",
507
+ code: "cli_protocol_error",
508
+ retryable: false,
509
+ });
510
+ }
511
+ if (session) void client.cancel(session.sessionId).catch(() => undefined);
512
+ return;
513
+ }
514
+
515
+ if (outcome.kind === "abort" || aborted) {
516
+ deliver({
517
+ type: "error",
518
+ message: timedOut ? "Gemini ACP turn timed out." : "Gemini ACP turn was cancelled.",
519
+ status: timedOut ? 504 : 499,
520
+ errorType: timedOut ? "upstream_error" : "invalid_request_error",
521
+ code: timedOut ? "timeout" : "client_cancelled",
522
+ retryable: timedOut,
523
+ });
524
+ return;
525
+ }
526
+
527
+ if (outcome.kind === "timeout") {
528
+ deliver({
529
+ type: "error",
530
+ message: "Gemini ACP turn timed out.",
531
+ status: 504,
532
+ errorType: "upstream_error",
533
+ code: "timeout",
534
+ retryable: true,
535
+ });
536
+ return;
537
+ }
538
+
539
+ if (outcome.kind === "error") {
540
+ const error = outcome.error;
541
+ const failure = error instanceof AcpError
542
+ ? error
543
+ : new AcpError(safeAcpMessage(error, "Gemini ACP failed."), 502, "upstream_error", "acp_transport_error", false, false);
544
+ deliver({
545
+ type: "error",
546
+ message: failure.message,
547
+ status: timedOut ? 504 : aborted ? 499 : failure.status,
548
+ errorType: timedOut || aborted ? (timedOut ? "upstream_error" : "invalid_request_error") : failure.errorType,
549
+ code: timedOut ? "timeout" : aborted ? "client_cancelled" : failure.code,
550
+ retryable: timedOut || failure.retryable,
551
+ });
552
+ return;
553
+ }
554
+
555
+ const response = outcome.response;
556
+ const actualModel = observedModelId(response, input.modelId);
557
+ const usage = usageFrom(response.usage, actualModel) ?? state.usage;
558
+ if (usage) state.usage = usage;
559
+ if (aborted) {
560
+ deliver({
561
+ type: "error",
562
+ message: "Gemini ACP turn was cancelled.",
563
+ status: 499,
564
+ errorType: "invalid_request_error",
565
+ code: "client_cancelled",
566
+ retryable: false,
567
+ });
568
+ } else if (timedOut) {
569
+ deliver({
570
+ type: "error",
571
+ message: "Gemini ACP turn timed out.",
572
+ status: 504,
573
+ errorType: "upstream_error",
574
+ code: "timeout",
575
+ retryable: true,
576
+ });
577
+ } else if (state.toolCalls > 0) {
578
+ deliver({ type: "done", stopReason: "tool_use", ...(usage ? { usage } : {}) });
579
+ } else if (input.requireToolCall) {
580
+ deliver({
581
+ type: "error",
582
+ message: "Gemini ACP returned no tool call for a request that requires one.",
583
+ status: 502,
584
+ errorType: "upstream_error",
585
+ code: "tool_call_required",
586
+ retryable: false,
587
+ });
588
+ } else if (response.stopReason === "end_turn") {
589
+ deliver({ type: "done", stopReason: "stop", ...(usage ? { usage } : {}) });
590
+ } else if (response.stopReason === "cancelled") {
591
+ deliver({
592
+ type: "error",
593
+ message: "Gemini ACP cancelled the turn.",
594
+ status: 502,
595
+ errorType: "upstream_error",
596
+ code: "acp_cancelled",
597
+ retryable: false,
598
+ });
599
+ } else {
600
+ deliver({
601
+ type: "error",
602
+ message: `Gemini ACP finished with ${response.stopReason}.`,
603
+ status: response.stopReason === "max_tokens" ? 400 : 502,
604
+ errorType: response.stopReason === "max_tokens" ? "invalid_request_error" : "upstream_error",
605
+ code: "cli_protocol_error",
606
+ retryable: false,
607
+ });
608
+ }
609
+ } catch (error) {
610
+ if (!terminalEmitted) {
611
+ const failure = error instanceof AcpError
612
+ ? error
613
+ : new AcpError(safeAcpMessage(error, "Gemini ACP failed."), 502, "upstream_error", "acp_transport_error", false, false);
614
+ deliver({
615
+ type: "error",
616
+ message: aborted && !timedOut ? "Gemini ACP turn was cancelled." : failure.message,
617
+ status: timedOut ? 504 : aborted ? 499 : failure.status,
618
+ errorType: timedOut || aborted ? (timedOut ? "upstream_error" : "invalid_request_error") : failure.errorType,
619
+ code: timedOut ? "timeout" : aborted ? "client_cancelled" : failure.code,
620
+ retryable: timedOut || failure.retryable,
621
+ });
622
+ }
623
+ } finally {
624
+ if (pollTimer) clearInterval(pollTimer);
625
+ if (turnTimer) clearTimeout(turnTimer);
626
+ input.incoming.abortSignal?.removeEventListener("abort", onAbort);
627
+ await client?.terminate();
628
+ }
629
+ }