@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.
@@ -0,0 +1,654 @@
1
+ import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
2
+ import { link, lstat, mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import type { AdapterEvent, IncomingMeta, OcxUsage } from "../../../types";
6
+ import { redactSecretString } from "../../../lib/redact";
7
+ import { commandInvocation } from "../../../lib/win-exec";
8
+ import { baseScopedEnv } from "../env";
9
+ import { readJsonLines } from "../protocol";
10
+ import type { CliAgentTurnDeps } from "../types";
11
+
12
+ const DEFAULT_TIMEOUT_MS = 300_000;
13
+ const DEFAULT_KILL_GRACE_MS = 2_000;
14
+ const HELP_TIMEOUT_MS = 15_000;
15
+ const MAX_HELP_BYTES = 256 * 1024;
16
+ const MAX_STDERR_BYTES = 8 * 1024;
17
+ const MAX_ERROR_CHARS = 2_048;
18
+
19
+ const GEMINI_SYSTEM_SETTINGS = JSON.stringify({
20
+ hooksConfig: { enabled: false },
21
+ skills: { enabled: false },
22
+ mcp: { excluded: ["*"] },
23
+ admin: {
24
+ secureModeEnabled: true,
25
+ extensions: { enabled: false },
26
+ skills: { enabled: false },
27
+ },
28
+ }, null, 2) + "\n";
29
+
30
+ const FORWARDED_ENV_KEYS = [
31
+ "XDG_CONFIG_HOME",
32
+ "XDG_CACHE_HOME",
33
+ "APPDATA",
34
+ "LOCALAPPDATA",
35
+ "GEMINI_API_KEY",
36
+ "GOOGLE_API_KEY",
37
+ "GOOGLE_GENAI_USE_VERTEXAI",
38
+ "GOOGLE_GENAI_USE_GCA",
39
+ "GOOGLE_CLOUD_PROJECT",
40
+ "GOOGLE_CLOUD_LOCATION",
41
+ "GOOGLE_APPLICATION_CREDENTIALS",
42
+ "GOOGLE_GEMINI_BASE_URL",
43
+ "GEMINI_API_KEY_AUTH_MECHANISM",
44
+ "GOOGLE_GENAI_API_VERSION",
45
+ "GEMINI_SYSTEM_MD",
46
+ "HTTP_PROXY",
47
+ "HTTPS_PROXY",
48
+ "ALL_PROXY",
49
+ "NO_PROXY",
50
+ "http_proxy",
51
+ "https_proxy",
52
+ "no_proxy",
53
+ ] as const;
54
+
55
+ const AUTH_FILE_NAMES = ["oauth_creds.json", "google_accounts.json", ".env"] as const;
56
+
57
+ export interface GeminiHeadlessDeps extends CliAgentTurnDeps {
58
+ env?: NodeJS.ProcessEnv;
59
+ }
60
+
61
+ export interface GeminiHeadlessMode {
62
+ headless: boolean;
63
+ detail?: string;
64
+ }
65
+
66
+ interface ModeCacheEntry {
67
+ mtimeMs: number;
68
+ size: number;
69
+ mode: GeminiHeadlessMode;
70
+ }
71
+
72
+ const MODE_CACHE = new Map<string, ModeCacheEntry>();
73
+
74
+ function nonEmpty(value: unknown): string | undefined {
75
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
76
+ }
77
+
78
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
79
+ return value && typeof value === "object" && !Array.isArray(value)
80
+ ? value as Record<string, unknown>
81
+ : undefined;
82
+ }
83
+
84
+ function asString(value: unknown): string | undefined {
85
+ return typeof value === "string" ? value : undefined;
86
+ }
87
+
88
+ function safeMessage(value: unknown, fallback: string): string {
89
+ const raw = typeof value === "string"
90
+ ? value
91
+ : value instanceof Error
92
+ ? value.message
93
+ : value === undefined
94
+ ? fallback
95
+ : JSON.stringify(value) ?? fallback;
96
+ return redactSecretString(raw).slice(0, MAX_ERROR_CHARS);
97
+ }
98
+
99
+ function optionPresent(text: string, option: string): boolean {
100
+ const escaped = option.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
101
+ return new RegExp("(?:^|\\s)" + escaped + "(?:\\s|,|=|\\[|<|$)").test(text);
102
+ }
103
+
104
+ export function resetGeminiHeadlessCacheForTests(): void {
105
+ MODE_CACHE.clear();
106
+ }
107
+
108
+ export function geminiHeadlessEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
109
+ return env.OPENCODEX_GEMINI_ACP?.trim() !== "0";
110
+ }
111
+
112
+ export function buildGeminiHeadlessEnv(
113
+ sourceEnv: NodeJS.ProcessEnv = process.env,
114
+ platform: NodeJS.Platform = process.platform,
115
+ ): Record<string, string> {
116
+ const env = baseScopedEnv(sourceEnv);
117
+ const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
118
+ env.HOME = realHome;
119
+ env.USERPROFILE = realHome;
120
+ for (const key of FORWARDED_ENV_KEYS) {
121
+ const value = nonEmpty(sourceEnv[key]);
122
+ if (value) env[key] = value;
123
+ }
124
+ Object.assign(env, {
125
+ GEMINI_CLI_TRUST_WORKSPACE: "true",
126
+ GEMINI_CLI_NO_RELAUNCH: "1",
127
+ GEMINI_TELEMETRY_ENABLED: "false",
128
+ NO_UPDATE_NOTIFIER: "1",
129
+ NO_COLOR: "1",
130
+ });
131
+ if (platform === "win32") env.NODE_USE_SYSTEM_CA = "1";
132
+ return env;
133
+ }
134
+
135
+ export async function probeGeminiHeadlessSupport(
136
+ binary: string,
137
+ env: Record<string, string>,
138
+ deps: GeminiHeadlessDeps = {},
139
+ ): Promise<GeminiHeadlessMode> {
140
+ if (!geminiHeadlessEnabled(deps.env ?? process.env)) {
141
+ return { headless: false, detail: "Gemini provider is disabled by OPENCODEX_GEMINI_ACP=0." };
142
+ }
143
+ const key = resolve(binary);
144
+ let mtimeMs: number | undefined;
145
+ let size: number | undefined;
146
+ try {
147
+ const value = await stat(key);
148
+ mtimeMs = value.mtimeMs;
149
+ size = value.size;
150
+ } catch {
151
+ return { headless: false, detail: "Gemini CLI binary was not found." };
152
+ }
153
+ const cached = MODE_CACHE.get(key);
154
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) return cached.mode;
155
+ const help = await runGeminiHelp(binary, env, deps);
156
+ const supported = optionPresent(help, "--prompt")
157
+ && optionPresent(help, "--output-format")
158
+ && optionPresent(help, "--skip-trust")
159
+ && optionPresent(help, "--approval-mode");
160
+ const mode: GeminiHeadlessMode = supported
161
+ ? { headless: true }
162
+ : { headless: false, detail: "Gemini --help does not advertise -p/--prompt, --output-format, --skip-trust, and --approval-mode." };
163
+ MODE_CACHE.set(key, { mtimeMs: mtimeMs!, size: size!, mode });
164
+ return mode;
165
+ }
166
+
167
+ async function runGeminiHelp(
168
+ binary: string,
169
+ env: Record<string, string>,
170
+ deps: CliAgentTurnDeps,
171
+ ): Promise<string> {
172
+ return await new Promise((resolvePromise, reject) => {
173
+ const invocation = commandInvocation(binary, ["--help"], deps.platform ?? process.platform, { env });
174
+ const spawnFn = deps.spawn ?? (nodeSpawn as unknown as NonNullable<CliAgentTurnDeps["spawn"]>);
175
+ const child = spawnFn(invocation.file, invocation.args, {
176
+ ...invocation.options,
177
+ cwd: homedir(),
178
+ env,
179
+ stdio: ["ignore", "pipe", "pipe"],
180
+ windowsHide: true,
181
+ });
182
+ const chunks: string[] = [];
183
+ let bytes = 0;
184
+ let settled = false;
185
+ const finish = (fn: () => void): void => {
186
+ if (settled) return;
187
+ settled = true;
188
+ clearTimeout(timer);
189
+ fn();
190
+ };
191
+ const timer = setTimeout(() => {
192
+ try { child.kill("SIGKILL"); } catch { /* ignore */ }
193
+ finish(() => reject(new Error("Gemini --help probe timed out.")));
194
+ }, deps.timeoutMs ?? HELP_TIMEOUT_MS);
195
+ const collect = (chunk: Buffer): void => {
196
+ if (bytes >= MAX_HELP_BYTES) return;
197
+ const admitted = chunk.subarray(0, MAX_HELP_BYTES - bytes);
198
+ chunks.push(admitted.toString("utf8"));
199
+ bytes += admitted.byteLength;
200
+ };
201
+ child.stdout?.on("data", collect);
202
+ child.stderr?.on("data", collect);
203
+ child.once("error", error => finish(() => reject(error)));
204
+ child.once("close", () => finish(() => resolvePromise(chunks.join(""))));
205
+ });
206
+ }
207
+
208
+ export interface GeminiHeadlessWorkspace {
209
+ root: string;
210
+ geminiHome: string;
211
+ settingsPath: string;
212
+ cleanup: () => Promise<void>;
213
+ }
214
+
215
+ export interface GeminiReasoningSelection {
216
+ modelId: string;
217
+ thinkingConfig: {
218
+ thinkingLevel?: string;
219
+ thinkingBudget?: number;
220
+ includeThoughts?: boolean;
221
+ };
222
+ }
223
+
224
+ function geminiSystemSettings(reasoning?: GeminiReasoningSelection): string {
225
+ const settings = JSON.parse(GEMINI_SYSTEM_SETTINGS) as Record<string, unknown>;
226
+ if (reasoning) {
227
+ settings.modelConfigs = {
228
+ customOverrides: [{
229
+ match: { model: reasoning.modelId },
230
+ modelConfig: {
231
+ generateContentConfig: {
232
+ thinkingConfig: reasoning.thinkingConfig,
233
+ },
234
+ },
235
+ }],
236
+ };
237
+ }
238
+ return `${JSON.stringify(settings, null, 2)}\n`;
239
+ }
240
+
241
+ export async function stageGeminiHeadlessWorkspace(
242
+ reasoning?: GeminiReasoningSelection,
243
+ options: {
244
+ root?: string;
245
+ sourceEnv?: NodeJS.ProcessEnv;
246
+ } = {},
247
+ ): Promise<GeminiHeadlessWorkspace> {
248
+ const root = options.root ?? await mkdtemp(join(tmpdir(), "ocx-gemini-headless-"));
249
+ await mkdir(root, { recursive: true, mode: 0o700 });
250
+ const geminiHome = join(root, "gemini-home");
251
+ const settingsPath = join(root, "settings.json");
252
+ const geminiConfigDir = join(geminiHome, ".gemini");
253
+ const exclusive = options.root === undefined;
254
+ const writeOptions = { encoding: "utf8" as const, mode: 0o600, ...(exclusive ? { flag: "wx" as const } : {}) };
255
+ await Promise.all([
256
+ mkdir(geminiConfigDir, { recursive: true, mode: 0o700 }),
257
+ writeFile(settingsPath, geminiSystemSettings(reasoning), writeOptions),
258
+ ]);
259
+ await linkGeminiAuth(geminiConfigDir, options.sourceEnv ?? process.env);
260
+ return {
261
+ root,
262
+ geminiHome,
263
+ settingsPath,
264
+ cleanup: async () => { await rm(root, { recursive: true, force: true }).catch(() => undefined); },
265
+ };
266
+ }
267
+
268
+ async function linkGeminiAuth(targetDir: string, sourceEnv: NodeJS.ProcessEnv): Promise<void> {
269
+ const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
270
+ const realGemini = join(realHome, ".gemini");
271
+ await Promise.all(AUTH_FILE_NAMES.map(async name => {
272
+ const target = join(realGemini, name);
273
+ try {
274
+ await lstat(target);
275
+ } catch {
276
+ return;
277
+ }
278
+ const path = join(targetDir, name);
279
+ try {
280
+ await symlink(resolve(target), path);
281
+ } catch {
282
+ try {
283
+ await link(resolve(target), path);
284
+ } catch {
285
+ // Best-effort; environment-provided auth remains the primary contract.
286
+ }
287
+ }
288
+ }));
289
+ }
290
+
291
+ export interface GeminiHeadlessArgsInput {
292
+ modelId?: string;
293
+ resumeSessionId?: string;
294
+ }
295
+
296
+ export function geminiHeadlessArgs(input: GeminiHeadlessArgsInput = {}): string[] {
297
+ const args = [
298
+ "-p", "",
299
+ "--output-format", "stream-json",
300
+ "--skip-trust",
301
+ "--approval-mode", "auto_edit",
302
+ "-e", "none",
303
+ ];
304
+ if (input.modelId && input.modelId !== "default") args.push("--model", input.modelId);
305
+ if (input.resumeSessionId) args.push("--resume", input.resumeSessionId);
306
+ return args;
307
+ }
308
+
309
+ export interface GeminiHeadlessTurnInput {
310
+ binary: string;
311
+ env: Record<string, string>;
312
+ cwd: string;
313
+ args: readonly string[];
314
+ modelId: string;
315
+ prompt: string;
316
+ deltaPrompt?: string;
317
+ resumeSessionId?: string;
318
+ sessionKeySource?: string;
319
+ hadPreviousResponseId?: boolean;
320
+ incoming: IncomingMeta;
321
+ onVendorSession?: (sessionId: string) => void;
322
+ onResumeFailed?: () => void;
323
+ deps?: CliAgentTurnDeps;
324
+ }
325
+
326
+ interface AttemptResult {
327
+ terminal: Extract<AdapterEvent, { type: "done" | "error" | "incomplete" }>;
328
+ sessionId?: string;
329
+ sawText: boolean;
330
+ resumeFailed: boolean;
331
+ }
332
+
333
+ function usageFromStats(value: unknown, modelId?: string): OcxUsage | undefined {
334
+ const stats = asRecord(value);
335
+ if (!stats) return undefined;
336
+ const count = (input: unknown): number =>
337
+ typeof input === "number" && Number.isFinite(input) && input >= 0 ? Math.floor(input) : 0;
338
+ const inputTokens = count(stats.input_tokens ?? stats.inputTokens);
339
+ const outputTokens = count(stats.output_tokens ?? stats.outputTokens);
340
+ const cached = count(stats.cached ?? stats.cached_tokens ?? stats.cachedTokens);
341
+ const totalTokens = count(stats.total_tokens ?? stats.totalTokens) || inputTokens + outputTokens;
342
+ if (inputTokens === 0 && outputTokens === 0 && cached === 0) return undefined;
343
+ return {
344
+ inputTokens,
345
+ outputTokens,
346
+ totalTokens,
347
+ ...(modelId ? { modelId } : {}),
348
+ ...(cached > 0 ? { cachedInputTokens: cached, cacheReadInputTokens: cached } : {}),
349
+ };
350
+ }
351
+
352
+ function classifyError(message: string): { status: number; errorType: string; code: string; retryable: boolean } {
353
+ if (/session.*(?:not found|unknown|invalid|expired)|resume.*(?:not found|failed|invalid)/i.test(message)) {
354
+ return { status: 502, errorType: "upstream_error", code: "gemini_session_resume_failed", retryable: false };
355
+ }
356
+ if (/not logged in|please (?:run )?\/?login|sign in|authentication (?:failed|required)|unauthorized|invalid (?:api )?key/i.test(message)) {
357
+ return { status: 401, errorType: "authentication_error", code: "gemini_cli_not_logged_in", retryable: false };
358
+ }
359
+ if (/rate limit|too many requests|quota|credit|usage limit|daily limit/i.test(message)) {
360
+ return { status: 429, errorType: "rate_limit_error", code: "rate_limit_exceeded", retryable: true };
361
+ }
362
+ if (/model (?:is )?(?:not found|unavailable|unsupported)|invalid model/i.test(message)) {
363
+ return { status: 400, errorType: "invalid_request_error", code: "model_not_found", retryable: false };
364
+ }
365
+ return { status: 502, errorType: "upstream_error", code: "cli_protocol_error", retryable: false };
366
+ }
367
+
368
+ function errorEvent(error: unknown): Extract<AdapterEvent, { type: "error" }> {
369
+ const message = safeMessage(error, "Gemini headless turn failed.");
370
+ return { type: "error", message, ...classifyError(message) };
371
+ }
372
+
373
+ function classifyFailure(exitCode: number, stderr: string): Extract<AdapterEvent, { type: "error" }> {
374
+ const message = safeMessage(stderr.trim() || `Gemini headless exited with code ${exitCode}.`, "Gemini headless turn failed.");
375
+ return { type: "error", message, ...classifyError(message) };
376
+ }
377
+
378
+ function killWindowsProcessTree(pid: number): void {
379
+ const taskkill = (process.env.SystemRoot ?? "C:\\Windows") + "\\System32\\taskkill.exe";
380
+ nodeSpawn(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
381
+ }
382
+
383
+ async function runAttempt(
384
+ input: GeminiHeadlessTurnInput,
385
+ emit: (event: AdapterEvent) => void,
386
+ deps: CliAgentTurnDeps,
387
+ resumeSessionId: string | undefined,
388
+ ): Promise<AttemptResult> {
389
+ const spawnFn = deps.spawn ?? (nodeSpawn as unknown as NonNullable<CliAgentTurnDeps["spawn"]>);
390
+ const platform = deps.platform ?? process.platform;
391
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
392
+ const killGraceMs = deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
393
+ const reapTimeoutMs = deps.reapTimeoutMs ?? killGraceMs * 2 + 250;
394
+ const promptText = resumeSessionId && input.deltaPrompt ? input.deltaPrompt : input.prompt;
395
+ const stderr: string[] = [];
396
+ let stderrBytes = 0;
397
+ let child: ChildProcess | undefined;
398
+ let terminal: AttemptResult["terminal"] | undefined;
399
+ let sawText = false;
400
+ let aborted = false;
401
+ let timedOut = false;
402
+ let spawnError: Error | undefined;
403
+ let exitCode: number | null = null;
404
+ let sessionId: string | undefined;
405
+ let observedModel: string | undefined;
406
+ let usage: OcxUsage | undefined;
407
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
408
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
409
+ let lifecycle: Promise<void> | undefined;
410
+
411
+ const finish = (event: AttemptResult["terminal"]): void => {
412
+ if (!terminal) terminal = event;
413
+ };
414
+
415
+ const stopStdout = (): void => {
416
+ try { child?.stdout?.destroy(); } catch { /* already closed */ }
417
+ };
418
+
419
+ const kill = (): void => {
420
+ if (!child || child.killed) return;
421
+ if (platform === "win32" && child.pid !== undefined) {
422
+ try {
423
+ (deps.killWindowsProcessTree ?? killWindowsProcessTree)(child.pid);
424
+ return;
425
+ } catch {
426
+ // Fall through to the direct termination ladder.
427
+ }
428
+ }
429
+ try { child.kill("SIGTERM"); } catch { /* already stopped */ }
430
+ killTimer = setTimeout(() => {
431
+ try { child?.kill("SIGKILL"); } catch { /* already stopped */ }
432
+ }, killGraceMs);
433
+ };
434
+
435
+ if (input.incoming.abortSignal?.aborted) {
436
+ return {
437
+ terminal: {
438
+ type: "error",
439
+ message: "Gemini headless turn was cancelled.",
440
+ status: 499,
441
+ errorType: "invalid_request_error",
442
+ code: "client_cancelled",
443
+ retryable: false,
444
+ },
445
+ sawText: false,
446
+ resumeFailed: false,
447
+ };
448
+ }
449
+
450
+ const onAbort = (): void => {
451
+ if (terminal) return;
452
+ aborted = true;
453
+ kill();
454
+ stopStdout();
455
+ finish({
456
+ type: "error",
457
+ message: "Gemini headless turn was cancelled.",
458
+ status: 499,
459
+ errorType: "invalid_request_error",
460
+ code: "client_cancelled",
461
+ retryable: false,
462
+ });
463
+ };
464
+
465
+ try {
466
+ const args = [...input.args];
467
+ if (resumeSessionId) args.push("--resume", resumeSessionId);
468
+ const invocation = commandInvocation(input.binary, args, platform, { env: input.env });
469
+ child = spawnFn(invocation.file, invocation.args, {
470
+ ...invocation.options,
471
+ cwd: input.cwd,
472
+ env: input.env,
473
+ stdio: ["pipe", "pipe", "pipe"],
474
+ windowsHide: true,
475
+ });
476
+ lifecycle = new Promise<void>(resolvePromise => {
477
+ let settled = false;
478
+ const settle = (): void => {
479
+ if (settled) return;
480
+ settled = true;
481
+ resolvePromise();
482
+ };
483
+ child?.once("error", error => {
484
+ spawnError = error;
485
+ if (child?.pid === undefined) settle();
486
+ });
487
+ child?.once("close", code => {
488
+ exitCode = code;
489
+ settle();
490
+ });
491
+ });
492
+
493
+ input.incoming.abortSignal?.addEventListener("abort", onAbort, { once: true });
494
+ timeoutTimer = setTimeout(() => {
495
+ if (terminal) return;
496
+ timedOut = true;
497
+ kill();
498
+ stopStdout();
499
+ finish({
500
+ type: "error",
501
+ message: "Gemini headless turn timed out.",
502
+ status: 504,
503
+ errorType: "upstream_error",
504
+ code: "timeout",
505
+ retryable: true,
506
+ });
507
+ }, timeoutMs);
508
+
509
+ child.stderr?.setEncoding("utf8");
510
+ child.stderr?.on("data", (chunk: string) => {
511
+ if (stderrBytes >= MAX_STDERR_BYTES) return;
512
+ const remaining = MAX_STDERR_BYTES - stderrBytes;
513
+ const admitted = chunk.slice(0, remaining);
514
+ stderr.push(admitted);
515
+ stderrBytes += Buffer.byteLength(admitted);
516
+ });
517
+
518
+ if (!child.stdin) throw new Error("Gemini headless process produced no stdin stream.");
519
+ if (!child.stdout) throw new Error("Gemini headless process produced no stdout stream.");
520
+ child.stdin.on("error", () => undefined);
521
+ child.stdin.end(promptText);
522
+
523
+ for await (const message of readJsonLines(child.stdout, {}, "Gemini headless")) {
524
+ if (terminal) break;
525
+ const type = asString(message.type);
526
+ if (type === "init") {
527
+ sessionId = nonEmpty(message.session_id);
528
+ observedModel = nonEmpty(message.model);
529
+ if (sessionId) input.onVendorSession?.(sessionId);
530
+ continue;
531
+ }
532
+ if (type === "message") {
533
+ if (message.role !== "assistant") continue;
534
+ const content = asString(message.content);
535
+ if (!content) continue;
536
+ sawText = true;
537
+ emit({ type: "text_delta", text: content });
538
+ continue;
539
+ }
540
+ if (type === "error") {
541
+ if (message.severity === "warning") continue;
542
+ const messageText = safeMessage(message.message, "Gemini headless turn failed.");
543
+ finish({ type: "error", message: messageText, ...classifyError(messageText) });
544
+ continue;
545
+ }
546
+ if (type === "result") {
547
+ usage = usageFromStats(message.stats, observedModel ?? (input.modelId === "default" ? undefined : input.modelId)) ?? usage;
548
+ if (message.status === "success") {
549
+ finish({ type: "done", stopReason: "stop", ...(usage ? { usage } : {}) });
550
+ } else {
551
+ const error = asRecord(message.error);
552
+ const messageText = safeMessage(error?.message ?? "Gemini headless turn failed.", "Gemini headless turn failed.");
553
+ finish({ type: "error", message: messageText, ...classifyError(messageText) });
554
+ }
555
+ continue;
556
+ }
557
+ // tool_use/tool_result are Gemini's internal native-tool records.
558
+ }
559
+
560
+ if (!terminal) {
561
+ await Promise.race([
562
+ lifecycle ?? Promise.resolve(),
563
+ new Promise<void>(resolvePromise => setTimeout(resolvePromise, reapTimeoutMs)),
564
+ ]);
565
+ if (aborted) {
566
+ finish({
567
+ type: "error",
568
+ message: "Gemini headless turn was cancelled.",
569
+ status: 499,
570
+ errorType: "invalid_request_error",
571
+ code: "client_cancelled",
572
+ retryable: false,
573
+ });
574
+ } else if (timedOut) {
575
+ finish({
576
+ type: "error",
577
+ message: "Gemini headless turn timed out.",
578
+ status: 504,
579
+ errorType: "upstream_error",
580
+ code: "timeout",
581
+ retryable: true,
582
+ });
583
+ } else if (spawnError) {
584
+ finish({
585
+ type: "error",
586
+ message: safeMessage(spawnError.message, "Gemini headless process failed to start."),
587
+ status: 500,
588
+ errorType: "upstream_error",
589
+ code: "cli_spawn_failed",
590
+ retryable: false,
591
+ });
592
+ } else if (exitCode !== null && exitCode !== 0) {
593
+ finish(classifyFailure(exitCode, stderr.join("")));
594
+ } else if (sawText) {
595
+ finish({ type: "done", stopReason: "stop", ...(usage ? { usage } : {}) });
596
+ } else {
597
+ finish({
598
+ type: "error",
599
+ message: "Gemini headless ended without a terminal result.",
600
+ status: 502,
601
+ errorType: "upstream_error",
602
+ code: "cli_protocol_error",
603
+ retryable: false,
604
+ });
605
+ }
606
+ }
607
+ } catch (error) {
608
+ if (!terminal) finish(errorEvent(error));
609
+ } finally {
610
+ if (timeoutTimer) clearTimeout(timeoutTimer);
611
+ input.incoming.abortSignal?.removeEventListener("abort", onAbort);
612
+ try { child?.stdin?.destroy(); } catch { /* already closed */ }
613
+ if (child && child.exitCode === null && !child.killed) kill();
614
+ if (child && child.exitCode === null) {
615
+ await Promise.race([
616
+ lifecycle ?? new Promise<void>(resolvePromise => child?.once("close", () => resolvePromise())),
617
+ new Promise<void>(resolvePromise => setTimeout(resolvePromise, reapTimeoutMs)),
618
+ ]);
619
+ }
620
+ if (killTimer) clearTimeout(killTimer);
621
+ }
622
+
623
+ const result = terminal ?? {
624
+ type: "error" as const,
625
+ message: "Gemini headless ended without a terminal result.",
626
+ status: 502,
627
+ errorType: "upstream_error",
628
+ code: "cli_protocol_error",
629
+ retryable: false,
630
+ };
631
+ return {
632
+ terminal: result,
633
+ ...(sessionId ? { sessionId } : {}),
634
+ sawText,
635
+ resumeFailed: Boolean(resumeSessionId) && !sawText && result.type === "error" && result.code === "gemini_session_resume_failed",
636
+ };
637
+ }
638
+
639
+ /** Run one headless Gemini turn, retrying a stale resume as a fresh session. */
640
+ export async function runGeminiHeadlessTurn(
641
+ input: GeminiHeadlessTurnInput,
642
+ emit: (event: AdapterEvent) => void,
643
+ deps?: CliAgentTurnDeps,
644
+ ): Promise<void> {
645
+ const effectiveDeps = deps ?? input.deps ?? {};
646
+ const first = await runAttempt(input, emit, effectiveDeps, input.resumeSessionId);
647
+ if (first.resumeFailed && input.resumeSessionId) {
648
+ input.onResumeFailed?.();
649
+ const second = await runAttempt(input, emit, effectiveDeps, undefined);
650
+ emit(second.terminal);
651
+ return;
652
+ }
653
+ emit(first.terminal);
654
+ }