@koreanhole/opencodex 2.95.7 → 2.95.9

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,1006 @@
1
+ import { execFileSync, spawn as nodeSpawn } from "node:child_process";
2
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { Readable, Writable } from "node:stream";
6
+ import * as acp from "@agentclientprotocol/sdk";
7
+ import type { AdapterEvent } from "../../../types";
8
+ import type { IncomingMeta } from "../../base";
9
+ import { commandInvocation } from "../../../lib/win-exec";
10
+ import { SendBudgetExhaustedError } from "../../../lib/upstream-retry";
11
+ import type { SingleUseDispatchPermit } from "../../../lib/request-execution-budget";
12
+ import { baseScopedEnv } from "../../coding-agent/turn";
13
+ import { resolveClineBinary } from "./binary";
14
+ import type { CliAgentStreamState, CliAgentTurnDeps } from "../types";
15
+ import { createClineStreamState, ClineStreamProtocolError, mapClineToolIntent } from "./stream";
16
+
17
+ const DEFAULT_TIMEOUT_MS = 300_000;
18
+ const DEFAULT_KILL_GRACE_MS = 2_000;
19
+ const MAX_STDERR_BYTES = 8 * 1024;
20
+ const MAX_MODEL_ID_CHARS = 512;
21
+ const MAX_ACP_MODELS = 512;
22
+ const MAX_ACP_CONFIG_OPTIONS = 128;
23
+ const MAX_ACP_OPTION_VALUES = 512;
24
+ const MAX_MCP_SERVERS = 32;
25
+
26
+ const CLINE_ACP_ARGS = ["--acp"] as const;
27
+ const OPENAI_CODEX_AUTH_IDS = new Set(["openai-codex", "openai-codex-oauth"]);
28
+ const CLINE_AUTH_IDS = new Set(["cline", "cline-oauth"]);
29
+
30
+ export interface ClineAcpModel {
31
+ modelId: string;
32
+ name?: string;
33
+ description?: string;
34
+ }
35
+
36
+ export interface ClineAcpConfigOptionValue {
37
+ value: string;
38
+ name?: string;
39
+ description?: string;
40
+ }
41
+
42
+ export interface ClineAcpConfigOption {
43
+ type: "select" | "boolean";
44
+ id: string;
45
+ name: string;
46
+ category?: string;
47
+ currentValue?: string | boolean;
48
+ options?: readonly ClineAcpConfigOptionValue[];
49
+ }
50
+
51
+ export interface ClineAcpSessionInfo {
52
+ sessionId: string;
53
+ currentModelId?: string;
54
+ availableModels: readonly ClineAcpModel[];
55
+ configOptions: readonly ClineAcpConfigOption[];
56
+ modelConfigOption?: ClineAcpConfigOption;
57
+ }
58
+
59
+ export interface ClineAcpAuthMethod {
60
+ id: string;
61
+ name: string;
62
+ description?: string;
63
+ }
64
+
65
+ export interface ClineAcpInitialize {
66
+ protocolVersion: number;
67
+ authMethods: readonly ClineAcpAuthMethod[];
68
+ agentVersion?: string;
69
+ }
70
+
71
+ export class ClineAcpError extends Error {
72
+ constructor(
73
+ message: string,
74
+ readonly status: number,
75
+ readonly errorType: string,
76
+ readonly code: string,
77
+ readonly retryable: boolean,
78
+ readonly fallbackAllowed: boolean,
79
+ readonly authRequired = false,
80
+ ) {
81
+ super(message);
82
+ this.name = "ClineAcpError";
83
+ }
84
+ }
85
+
86
+ export interface ClineAcpDeps extends CliAgentTurnDeps {
87
+ spawn?: typeof nodeSpawn;
88
+ env?: NodeJS.ProcessEnv;
89
+ }
90
+
91
+ export interface ClineAcpHooks {
92
+ onSessionUpdate?: (notification: acp.SessionNotification) => void;
93
+ onPermission?: (params: acp.RequestPermissionRequest) => void;
94
+ onStderr?: (text: string) => void;
95
+ }
96
+
97
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
98
+ return value && typeof value === "object" && !Array.isArray(value)
99
+ ? value as Record<string, unknown>
100
+ : undefined;
101
+ }
102
+
103
+ function nonEmpty(value: unknown, max = MAX_MODEL_ID_CHARS): string | undefined {
104
+ if (typeof value !== "string") return undefined;
105
+ const trimmed = value.trim();
106
+ return trimmed && trimmed.length <= max ? trimmed : undefined;
107
+ }
108
+
109
+ function safeMessage(value: unknown): string {
110
+ const raw = value instanceof Error
111
+ ? value.message
112
+ : typeof value === "string"
113
+ ? value
114
+ : value === undefined
115
+ ? "Cline ACP failed."
116
+ : JSON.stringify(value) ?? String(value);
117
+ return raw
118
+ .replace(/\bsk-[A-Za-z0-9_-]{6,}\b/g, "[redacted]")
119
+ .replace(/(authorization\s*[:=]\s*)\S+/gi, "$1[redacted]")
120
+ .slice(0, 2_048);
121
+ }
122
+
123
+ function modelFrom(value: unknown): ClineAcpModel | undefined {
124
+ const record = asRecord(value);
125
+ const modelId = nonEmpty(record?.modelId);
126
+ if (!modelId) return undefined;
127
+ const name = nonEmpty(record?.name, 512);
128
+ const description = nonEmpty(record?.description, 2_048);
129
+ return {
130
+ modelId,
131
+ ...(name ? { name } : {}),
132
+ ...(description ? { description } : {}),
133
+ };
134
+ }
135
+
136
+ function configOptionFrom(value: unknown): ClineAcpConfigOption | undefined {
137
+ const record = asRecord(value);
138
+ if (!record) return undefined;
139
+ const id = nonEmpty(record.id, 512);
140
+ const name = nonEmpty(record.name, 512);
141
+ if (!id || !name) return undefined;
142
+ const type = record.type === "boolean" ? "boolean" : record.type === "select" ? "select" : undefined;
143
+ if (!type) return undefined;
144
+ const category = typeof record.category === "string" ? record.category : undefined;
145
+ const currentValue = typeof record.currentValue === "string" || typeof record.currentValue === "boolean"
146
+ ? record.currentValue
147
+ : undefined;
148
+ const options = Array.isArray(record.options)
149
+ ? record.options.slice(0, MAX_ACP_OPTION_VALUES).flatMap(item => {
150
+ const option = asRecord(item);
151
+ const optionValue = nonEmpty(option?.value, 512);
152
+ if (!optionValue) return [];
153
+ const optionName = nonEmpty(option?.name, 512);
154
+ const optionDescription = nonEmpty(option?.description, 2_048);
155
+ return [{
156
+ value: optionValue,
157
+ ...(optionName ? { name: optionName } : {}),
158
+ ...(optionDescription ? { description: optionDescription } : {}),
159
+ }];
160
+ })
161
+ : undefined;
162
+ return {
163
+ type,
164
+ id,
165
+ name,
166
+ ...(category ? { category } : {}),
167
+ ...(currentValue !== undefined ? { currentValue } : {}),
168
+ ...(options ? { options } : {}),
169
+ };
170
+ }
171
+
172
+ /** Parse Cline's session/new extension without requiring it in the stable ACP schema. */
173
+ export function parseClineAcpSession(response: unknown): ClineAcpSessionInfo {
174
+ const result = asRecord(response);
175
+ const sessionId = nonEmpty(result?.sessionId, 1_024);
176
+ if (!sessionId) throw new ClineAcpError("Cline ACP returned a session without an id.", 502, "upstream_error", "acp_protocol_error", false, true);
177
+ const models = asRecord(result?.models);
178
+ const seen = new Set<string>();
179
+ const availableModels: ClineAcpModel[] = [];
180
+ if (Array.isArray(models?.availableModels)) {
181
+ for (const item of models.availableModels.slice(0, MAX_ACP_MODELS)) {
182
+ const model = modelFrom(item);
183
+ if (!model || seen.has(model.modelId)) continue;
184
+ seen.add(model.modelId);
185
+ availableModels.push(model);
186
+ }
187
+ }
188
+ const configOptions = Array.isArray(result?.configOptions)
189
+ ? result.configOptions.slice(0, MAX_ACP_CONFIG_OPTIONS).flatMap(item => {
190
+ const option = configOptionFrom(item);
191
+ return option ? [option] : [];
192
+ })
193
+ : [];
194
+ const currentModelId = nonEmpty(models?.currentModelId);
195
+ return {
196
+ sessionId,
197
+ ...(currentModelId ? { currentModelId } : {}),
198
+ availableModels,
199
+ configOptions,
200
+ ...(configOptions.find(option => option.type === "select" && option.category === "model")
201
+ ? { modelConfigOption: configOptions.find(option => option.type === "select" && option.category === "model") }
202
+ : {}),
203
+ };
204
+ }
205
+
206
+ export interface ClineModelSource {
207
+ ids: string[];
208
+ source: "models" | "configOptions" | "default";
209
+ }
210
+
211
+ /** Apply the specified discovery order and de-duplicate native ids. */
212
+ export function clineModelSource(session: ClineAcpSessionInfo): ClineModelSource {
213
+ if (session.availableModels.length > 0) {
214
+ return { ids: session.availableModels.map(model => model.modelId), source: "models" };
215
+ }
216
+ const values = session.modelConfigOption?.options?.map(option => option.value) ?? [];
217
+ const ids = [...new Set(values.filter(value => value && value !== "default"))];
218
+ return ids.length > 0 ? { ids, source: "configOptions" } : { ids: ["default"], source: "default" };
219
+ }
220
+
221
+ export function parseClineAcpInitialize(value: unknown): ClineAcpInitialize {
222
+ const result = asRecord(value);
223
+ if (!result) throw new ClineAcpError("Cline ACP initialize returned an invalid response.", 502, "upstream_error", "acp_protocol_error", false, true);
224
+ const version = typeof result.protocolVersion === "number" && Number.isInteger(result.protocolVersion)
225
+ ? result.protocolVersion
226
+ : undefined;
227
+ if (version !== acp.PROTOCOL_VERSION) {
228
+ throw new ClineAcpError(
229
+ `Cline ACP negotiated protocol ${String(version ?? "unknown")}; only v${acp.PROTOCOL_VERSION} is supported.`,
230
+ 502,
231
+ "upstream_error",
232
+ "acp_protocol_error",
233
+ false,
234
+ true,
235
+ );
236
+ }
237
+ const authMethods = Array.isArray(result.authMethods)
238
+ ? result.authMethods.flatMap(item => {
239
+ const record = asRecord(item);
240
+ const id = nonEmpty(record?.id, 256);
241
+ const name = nonEmpty(record?.name, 512);
242
+ const description = nonEmpty(record?.description, 2_048);
243
+ return id && name ? [{ id, name, ...(description ? { description } : {}) }] : [];
244
+ })
245
+ : [];
246
+ const agentInfo = asRecord(result.agentInfo);
247
+ const agentVersion = nonEmpty(agentInfo?.version, 256);
248
+ return { protocolVersion: version, authMethods, ...(agentVersion ? { agentVersion } : {}) };
249
+ }
250
+
251
+ export interface ClineAcpMode {
252
+ acp: boolean;
253
+ autoApproveFlag?: boolean;
254
+ }
255
+
256
+ const MODE_CACHE = new Map<string, { mtimeMs: number; size: number; acp: boolean; autoApproveFlag: boolean }>();
257
+
258
+ /** ACP is off by exact `0`; every other/missing value permits the capability probe. */
259
+ export function clineAcpEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
260
+ return env.OPENCODEX_CLINE_ACP?.trim() !== "0";
261
+ }
262
+
263
+ function acpHelp(text: string): { acp: boolean; autoApproveFlag: boolean } {
264
+ return {
265
+ acp: /(?:^|\s)--acp(?:\s|,|<|$)/.test(text),
266
+ autoApproveFlag: /(?:^|\s)--auto-approve(?:\s|,|<|$)/.test(text),
267
+ };
268
+ }
269
+
270
+ /** Probe `--acp` with a bounded help command and cache by binary path/fingerprint. */
271
+ export async function probeClineAcpSupport(
272
+ binary: string,
273
+ env: Record<string, string>,
274
+ deps: ClineAcpDeps = {},
275
+ ): Promise<ClineAcpMode> {
276
+ if (!clineAcpEnabled(deps.env ?? process.env)) return { acp: false };
277
+ const key = resolve(binary);
278
+ let mtimeMs: number | undefined;
279
+ let size: number | undefined;
280
+ try {
281
+ const value = await stat(key);
282
+ mtimeMs = value.mtimeMs;
283
+ size = value.size;
284
+ } catch {
285
+ return { acp: false };
286
+ }
287
+ const cached = MODE_CACHE.get(key);
288
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
289
+ return { acp: cached.acp, ...(cached.autoApproveFlag ? { autoApproveFlag: true } : {}) };
290
+ }
291
+ const result = await runClineHelp(binary, env, deps);
292
+ const capabilities = acpHelp(result);
293
+ MODE_CACHE.set(key, { mtimeMs, size, ...capabilities });
294
+ return { acp: capabilities.acp, ...(capabilities.autoApproveFlag ? { autoApproveFlag: true } : {}) };
295
+ }
296
+
297
+ async function runClineHelp(
298
+ binary: string,
299
+ env: Record<string, string>,
300
+ deps: ClineAcpDeps,
301
+ ): Promise<string> {
302
+ return await new Promise((resolvePromise, reject) => {
303
+ const invocation = commandInvocation(binary, ["--help"], deps.platform ?? process.platform, { env });
304
+ const spawnFn = deps.spawn ?? nodeSpawn;
305
+ const child = spawnFn(invocation.file, invocation.args, {
306
+ ...invocation.options,
307
+ cwd: homedir(),
308
+ env,
309
+ stdio: ["ignore", "pipe", "pipe"],
310
+ windowsHide: true,
311
+ });
312
+ const chunks: string[] = [];
313
+ let bytes = 0;
314
+ let settled = false;
315
+ const finish = (fn: () => void): void => {
316
+ if (settled) return;
317
+ settled = true;
318
+ clearTimeout(timer);
319
+ fn();
320
+ };
321
+ const timer = setTimeout(() => {
322
+ try { child.kill("SIGKILL"); } catch { /* ignore */ }
323
+ finish(() => reject(new Error("Cline --help probe timed out.")));
324
+ }, deps.timeoutMs ?? 15_000);
325
+ const collect = (chunk: Buffer): void => {
326
+ if (bytes >= MAX_STDERR_BYTES) return;
327
+ const admitted = chunk.subarray(0, MAX_STDERR_BYTES - bytes);
328
+ chunks.push(admitted.toString("utf8"));
329
+ bytes += admitted.byteLength;
330
+ };
331
+ child.stdout?.on("data", collect);
332
+ child.stderr?.on("data", collect);
333
+ child.once("error", error => finish(() => reject(error)));
334
+ child.once("close", () => finish(() => resolvePromise(chunks.join(""))));
335
+ });
336
+ }
337
+
338
+ function isAuthError(error: unknown): boolean {
339
+ const message = safeMessage(error);
340
+ return /auth(?:entication)?\s*(?:required|failed)|not\s+(?:logged|signed)\s+in|sign[ -]?in|login required|unauthorized/i.test(message);
341
+ }
342
+
343
+ function acpError(error: unknown, fallbackAllowed: boolean): ClineAcpError {
344
+ const authRequired = isAuthError(error);
345
+ return new ClineAcpError(
346
+ safeMessage(error),
347
+ authRequired ? 401 : 502,
348
+ authRequired ? "authentication_error" : "upstream_error",
349
+ authRequired ? "acp_auth_required" : "acp_transport_error",
350
+ false,
351
+ fallbackAllowed,
352
+ authRequired,
353
+ );
354
+ }
355
+
356
+ function killWindowsProcessTree(pid: number): void {
357
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
358
+ execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true });
359
+ }
360
+
361
+ export interface ClineAcpClientOptions {
362
+ binary: string;
363
+ env: Record<string, string>;
364
+ cwd: string;
365
+ args?: readonly string[];
366
+ hooks?: ClineAcpHooks;
367
+ }
368
+
369
+ /** One request-scoped ACP child. Cleanup is idempotent and always reaps the process. */
370
+ export class ClineAcpClient {
371
+ private readonly child: ReturnType<typeof nodeSpawn>;
372
+ private readonly connection: acp.ClientSideConnection;
373
+ private readonly processLifecycle: Promise<void>;
374
+ private readonly stderrChunks: string[] = [];
375
+ private stderrBytes = 0;
376
+ private readonly terminateOnce: () => Promise<void>;
377
+ private terminated = false;
378
+ private exited = false;
379
+ sessionId?: string;
380
+ initializeResult?: ClineAcpInitialize;
381
+
382
+ private constructor(
383
+ child: ReturnType<typeof nodeSpawn>,
384
+ connection: acp.ClientSideConnection,
385
+ processLifecycle: Promise<void>,
386
+ private readonly hooks: ClineAcpHooks,
387
+ private readonly deps: ClineAcpDeps,
388
+ ) {
389
+ this.child = child;
390
+ this.connection = connection;
391
+ this.processLifecycle = processLifecycle;
392
+ this.terminateOnce = async () => {
393
+ if (this.terminated) return;
394
+ this.terminated = true;
395
+ try { this.child.stdin?.end(); } catch { /* already closed */ }
396
+ if (this.deps.platform === "win32" && this.child.pid !== undefined) {
397
+ try { (this.deps.killWindowsProcessTree ?? killWindowsProcessTree)(this.child.pid); } catch { /* direct fallback */ }
398
+ }
399
+ try { this.child.kill("SIGTERM"); } catch { /* already gone */ }
400
+ await Promise.race([
401
+ this.processLifecycle,
402
+ new Promise<void>(resolve => setTimeout(resolve, this.deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS)),
403
+ ]);
404
+ if (this.child.exitCode === null) {
405
+ try { this.child.kill("SIGKILL"); } catch { /* already gone */ }
406
+ await Promise.race([
407
+ this.processLifecycle,
408
+ new Promise<void>(resolve => setTimeout(resolve, (this.deps.reapTimeoutMs ?? (this.deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS) * 2 + 250))),
409
+ ]);
410
+ }
411
+ };
412
+ }
413
+
414
+ static async connect(
415
+ options: ClineAcpClientOptions,
416
+ deps: ClineAcpDeps = {},
417
+ ): Promise<ClineAcpClient> {
418
+ const hooks = options.hooks ?? {};
419
+ const spawnFn = deps.spawn ?? nodeSpawn;
420
+ const invocation = commandInvocation(options.binary, [...(options.args ?? CLINE_ACP_ARGS)], deps.platform ?? process.platform, { env: options.env });
421
+ let child: ReturnType<typeof nodeSpawn>;
422
+ try {
423
+ child = spawnFn(invocation.file, invocation.args, {
424
+ ...invocation.options,
425
+ cwd: options.cwd,
426
+ env: options.env,
427
+ stdio: ["pipe", "pipe", "pipe"],
428
+ windowsHide: true,
429
+ });
430
+ } catch (error) {
431
+ throw acpError(error, true);
432
+ }
433
+ if (!child.stdin || !child.stdout) {
434
+ try { child.kill("SIGKILL"); } catch { /* ignore */ }
435
+ throw new ClineAcpError("Cline ACP process did not expose stdio.", 500, "upstream_error", "acp_spawn_failed", false, true);
436
+ }
437
+ const processLifecycle = new Promise<void>(resolvePromise => {
438
+ let settled = false;
439
+ const settle = (): void => {
440
+ if (settled) return;
441
+ settled = true;
442
+ resolvePromise();
443
+ };
444
+ child.once("error", settle);
445
+ child.once("close", settle);
446
+ if (child.exitCode !== null) settle();
447
+ });
448
+ let instance: ClineAcpClient;
449
+ const client: acp.Client = {
450
+ requestPermission: async params => {
451
+ hooks.onPermission?.(params);
452
+ if (instance?.sessionId) void instance.cancel(instance.sessionId).catch(() => undefined);
453
+ return { outcome: { outcome: "cancelled" } };
454
+ },
455
+ sessionUpdate: async notification => {
456
+ if (notification.sessionId === instance?.sessionId) hooks.onSessionUpdate?.(notification);
457
+ },
458
+ };
459
+ const stream = acp.ndJsonStream(
460
+ Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
461
+ Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>,
462
+ );
463
+ const connection = new acp.ClientSideConnection(() => client, stream);
464
+ instance = new ClineAcpClient(child, connection, processLifecycle, hooks, deps);
465
+ child.stderr?.setEncoding("utf8");
466
+ child.stderr?.on("data", (chunk: string) => {
467
+ if (instance.stderrBytes >= MAX_STDERR_BYTES) return;
468
+ const remaining = MAX_STDERR_BYTES - instance.stderrBytes;
469
+ const admitted = chunk.slice(0, remaining);
470
+ instance.stderrChunks.push(admitted);
471
+ instance.stderrBytes += admitted.length;
472
+ hooks.onStderr?.(admitted);
473
+ });
474
+ child.once("close", () => { instance.exited = true; });
475
+ try {
476
+ const initialize = await deadline(
477
+ connection.initialize({
478
+ protocolVersion: acp.PROTOCOL_VERSION,
479
+ clientCapabilities: {},
480
+ clientInfo: { name: "opencodex", version: "1.0.0" },
481
+ }),
482
+ deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
483
+ () => instance.terminate(),
484
+ );
485
+ instance.initializeResult = parseClineAcpInitialize(initialize);
486
+ return instance;
487
+ } catch (error) {
488
+ await instance.terminate();
489
+ if (error instanceof ClineAcpError) {
490
+ if (error.code === "timeout") {
491
+ throw new ClineAcpError(error.message, error.status, error.errorType, error.code, error.retryable, true);
492
+ }
493
+ throw error;
494
+ }
495
+ throw acpError(error, true);
496
+ }
497
+ }
498
+
499
+ stderrText(): string {
500
+ return this.stderrChunks.join("");
501
+ }
502
+
503
+ async newSession(params: {
504
+ cwd: string;
505
+ mcpServers: readonly Record<string, unknown>[];
506
+ }): Promise<ClineAcpSessionInfo> {
507
+ try {
508
+ const response = await deadline(
509
+ this.connection.request<unknown>(acp.AGENT_METHODS.session_new, {
510
+ cwd: params.cwd,
511
+ mcpServers: params.mcpServers.slice(0, MAX_MCP_SERVERS),
512
+ }),
513
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
514
+ () => this.terminate(),
515
+ );
516
+ const session = parseClineAcpSession(response);
517
+ this.sessionId = session.sessionId;
518
+ return session;
519
+ } catch (error) {
520
+ if (error instanceof ClineAcpError) {
521
+ if (error.code === "timeout") {
522
+ throw new ClineAcpError(error.message, error.status, error.errorType, error.code, error.retryable, true);
523
+ }
524
+ throw error;
525
+ }
526
+ throw acpError(error, true);
527
+ }
528
+ }
529
+
530
+ async setModel(sessionId: string, modelId: string): Promise<void> {
531
+ await deadline(
532
+ this.connection.request("session/set_model", { sessionId, modelId }),
533
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
534
+ () => this.terminate(),
535
+ ).catch(error => {
536
+ if (error instanceof ClineAcpError) throw error;
537
+ throw new ClineAcpError("Cline ACP rejected session/set_model.", 502, "upstream_error", "acp_transport_error", false, false);
538
+ });
539
+ }
540
+
541
+ async setConfigOption(sessionId: string, configId: string, value: string): Promise<void> {
542
+ await deadline(
543
+ this.connection.request(acp.AGENT_METHODS.session_set_config_option, {
544
+ sessionId,
545
+ configId,
546
+ value,
547
+ }),
548
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
549
+ () => this.terminate(),
550
+ ).catch(error => {
551
+ if (error instanceof ClineAcpError) throw error;
552
+ throw new ClineAcpError("Cline ACP rejected session/set_config_option.", 502, "upstream_error", "acp_transport_error", false, false);
553
+ });
554
+ }
555
+
556
+ async authenticate(methodId: string): Promise<void> {
557
+ await deadline(
558
+ this.connection.authenticate({ methodId }),
559
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
560
+ () => this.terminate(),
561
+ ).catch(error => {
562
+ if (error instanceof ClineAcpError) throw error;
563
+ throw acpError(error, false);
564
+ });
565
+ }
566
+
567
+ async prompt(sessionId: string, text: string, cancellationSignal?: AbortSignal): Promise<acp.PromptResponse> {
568
+ return await deadline(
569
+ this.connection.request(
570
+ acp.AGENT_METHODS.session_prompt,
571
+ { sessionId, prompt: [{ type: "text" as const, text }] },
572
+ { ...(cancellationSignal ? { cancellationSignal } : {}) },
573
+ ),
574
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
575
+ () => this.terminate(),
576
+ );
577
+ }
578
+
579
+ async cancel(sessionId: string): Promise<void> {
580
+ if (this.exited) return;
581
+ await this.connection.cancel({ sessionId });
582
+ }
583
+
584
+ async terminate(): Promise<void> {
585
+ await this.terminateOnce();
586
+ }
587
+ }
588
+
589
+ async function deadline<T>(promise: Promise<T>, ms: number, onTimeout: () => Promise<void>): Promise<T> {
590
+ let timer: ReturnType<typeof setTimeout> | undefined;
591
+ const timeout = new Promise<never>((_, reject) => {
592
+ timer = setTimeout(() => {
593
+ void onTimeout().catch(() => undefined);
594
+ reject(new ClineAcpError("Cline ACP request timed out.", 504, "upstream_error", "timeout", true, false));
595
+ }, ms);
596
+ });
597
+ try {
598
+ return await Promise.race([promise, timeout]);
599
+ } finally {
600
+ if (timer) clearTimeout(timer);
601
+ }
602
+ }
603
+
604
+ export function selectClineAuthMethod(methods: readonly ClineAcpAuthMethod[]): ClineAcpAuthMethod | undefined {
605
+ const byId = new Map(methods.map(method => [method.id.toLowerCase(), method]));
606
+ for (const id of ["cline", "cline-oauth", "openai-codex", "openai-codex-oauth"]) {
607
+ const method = byId.get(id);
608
+ if (method) return method;
609
+ }
610
+ return methods.find(method => CLINE_AUTH_IDS.has(method.id.toLowerCase()) || OPENAI_CODEX_AUTH_IDS.has(method.id.toLowerCase()));
611
+ }
612
+
613
+ export interface ClineAcpLoginOptions {
614
+ binary?: string;
615
+ env?: NodeJS.ProcessEnv;
616
+ noWait?: boolean;
617
+ onInstruction?: (text: string) => void;
618
+ deps?: ClineAcpDeps;
619
+ }
620
+
621
+ export interface ClineAcpLoginResult {
622
+ methods: readonly ClineAcpAuthMethod[];
623
+ method: ClineAcpAuthMethod;
624
+ instructions: string;
625
+ complete: boolean;
626
+ }
627
+
628
+ /** Perform one human-initiated ACP login. Request paths must never call this function. */
629
+ export async function loginClineAcp(options: ClineAcpLoginOptions = {}): Promise<ClineAcpLoginResult> {
630
+ const sourceEnv = options.env ?? process.env;
631
+ const binary = options.binary ?? resolveClineBinary(undefined, sourceEnv);
632
+ if (!binary) {
633
+ throw new ClineAcpError("Cline CLI not found on PATH. Install it with `npm install -g cline`.", 500, "upstream_error", "cli_not_found", false, false);
634
+ }
635
+ const env = buildClineAcpEnv(sourceEnv);
636
+ const mode = await probeClineAcpSupport(binary, env, { ...options.deps, env: sourceEnv });
637
+ if (!mode.acp) {
638
+ throw new ClineAcpError("The installed Cline CLI does not advertise --acp.", 400, "invalid_request_error", "cline_acp_unsupported", false, false);
639
+ }
640
+ const workspace = await stageClineAcpWorkspace();
641
+ let instructions = "";
642
+ let client: ClineAcpClient | undefined;
643
+ try {
644
+ client = await ClineAcpClient.connect({
645
+ binary,
646
+ env,
647
+ cwd: workspace.cwd,
648
+ args: mode.autoApproveFlag ? ["--acp", "--auto-approve", "false"] : ["--acp"],
649
+ hooks: {
650
+ onStderr: text => {
651
+ instructions = (instructions + text).slice(0, MAX_STDERR_BYTES);
652
+ options.onInstruction?.(text);
653
+ },
654
+ },
655
+ }, options.deps);
656
+ const methods = client.initializeResult?.authMethods ?? [];
657
+ const method = selectClineAuthMethod(methods);
658
+ if (!method) {
659
+ throw new ClineAcpError(
660
+ methods.length > 0
661
+ ? `No supported Cline ACP auth method. Advertised: ${methods.map(item => item.id).join(", ")}.`
662
+ : "Cline ACP did not advertise any authentication methods.",
663
+ 400,
664
+ "invalid_request_error",
665
+ "cline_acp_auth_unavailable",
666
+ false,
667
+ false,
668
+ );
669
+ }
670
+ let authError: unknown;
671
+ const auth = client.authenticate(method.id).then(
672
+ () => true,
673
+ error => {
674
+ authError = error;
675
+ return false;
676
+ },
677
+ );
678
+ const completed = options.noWait
679
+ ? await Promise.race([
680
+ auth,
681
+ new Promise<boolean>(resolvePromise => setTimeout(() => resolvePromise(false), 2_000)),
682
+ ])
683
+ : await auth;
684
+ if (authError) throw authError;
685
+ return { methods, method, instructions, complete: completed };
686
+ } finally {
687
+ await client?.terminate();
688
+ await workspace.cleanup();
689
+ }
690
+ }
691
+
692
+
693
+ /** Build the Cline ACP child environment without staging auth away from the real home. */
694
+ export function buildClineAcpEnv(sourceEnv: NodeJS.ProcessEnv = process.env): Record<string, string> {
695
+ const env = baseScopedEnv();
696
+ const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
697
+ env.HOME = realHome;
698
+ env.USERPROFILE = realHome;
699
+ for (const key of ["XDG_CONFIG_HOME", "XDG_CACHE_HOME", "CLINE_DIR", "CLINE_DATA_DIR", "CLINE_PROVIDER_SETTINGS_PATH"]) {
700
+ const value = nonEmpty(sourceEnv[key]);
701
+ if (value) env[key] = value;
702
+ }
703
+ Object.assign(env, {
704
+ CLINE_NO_AUTO_UPDATE: "1",
705
+ CLINE_TELEMETRY_DISABLED: "1",
706
+ CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
707
+ NO_UPDATE_NOTIFIER: "1",
708
+ NO_COLOR: "1",
709
+ });
710
+ return env;
711
+ }
712
+
713
+ export interface ClineAcpWorkspace {
714
+ cwd: string;
715
+ catalogPath?: string;
716
+ cleanup: () => Promise<void>;
717
+ }
718
+
719
+ export async function stageClineAcpWorkspace(
720
+ catalog: readonly Record<string, unknown>[] = [],
721
+ ): Promise<ClineAcpWorkspace> {
722
+ const cwd = await mkdtemp(join(tmpdir(), "ocx-cline-acp-"));
723
+ const catalogPath = join(cwd, "tool-catalog.json");
724
+ await writeFile(catalogPath, JSON.stringify(catalog), { encoding: "utf8", mode: 0o600, flag: "wx" });
725
+ return {
726
+ cwd,
727
+ ...(catalog.length > 0 ? { catalogPath } : {}),
728
+ cleanup: async () => { await rm(cwd, { recursive: true, force: true }).catch(() => undefined); },
729
+ };
730
+ }
731
+
732
+ export interface ClineAcpTurnInput {
733
+ binary: string;
734
+ env: Record<string, string>;
735
+ cwd: string;
736
+ modelId: string;
737
+ prompt: string;
738
+ toolNames: ReadonlyMap<string, string>;
739
+ mcpServers: readonly Record<string, unknown>[];
740
+ args?: readonly string[];
741
+ maxToolCalls: number;
742
+ requireToolCall: boolean;
743
+ incoming: IncomingMeta;
744
+ deps?: ClineAcpDeps;
745
+ }
746
+
747
+ function unsupportedModel(modelId: string): ClineAcpError {
748
+ return new ClineAcpError(
749
+ `Cline ACP cannot select model "${modelId}".`,
750
+ 400,
751
+ "invalid_request_error",
752
+ "cline_model_unsupported",
753
+ false,
754
+ false,
755
+ );
756
+ }
757
+
758
+ export async function applyClineAcpModel(client: ClineAcpClient, session: ClineAcpSessionInfo, modelId: string): Promise<void> {
759
+ if (modelId === "default") return;
760
+ if (session.availableModels.length > 0) {
761
+ if (!session.availableModels.some(model => model.modelId === modelId)) throw unsupportedModel(modelId);
762
+ try {
763
+ await client.setModel(session.sessionId, modelId);
764
+ return;
765
+ } catch (error) {
766
+ if (error instanceof ClineAcpError && error.code === "timeout") throw error;
767
+ throw unsupportedModel(modelId);
768
+ }
769
+ }
770
+ const option = session.modelConfigOption;
771
+ if (option?.options?.some(value => value.value === modelId)) {
772
+ try {
773
+ await client.setConfigOption(session.sessionId, option.id, modelId);
774
+ return;
775
+ } catch (error) {
776
+ if (error instanceof ClineAcpError && error.code === "timeout") throw error;
777
+ throw unsupportedModel(modelId);
778
+ }
779
+ }
780
+ throw unsupportedModel(modelId);
781
+ }
782
+
783
+ /** Run one ACP prompt through the shared Codex event grammar. */
784
+ export async function runClineAcpTurn(
785
+ input: ClineAcpTurnInput,
786
+ emit: (event: AdapterEvent) => void,
787
+ ): Promise<void> {
788
+ const deps = input.deps ?? {};
789
+ const state = createClineStreamState();
790
+ let client: ClineAcpClient | undefined;
791
+ let session: ClineAcpSessionInfo | undefined;
792
+ let terminalEmitted = false;
793
+ let sawOutput = false;
794
+ let safetyError: string | undefined;
795
+ let aborted = false;
796
+ let timedOut = false;
797
+ let terminateTimer: ReturnType<typeof setTimeout> | undefined;
798
+ let turnTimer: ReturnType<typeof setTimeout> | undefined;
799
+ let permit: SingleUseDispatchPermit | undefined;
800
+ let producer: { close(): void } | undefined;
801
+ const seenToolCalls = new Set<string>();
802
+
803
+ const deliver = (event: AdapterEvent): void => {
804
+ if (event.type === "text_delta" || event.type === "thinking_delta"
805
+ || event.type === "tool_call_start" || event.type === "tool_call_delta" || event.type === "tool_call_end") {
806
+ sawOutput = true;
807
+ }
808
+ if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
809
+ if (terminalEmitted) return;
810
+ terminalEmitted = true;
811
+ }
812
+ emit(event);
813
+ };
814
+
815
+ const captureTool = (toolCall: Record<string, unknown>, sessionId: string): void => {
816
+ const id = nonEmpty(toolCall.toolCallId, 512);
817
+ if (id && seenToolCalls.has(id)) return;
818
+ if (id) seenToolCalls.add(id);
819
+ const name = nonEmpty(toolCall.name, 512);
820
+ if (!name) {
821
+ safetyError ??= "Cline ACP returned an undeclared tool call.";
822
+ void client?.cancel(sessionId).catch(() => undefined);
823
+ return;
824
+ }
825
+ try {
826
+ const events = mapClineToolIntent(name, id, toolCall.rawInput, state, {
827
+ toolNames: input.toolNames,
828
+ maxToolCalls: input.maxToolCalls,
829
+ });
830
+ for (const event of events) deliver(event);
831
+ } catch (error) {
832
+ safetyError ??= error instanceof ClineStreamProtocolError
833
+ ? error.message
834
+ : "Cline ACP returned an invalid tool call.";
835
+ }
836
+ void client?.cancel(sessionId).catch(() => undefined);
837
+ };
838
+
839
+ const hooks: ClineAcpHooks = {
840
+ onSessionUpdate: notification => {
841
+ const update = notification.update;
842
+ if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text" && update.content.text) {
843
+ deliver({ type: "text_delta", text: update.content.text });
844
+ return;
845
+ }
846
+ if (update.sessionUpdate === "agent_thought_chunk" && update.content.type === "text" && update.content.text) {
847
+ deliver({ type: "thinking_delta", thinking: update.content.text });
848
+ return;
849
+ }
850
+ if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
851
+ captureTool(update as unknown as Record<string, unknown>, notification.sessionId);
852
+ }
853
+ },
854
+ onPermission: params => {
855
+ captureTool(params.toolCall as unknown as Record<string, unknown>, params.sessionId);
856
+ },
857
+ };
858
+
859
+ const onAbort = (): void => {
860
+ aborted = true;
861
+ if (session) void client?.cancel(session.sessionId).catch(() => undefined);
862
+ terminateTimer ??= setTimeout(() => { void client?.terminate(); }, 750);
863
+ };
864
+
865
+ try {
866
+ client = await ClineAcpClient.connect({ binary: input.binary, env: input.env, cwd: input.cwd, hooks, ...(input.args ? { args: input.args } : {}) }, deps);
867
+ session = await client.newSession({ cwd: input.cwd, mcpServers: input.mcpServers });
868
+ await applyClineAcpModel(client, session, input.modelId);
869
+
870
+ if (input.incoming.abortSignal?.aborted) {
871
+ deliver({
872
+ type: "error",
873
+ message: "Cline ACP turn was aborted before the prompt.",
874
+ status: 499,
875
+ errorType: "invalid_request_error",
876
+ code: "client_cancelled",
877
+ retryable: false,
878
+ });
879
+ return;
880
+ }
881
+
882
+ producer = input.incoming.sendBudget?.beginSpendProducer?.();
883
+ const decision = input.incoming.sendBudget?.reserveDispatch({
884
+ sendClass: "initial",
885
+ targetKey: `cli-agent|Cline ACP|${input.binary}`,
886
+ });
887
+ if (decision && !decision.allowed) {
888
+ deliver({
889
+ type: "error",
890
+ message: "Cline ACP send budget exhausted before dispatch.",
891
+ status: 429,
892
+ errorType: "rate_limit_error",
893
+ code: "send_budget_exhausted",
894
+ retryable: false,
895
+ });
896
+ return;
897
+ }
898
+ permit = decision?.allowed ? decision.permit : undefined;
899
+ input.incoming.beforePhysicalSend?.();
900
+ if (permit && !permit.use()) throw new SendBudgetExhaustedError();
901
+ input.incoming.onPhysicalSend?.({ ordinal: 1 });
902
+
903
+ input.incoming.abortSignal?.addEventListener("abort", onAbort, { once: true });
904
+ turnTimer = setTimeout(() => {
905
+ timedOut = true;
906
+ onAbort();
907
+ }, deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
908
+
909
+ const response = await client.prompt(session.sessionId, input.prompt, input.incoming.abortSignal);
910
+
911
+ if (timedOut) {
912
+ deliver({
913
+ type: "error",
914
+ message: "Cline ACP turn timed out.",
915
+ status: 504,
916
+ errorType: "upstream_error",
917
+ code: "timeout",
918
+ retryable: true,
919
+ });
920
+ } else if (aborted) {
921
+ deliver({
922
+ type: "error",
923
+ message: "Cline ACP turn was cancelled.",
924
+ status: 499,
925
+ errorType: "invalid_request_error",
926
+ code: "client_cancelled",
927
+ retryable: false,
928
+ });
929
+ } else if (safetyError) {
930
+ deliver({
931
+ type: "error",
932
+ message: safetyError,
933
+ status: 502,
934
+ errorType: "upstream_error",
935
+ code: "cli_protocol_error",
936
+ retryable: false,
937
+ });
938
+ } else if (state.toolCalls > 0) {
939
+ deliver({ type: "done", stopReason: "tool_use", ...(state.usage ? { usage: state.usage } : {}) });
940
+ } else if (input.requireToolCall) {
941
+ deliver({
942
+ type: "error",
943
+ message: "Cline ACP returned no tool call for a request that requires one.",
944
+ status: 502,
945
+ errorType: "upstream_error",
946
+ code: "tool_call_required",
947
+ retryable: false,
948
+ });
949
+ } else if (response.stopReason === "end_turn") {
950
+ deliver({ type: "done", stopReason: "stop", ...(state.usage ? { usage: state.usage } : {}) });
951
+ } else if (response.stopReason === "cancelled") {
952
+ deliver({
953
+ type: "error",
954
+ message: "Cline ACP cancelled the turn.",
955
+ status: 502,
956
+ errorType: "upstream_error",
957
+ code: "acp_cancelled",
958
+ retryable: false,
959
+ });
960
+ } else {
961
+ deliver({
962
+ type: "error",
963
+ message: `Cline ACP finished with ${response.stopReason}.`,
964
+ status: response.stopReason === "max_tokens" ? 400 : 502,
965
+ errorType: response.stopReason === "max_tokens" ? "invalid_request_error" : "upstream_error",
966
+ code: "cli_protocol_error",
967
+ retryable: false,
968
+ });
969
+ }
970
+ } catch (error) {
971
+ if (error instanceof ClineAcpError && error.fallbackAllowed && !sawOutput) throw error;
972
+ if (!terminalEmitted) {
973
+ const failure = timedOut
974
+ ? new ClineAcpError("Cline ACP turn timed out.", 504, "upstream_error", "timeout", true, false)
975
+ : aborted
976
+ ? new ClineAcpError("Cline ACP turn was cancelled.", 499, "invalid_request_error", "client_cancelled", false, false)
977
+ : error instanceof ClineAcpError
978
+ ? error
979
+ : error instanceof SendBudgetExhaustedError
980
+ ? new ClineAcpError("Cline ACP send budget exhausted before dispatch.", 429, "rate_limit_error", "send_budget_exhausted", false, false)
981
+ : new ClineAcpError(
982
+ safeMessage(error),
983
+ 502,
984
+ "upstream_error",
985
+ "acp_transport_error",
986
+ false,
987
+ false,
988
+ );
989
+ deliver({
990
+ type: "error",
991
+ message: failure.message,
992
+ status: failure.status,
993
+ errorType: failure.errorType,
994
+ code: failure.code,
995
+ retryable: failure.retryable,
996
+ });
997
+ }
998
+ } finally {
999
+ if (turnTimer) clearTimeout(turnTimer);
1000
+ if (terminateTimer) clearTimeout(terminateTimer);
1001
+ input.incoming.abortSignal?.removeEventListener("abort", onAbort);
1002
+ try { permit?.release?.(); } catch { /* ignore */ }
1003
+ producer?.close();
1004
+ await client?.terminate();
1005
+ }
1006
+ }