@koreanhole/opencodex 2.97.0 → 2.99.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 CHANGED
@@ -29,6 +29,10 @@ ocx start
29
29
  ocx codex sync
30
30
  ```
31
31
 
32
+ `ocx start` journals the native Codex config and injects the managed routing.
33
+ `ocx stop` restores it. Use `ocx restore`/`ocx eject` to restore while keeping
34
+ the proxy alive, and `ocx restore back` to re-enable routing.
35
+
32
36
  `ocx codex sync` refreshes the Codex app catalog with locally discovered Gemini
33
37
  models and reasoning levels. Restart the Codex app/app-server after refresh. The
34
38
  catalog file is written only when `model_catalog_json` names an opencodex catalog
@@ -37,10 +41,12 @@ or through the documented default path; unrelated Codex settings are not changed
37
41
  ## Credential rules
38
42
 
39
43
  - Use the operator's existing Cline or Gemini CLI sign-in. Do not copy vendor
40
- tokens into config, environment files, logs, prompts, or package content.
44
+ tokens into config, environment files, logs, prompts, or package content. The
45
+ only exception is Cline's mode-0600 request-scoped staging copy.
41
46
  - Do not authenticate on a request path. If Cline reports `auth_required`, stop
42
47
  and ask the operator to run `ocx login cline-cli`.
43
- - Do not edit the operator's real Cline provider store unless the operator asks.
48
+ - Do not edit the operator's real Cline provider store. Read it only to create a
49
+ mode-0600 request-scoped copy; remove that copy after the turn.
44
50
  - Do not import or replay Gemini OAuth/API-key material; the Gemini CLI remains
45
51
  the authentication authority.
46
52
  - Third-party use must use a Gemini Developer API key or Vertex AI GenAI API key.
@@ -55,6 +61,9 @@ or through the documented default path; unrelated Codex settings are not changed
55
61
  ```bash
56
62
  ocx status
57
63
  ocx stop
64
+ ocx restore [--json] [--remove-codex-provider-table]
65
+ ocx restore back [--json]
66
+ ocx eject
58
67
  ```
59
68
 
60
69
  The default listener is loopback-only. A non-loopback bind is refused unless a
@@ -71,6 +80,7 @@ bun test tests/e2e/cline-only.test.ts
71
80
  bun run test:e2e:gemini
72
81
  bun run test:e2e:gemini-tool
73
82
  bun run test:e2e:gemini-reasoning
83
+ bun run test:e2e:codex-restore
74
84
  ```
75
85
 
76
86
  Report whether each evidence path used a deterministic ACP fixture or a real
package/README.md CHANGED
@@ -20,6 +20,10 @@ npm install -g @google/gemini-cli
20
20
  gemini # configure GEMINI_API_KEY before using it through opencodex
21
21
  ```
22
22
 
23
+ Cline must advertise ACP (`cline --help` includes `--acp`). opencodex uses ACP
24
+ only and fails closed with `cline_acp_unsupported` for older one-shot-only
25
+ builds.
26
+
23
27
  Gemini authentication remains in Gemini CLI's own home/settings. The proxy never
24
28
  copies, logs, or stores an API key, OAuth token, bearer token, or full request
25
29
  body.
@@ -51,7 +55,15 @@ Cline-only configs load unchanged and keep Cline as the default.
51
55
 
52
56
  ## Codex
53
57
 
54
- Use a Responses provider pointed at the proxy:
58
+ `ocx start` now records a mode-0600 pre-image under `$OPENCODEX_HOME/codex/`,
59
+ injects the loopback Responses provider, and points Codex at the live model
60
+ catalog. The managed block is marked and is the only block opencodex will touch.
61
+
62
+ ```bash
63
+ ocx start
64
+ ```
65
+
66
+ The resulting Codex config is equivalent to:
55
67
 
56
68
  ```toml
57
69
  model = "gemini-cli/gemini-3.5-flash-lite"
@@ -65,6 +77,19 @@ requires_openai_auth = false
65
77
  model_catalog_url = "http://127.0.0.1:10100/v1/model-catalog"
66
78
  ```
67
79
 
80
+ Switch back to native Codex without stopping the proxy:
81
+
82
+ ```bash
83
+ ocx restore # alias: ocx eject
84
+ ocx restore back # re-point Codex at the running proxy
85
+ ```
86
+
87
+ `ocx stop`, Ctrl-C/SIGTERM, and SIGHUP also restore native Codex. The restore
88
+ covers Codex config and the opencodex catalog only; resume-history metadata is
89
+ not changed. A pre-existing unmarked `[model_providers.opencodex]` block is
90
+ treated as user-owned and refused. Remove a stale manual block first with
91
+ `ocx restore --remove-codex-provider-table` if needed.
92
+
68
93
  The model list is live. Cline ACP models are exposed as `cline-cli/<encoded-id>`;
69
94
  Gemini ACP models are exposed as `gemini-cli/<encoded-id>`. Each provider keeps a
70
95
  `default` selector. A provider prefix routes only to that vendor; unprefixed
@@ -84,6 +109,20 @@ opencodex catalog, otherwise it writes `$CODEX_HOME/opencodex-catalog.json` and
84
109
  prints the activation setting. `model_catalog_url` avoids the file when the app
85
110
  catalog is fetched dynamically.
86
111
 
112
+ Codex 0.165.0 can use the Responses WebSocket transport when the provider sets
113
+ `supports_websockets = true` and the process is started with
114
+ `--enable responses_websockets`. opencodex accepts the WebSocket on
115
+ `/v1/responses`, handles the `generate=false` warmup, and maps the following
116
+ `previous_response_id` onto the stored Gemini session. HTTP remains the fallback.
117
+
118
+ Gemini context capacity, prompt modalities, and session-load support are
119
+ discovered from the installed ACP agent at runtime; the proxy does not hardcode a
120
+ model context window. A reusable Codex conversation uses
121
+ `previous_response_id`, `prompt_cache_key`, or a documented conversation header
122
+ to load the vendor session and send only the new input. Tool-use turns fall back
123
+ to bounded replay because the vendor session is cancelled when a tool intent is
124
+ captured.
125
+
87
126
  ## Claude Code
88
127
 
89
128
  ```bash
@@ -98,18 +137,31 @@ Use a `cline-cli/...` model to route the same Messages surface to Cline.
98
137
  ## Security boundary
99
138
 
100
139
  - Neither Cline nor Gemini receives filesystem or terminal capability from the proxy.
140
+ - Cline turns use a request-scoped Cline home with built-in/plugin tools, hooks,
141
+ telemetry, updates, and automatic approval disabled. The real vendor provider
142
+ store is immutable; a mode-0600 copy inside the staged home lets the vendor CLI
143
+ refresh auth state and is removed after the turn.
144
+ - Codex restore uses a mode-0600 journal under `$OPENCODEX_HOME/codex/`. The
145
+ journal contains the pre-image needed to reverse only opencodex-managed config
146
+ and catalog changes; it is never logged and is removed after a successful restore.
101
147
  - Gemini native tools are removed by a per-turn deny policy; permission requests
102
148
  are cancelled, and no ACP filesystem/terminal client capabilities are advertised.
103
149
  User MCP servers, extensions, skills, and hooks are disabled for the session; only
104
150
  the isolated `opencodex` MCP catalog can receive a client tool call.
151
+ - Gemini runs with a staged `GEMINI_CLI_HOME` under opencodex control; only the
152
+ minimum vendor auth files are linked from the real home, and staged session
153
+ state is bounded and reclaimed.
105
154
  - Declared client tools are exposed only through a request-scoped MCP process that
106
155
  records bounded intent and never executes; Codex owns approval and execution.
107
156
  - Vendor credentials remain in the vendor's real home or authenticated
108
- environment. The proxy stores no vendor token.
157
+ environment. Cline's request-scoped copy is never logged or persisted beyond
158
+ the turn. The proxy stores no vendor token.
109
159
  - Request-path traffic never starts an interactive login.
110
160
  - The listener is loopback-only by default; a non-loopback listener requires an
111
161
  explicit API token.
112
- - Image, video, and audio input are rejected before a vendor process starts.
162
+ - Image input is forwarded only when the selected Gemini model and ACP agent
163
+ advertise image capability; video and audio input are rejected before a vendor
164
+ process starts.
113
165
 
114
166
  ## Commands
115
167
 
@@ -121,6 +173,9 @@ bun run src/cli/index.ts provider list [--json]
121
173
  bun run src/cli/index.ts start [--port 10100] [--host 127.0.0.1]
122
174
  bun run src/cli/index.ts status [--json]
123
175
  bun run src/cli/index.ts stop [--json]
176
+ bun run src/cli/index.ts restore [--json] [--remove-codex-provider-table]
177
+ bun run src/cli/index.ts restore back [--json]
178
+ bun run src/cli/index.ts eject
124
179
  ```
125
180
 
126
181
  Gemini has no proxy login command. Authenticate it through the official CLI
@@ -135,7 +190,12 @@ bun test tests
135
190
  bun run test:e2e
136
191
  bun run test:e2e:gemini
137
192
  bun run test:e2e:gemini-tool
193
+ bun run test:e2e:gemini-multiturn
194
+ bun run test:e2e:gemini-previous-response
195
+ bun run test:e2e:gemini-websocket
196
+ bun run test:e2e:gemini-websocket-tool
138
197
  bun run test:e2e:gemini-reasoning
198
+ bun run test:e2e:codex-restore
139
199
  bun run privacy:scan
140
200
  bun run structure:check
141
201
  ```
@@ -153,6 +213,19 @@ Codex's `exec_command`, Codex executes a fixed read-only `printf`, and Gemini
153
213
  returns the final answer. Both commands use no retries. Missing binaries,
154
214
  authentication, or a Flash Lite model cause a hard failure.
155
215
 
216
+ `test:e2e:gemini-multiturn` starts a persisted real Codex session, resumes it with
217
+ `codex exec resume`, and asserts that the proxy loads the Gemini session and
218
+ sends only the new turn. Codex 0.165.0 sends `prompt_cache_key` for this path.
219
+
220
+ `test:e2e:gemini-previous-response` sends a direct Responses continuation with
221
+ `previous_response_id` and verifies that the proxy loads the stored Gemini
222
+ session and sends only the new input.
223
+
224
+ `test:e2e:gemini-websocket` and `test:e2e:gemini-websocket-tool` enable Codex's
225
+ actual WebSocket transport with `supports_websockets = true` and
226
+ `--enable responses_websockets`. They verify the `generate=false` warmup, the
227
+ real request's non-empty `previous_response_id`, and the WebSocket tool loop.
228
+
156
229
  `test:e2e:gemini-reasoning` discovers a thinking-capable model and level from the
157
230
  local Gemini CLI, validates the refreshed Codex catalog, and runs one minimal
158
231
  reasoning-enabled turn. It fails when the authenticated account cannot execute
@@ -164,8 +237,10 @@ Optional overrides are `CLINE_BIN_PATH`, `GEMINI_BIN_PATH`, and `CODEX_BIN_PATH`
164
237
 
165
238
  - **Cline not found**: install `cline` and verify `cline --help` works.
166
239
  - **Cline authentication required**: run `ocx login cline-cli`, then retry.
167
- - **Cline ACP unavailable**: the adapter falls back to the one-shot JSON path when
168
- the model is `default`; native model selection requires ACP.
240
+ - **Cline ACP unavailable**: opencodex no longer falls back to the one-shot JSON
241
+ path. Upgrade Cline so `cline --help` advertises `--acp`, or set
242
+ `OPENCODEX_CLINE_ACP=1`; otherwise requests fail with
243
+ `cline_acp_unsupported`.
169
244
  - **Gemini not found**: install it and verify `gemini --help` works, or set
170
245
  `GEMINI_BIN_PATH`.
171
246
  - **Gemini authentication required**: configure a Gemini Developer API key or
@@ -176,6 +251,9 @@ Optional overrides are `CLINE_BIN_PATH`, `GEMINI_BIN_PATH`, and `CODEX_BIN_PATH`
176
251
  quota; the catalog stays accurate but that model cannot complete a live turn.
177
252
  - **Codex model picker is stale**: run `ocx codex sync`, then restart the Codex
178
253
  app/app-server.
254
+ - **Codex provider conflict**: an existing unmarked
255
+ `[model_providers.opencodex]` was preserved. Reconcile it manually or use
256
+ `ocx restore --remove-codex-provider-table`, then start again.
179
257
  - **Port in use**: choose another port with `ocx start --port <port>`.
180
258
  - **Stale runtime state**: run `ocx stop`; it removes stale state only when the
181
259
  recorded process and health endpoint cannot be verified.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koreanhole/opencodex",
3
- "version": "2.97.0",
3
+ "version": "2.99.0",
4
4
  "description": "Cline and Gemini CLI provider proxy for OpenAI Codex and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -32,7 +32,12 @@
32
32
  "test:e2e": "bun test tests/e2e/cline-only.test.ts",
33
33
  "test:e2e:gemini": "bun test ./tests/e2e/gemini-codex.live.ts -t \"one minimal Flash Lite completion\"",
34
34
  "test:e2e:gemini-tool": "bun test ./tests/e2e/gemini-codex.live.ts -t \"one short Gemini tool call\"",
35
+ "test:e2e:gemini-multiturn": "bun test ./tests/e2e/gemini-codex.live.ts -t \"reuses a live Codex multi-turn session\"",
36
+ "test:e2e:gemini-previous-response": "bun test ./tests/e2e/gemini-codex.live.ts -t \"loads a live Gemini session for a previous_response_id continuation\"",
37
+ "test:e2e:gemini-websocket": "bun test ./tests/e2e/gemini-codex.live.ts -t \"runs a live Codex WebSocket turn\"",
38
+ "test:e2e:gemini-websocket-tool": "bun test ./tests/e2e/gemini-codex.live.ts -t \"runs a live Codex WebSocket tool loop\"",
35
39
  "test:e2e:gemini-reasoning": "bun test ./tests/e2e/gemini-reasoning.live.ts",
40
+ "test:e2e:codex-restore": "bun test ./tests/e2e/codex-restore.live.ts",
36
41
  "typecheck": "bun check --no-script --noEmit",
37
42
  "privacy:scan": "bun scripts/privacy-scan.ts",
38
43
  "structure:check": "bun scripts/structure-check.ts",
@@ -49,9 +49,19 @@ export interface AcpAuthMethod {
49
49
  description?: string;
50
50
  }
51
51
 
52
+ export interface AcpAgentCapabilities {
53
+ loadSession: boolean;
54
+ prompt: {
55
+ image: boolean;
56
+ audio: boolean;
57
+ embeddedContext: boolean;
58
+ };
59
+ }
60
+
52
61
  export interface AcpInitialize {
53
62
  protocolVersion: number;
54
63
  authMethods: readonly AcpAuthMethod[];
64
+ capabilities: AcpAgentCapabilities;
55
65
  agentVersion?: string;
56
66
  }
57
67
 
@@ -224,7 +234,21 @@ export function parseAcpInitialize(
224
234
  : [];
225
235
  const agentInfo = asRecord(result.agentInfo);
226
236
  const agentVersion = nonEmpty(agentInfo?.version, 256);
227
- return { protocolVersion: version, authMethods, ...(agentVersion ? { agentVersion } : {}) };
237
+ const agentCapabilities = asRecord(result.agentCapabilities);
238
+ const promptCapabilities = asRecord(agentCapabilities?.promptCapabilities);
239
+ return {
240
+ protocolVersion: version,
241
+ authMethods,
242
+ capabilities: {
243
+ loadSession: agentCapabilities?.loadSession === true,
244
+ prompt: {
245
+ image: promptCapabilities?.image === true,
246
+ audio: promptCapabilities?.audio === true,
247
+ embeddedContext: promptCapabilities?.embeddedContext === true,
248
+ },
249
+ },
250
+ ...(agentVersion ? { agentVersion } : {}),
251
+ };
228
252
  }
229
253
 
230
254
  function acpError(error: unknown, fallbackAllowed: boolean, label: string): AcpError {
@@ -443,6 +467,32 @@ export class AcpClient {
443
467
  }
444
468
  }
445
469
 
470
+ async loadSession(params: {
471
+ sessionId: string;
472
+ cwd: string;
473
+ mcpServers: readonly Record<string, unknown>[];
474
+ }): Promise<AcpSessionInfo> {
475
+ try {
476
+ const response = await deadline(
477
+ this.connection.request<unknown>(acp.AGENT_METHODS.session_load, {
478
+ sessionId: params.sessionId,
479
+ cwd: params.cwd,
480
+ mcpServers: params.mcpServers.slice(0, MAX_MCP_SERVERS),
481
+ }),
482
+ this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
483
+ () => this.terminate(),
484
+ this.label,
485
+ this.fallbackAllowed,
486
+ );
487
+ const session = parseAcpSession(response, this.label, this.fallbackAllowed);
488
+ this.sessionId = session.sessionId;
489
+ return session;
490
+ } catch (error) {
491
+ if (error instanceof AcpError) throw error;
492
+ throw acpError(error, this.fallbackAllowed, this.label);
493
+ }
494
+ }
495
+
446
496
  async setModel(sessionId: string, modelId: string): Promise<void> {
447
497
  await deadline(
448
498
  this.connection.request("session/set_model", { sessionId, modelId }),
@@ -499,11 +549,18 @@ export class AcpClient {
499
549
  });
500
550
  }
501
551
 
502
- async prompt(sessionId: string, text: string, cancellationSignal?: AbortSignal): Promise<acp.PromptResponse> {
552
+ async prompt(
553
+ sessionId: string,
554
+ content: string | readonly acp.ContentBlock[],
555
+ cancellationSignal?: AbortSignal,
556
+ ): Promise<acp.PromptResponse> {
557
+ const prompt = typeof content === "string"
558
+ ? [{ type: "text" as const, text: content }]
559
+ : content;
503
560
  return await deadline(
504
561
  this.connection.request(
505
562
  acp.AGENT_METHODS.session_prompt,
506
- { sessionId, prompt: [{ type: "text" as const, text }] },
563
+ { sessionId, prompt },
507
564
  { ...(cancellationSignal ? { cancellationSignal } : {}) },
508
565
  ),
509
566
  this.deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
@@ -522,7 +522,6 @@ export async function runClineAcpTurn(
522
522
  let client: AcpClient | undefined;
523
523
  let session: ClineAcpSessionInfo | undefined;
524
524
  let terminalEmitted = false;
525
- let sawOutput = false;
526
525
  let safetyError: string | undefined;
527
526
  let aborted = false;
528
527
  let timedOut = false;
@@ -531,10 +530,6 @@ export async function runClineAcpTurn(
531
530
  const seenToolCalls = new Set<string>();
532
531
 
533
532
  const deliver = (event: AdapterEvent): void => {
534
- if (event.type === "text_delta" || event.type === "thinking_delta"
535
- || event.type === "tool_call_start" || event.type === "tool_call_delta" || event.type === "tool_call_end") {
536
- sawOutput = true;
537
- }
538
533
  if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
539
534
  if (terminalEmitted) return;
540
535
  terminalEmitted = true;
@@ -593,7 +588,7 @@ export async function runClineAcpTurn(
593
588
  };
594
589
 
595
590
  try {
596
- client = await AcpClient.connect({ binary: input.binary, env: input.env, cwd: input.cwd, hooks, label: "Cline ACP", fallbackAllowed: true, ...(input.args ? { args: input.args } : {}) }, deps);
591
+ client = await AcpClient.connect({ binary: input.binary, env: input.env, cwd: input.cwd, hooks, label: "Cline ACP", fallbackAllowed: false, ...(input.args ? { args: input.args } : {}) }, deps);
597
592
  session = await client.newSession({ cwd: input.cwd, mcpServers: input.mcpServers });
598
593
  await applyClineAcpModel(client, session, input.modelId);
599
594
 
@@ -677,7 +672,6 @@ export async function runClineAcpTurn(
677
672
  });
678
673
  }
679
674
  } catch (error) {
680
- if (error instanceof AcpError && error.fallbackAllowed && !sawOutput) throw error;
681
675
  if (!terminalEmitted) {
682
676
  const failure = timedOut
683
677
  ? new ClineAcpError("Cline ACP turn timed out.", 504, "upstream_error", "timeout", true, false)
@@ -1,19 +1,14 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import { writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
2
  import type { AdapterEvent, IncomingMeta, OcxParsedRequest } from "../../../types";
5
3
  import { buildCodingAgentToolCatalog, CodingAgentToolCatalogError } from "../../tool-catalog";
6
4
  import { buildConversationInput, buildSystemPrompt } from "../protocol";
7
5
  import { whichFromPath, type WhichFn } from "../../coding-agent/profile";
8
6
  import { isStandaloneBinary } from "../../../lib/standalone";
9
- import { runCliAgentTurn } from "../turn";
10
7
  import type { CliAgentTurnDeps } from "../types";
11
8
  import {
12
9
  assertClineAdvisoryDestinationSafe,
13
10
  buildClineChildEnv,
14
11
  ClineSettingsError,
15
- mergeClineDisabledTools,
16
- preflightClineTools,
17
12
  resolveClineProviderSettingsPath,
18
13
  stageClineTurn,
19
14
  type ClineStagedTurn,
@@ -23,12 +18,9 @@ import {
23
18
  buildClineAcpEnv,
24
19
  probeClineAcpSupport,
25
20
  runClineAcpTurn,
26
- stageClineAcpWorkspace,
27
21
  type ClineAcpDeps,
28
- type ClineAcpMode,
29
22
  type ClineAcpTurnInput,
30
23
  } from "./acp";
31
- import { classifyClineFailure, createClineStreamState, mapClineMessage } from "./stream";
32
24
  import { AcpError } from "../acp-client";
33
25
 
34
26
  export const CLINE_CLI_BASE_URL = "https://api.cline.bot";
@@ -50,9 +42,7 @@ export interface ClineTurnDeps extends CliAgentTurnDeps {
50
42
  env?: NodeJS.ProcessEnv;
51
43
  providerSettingsPath?: string;
52
44
  stageTurn?: typeof stageClineTurn;
53
- preflightTools?: typeof preflightClineTools;
54
45
  probeAcp?: typeof probeClineAcpSupport;
55
- stageAcp?: typeof stageClineAcpWorkspace;
56
46
  runAcp?: (input: ClineAcpTurnInput, emit: (event: AdapterEvent) => void, deps?: ClineAcpDeps) => Promise<void>;
57
47
  acpDeps?: ClineAcpDeps;
58
48
  }
@@ -92,10 +82,6 @@ export function buildClinePrompt(
92
82
  ].filter(Boolean).join("\n\n");
93
83
  }
94
84
 
95
- export function buildClineArgs(): string[] {
96
- return ["--json", "Follow the piped conversation and instructions."];
97
- }
98
-
99
85
  function toolNameMap(catalog: ReturnType<typeof buildCodingAgentToolCatalog>): Map<string, string> {
100
86
  const names = new Map<string, string>();
101
87
  for (const [vendorName, wireName] of catalog.emittedNameMap) {
@@ -192,12 +178,14 @@ export async function runClineTurn(
192
178
  return;
193
179
  }
194
180
 
195
- const vendorNames = toolNameMap(catalog);
196
- const vendorNameByWire = new Map([...catalog.emittedNameMap.values()].map(wireName => [
197
- wireName,
198
- wireName.startsWith("opencodex__") ? wireName : `opencodex__${wireName}`,
199
- ]));
200
- const fixedSystem = systemPromptWithToolNames(catalog);
181
+ const providerSettingsPath = deps.providerSettingsPath ?? resolveClineProviderSettingsPath(sourceEnv);
182
+ try {
183
+ await assertClineAdvisoryDestinationSafe(providerSettingsPath);
184
+ } catch (error) {
185
+ emit(settingsError(error));
186
+ return;
187
+ }
188
+
201
189
  const acpDeps: ClineAcpDeps = {
202
190
  ...deps.acpDeps,
203
191
  env: sourceEnv,
@@ -209,153 +197,68 @@ export async function runClineTurn(
209
197
  ...(deps.killWindowsProcessTree ? { killWindowsProcessTree: deps.killWindowsProcessTree } : {}),
210
198
  };
211
199
  const acpEnv = buildClineAcpEnv(sourceEnv);
212
- let acpMode: ClineAcpMode = { acp: false };
200
+ let acpMode: { acp: boolean; autoApproveFlag?: boolean } = { acp: false };
213
201
  try {
214
202
  acpMode = await (deps.probeAcp ?? probeClineAcpSupport)(binary, acpEnv, acpDeps);
215
203
  } catch {
216
204
  acpMode = { acp: false };
217
205
  }
218
-
219
- if (acpMode.acp) {
220
- let workspace: Awaited<ReturnType<typeof stageClineAcpWorkspace>> | undefined;
221
- try {
222
- workspace = await (deps.stageAcp ?? stageClineAcpWorkspace)(catalog.tools);
223
- const mcpServers = workspace.catalogPath && catalog.emittedNameMap.size > 0
224
- ? [{
225
- type: "stdio",
226
- name: CLINE_MCP_SERVER_NAME,
227
- command: process.execPath,
228
- args: isStandaloneBinary() ? ["__cli-agent-mcp", workspace.catalogPath] : [CLINE_MCP_SERVER_PATH, workspace.catalogPath],
229
- env: [],
230
- }]
231
- : [];
232
- let acpOutput = false;
233
- const acpEmit = (event: AdapterEvent): void => {
234
- if (event.type === "text_delta" || event.type === "thinking_delta"
235
- || event.type === "tool_call_start" || event.type === "tool_call_delta" || event.type === "tool_call_end") {
236
- acpOutput = true;
237
- }
238
- emit(event);
239
- };
240
- try {
241
- await (deps.runAcp ?? runClineAcpTurn)({
242
- binary,
243
- env: acpEnv,
244
- cwd: workspace.cwd,
245
- modelId: requestedModel,
246
- prompt: buildClinePrompt(parsed, vendorNameByWire, fixedSystem),
247
- toolNames: vendorNames,
248
- mcpServers,
249
- args: acpMode.autoApproveFlag ? ["--acp", "--auto-approve", "false"] : ["--acp"],
250
- maxToolCalls: 16,
251
- requireToolCall: catalog.requireToolCall,
252
- incoming,
253
- deps: acpDeps,
254
- }, acpEmit, acpDeps);
255
- return;
256
- } catch (error) {
257
- if (!(error instanceof AcpError && error.fallbackAllowed && !acpOutput && requestedModel === "default")) {
258
- emit(acpFailure(error));
259
- return;
260
- }
261
- if (error.authRequired) {
262
- console.warn("[opencodex] Cline ACP authentication required [code=acp_auth_required]; using one-shot fallback.");
263
- } else {
264
- console.warn("[opencodex] Cline ACP unavailable; using one-shot fallback.");
265
- }
266
- }
267
- } finally {
268
- await workspace?.cleanup();
269
- }
270
- }
271
-
272
- if (requestedModel !== CLINE_CLI_MODEL_ID) {
273
- emit({ type: "error", message: `Cline ACP is required to select model "${requestedModel}". Enable ACP or use the default model.`, status: 400, errorType: "invalid_request_error", code: "cline_model_unsupported", retryable: false });
274
- return;
275
- }
276
-
277
- const providerSettingsPath = deps.providerSettingsPath ?? resolveClineProviderSettingsPath(sourceEnv);
278
- try {
279
- await assertClineAdvisoryDestinationSafe(providerSettingsPath);
280
- } catch (error) {
281
- emit(settingsError(error));
282
- return;
283
- }
284
-
285
- const mcpLaunch = catalog.emittedNameMap.size > 0
286
- ? (isStandaloneBinary()
287
- ? { kind: "entrypoint" as const, entrypoint: "__cli-agent-mcp" }
288
- : { kind: "module" as const, modulePath: CLINE_MCP_SERVER_PATH })
289
- : undefined;
290
- let staged: ClineStagedTurn;
291
- try {
292
- staged = await (deps.stageTurn ?? stageClineTurn)({
293
- providerSettingsPath,
294
- catalog: catalog.tools,
295
- ...(mcpLaunch ? { mcpLaunch } : {}),
296
- });
297
- } catch (error) {
298
- emit(settingsError(error));
299
- return;
300
- }
301
-
302
- const env = buildClineChildEnv(staged, sourceEnv);
303
- const preflightTools = deps.preflightTools ?? preflightClineTools;
304
- let preflight = await preflightTools({ binary, env, cwd: staged.workspaceDir });
305
- if (!preflight.ok && preflight.enabledTools && preflight.enabledTools.length > 0) {
306
- try {
307
- await mergeClineDisabledTools(staged, preflight.enabledTools.map(tool => tool.id));
308
- } catch (error) {
309
- await staged.cleanup();
310
- emit(settingsError(error));
311
- return;
312
- }
313
- preflight = await preflightTools({ binary, env, cwd: staged.workspaceDir });
314
- }
315
- if (!preflight.ok) {
316
- await staged.cleanup();
206
+ if (!acpMode.acp) {
317
207
  emit({
318
208
  type: "error",
319
- message: preflight.detail || "Cline tool preflight failed.",
320
- status: 502,
321
- errorType: "upstream_error",
322
- code: preflight.unsupported ? "cline_tool_preflight_unsupported" : "cline_tool_preflight_failed",
209
+ message: "The installed Cline CLI does not advertise --acp. Upgrade Cline or enable ACP.",
210
+ status: 400,
211
+ errorType: "invalid_request_error",
212
+ code: "cline_acp_unsupported",
323
213
  retryable: false,
324
214
  });
325
215
  return;
326
216
  }
327
217
 
328
- const state = createClineStreamState();
329
- const promptFile = join(staged.root, "prompt.txt");
218
+ const vendorNames = toolNameMap(catalog);
219
+ const vendorNameByWire = new Map([...catalog.emittedNameMap.values()].map(wireName => [
220
+ wireName,
221
+ wireName.startsWith("opencodex__") ? wireName : `opencodex__${wireName}`,
222
+ ]));
223
+ const fixedSystem = systemPromptWithToolNames(catalog);
224
+ let staged: ClineStagedTurn | undefined;
330
225
  try {
331
- await writeFile(promptFile, buildClinePrompt(parsed, vendorNameByWire, fixedSystem), {
332
- encoding: "utf8",
333
- mode: 0o600,
334
- flag: "wx",
226
+ staged = await (deps.stageTurn ?? stageClineTurn)({
227
+ providerSettingsPath,
228
+ catalog: catalog.tools,
335
229
  });
336
- } catch {
337
- await staged.cleanup();
338
- emit({ type: "error", message: "Cline prompt could not be staged securely.", status: 500, errorType: "upstream_error", code: "cli_prompt_staging_failed", retryable: false });
339
- return;
340
- }
341
-
342
- await runCliAgentTurn({
343
- label: "Cline CLI",
344
- binary,
345
- args: buildClineArgs(),
346
- env,
347
- cwd: staged.workspaceDir,
348
- stdin: "",
349
- stdinFile: promptFile,
350
- state,
351
- requireToolCall: catalog.requireToolCall,
352
- classifyFailure: ({ exitCode, stderr }) => classifyClineFailure(exitCode, stderr),
353
- parseLine: (message, current) => mapClineMessage(message, current, {
230
+ const env = buildClineChildEnv(staged, sourceEnv);
231
+ const mcpServers = catalog.emittedNameMap.size > 0
232
+ ? [{
233
+ type: "stdio",
234
+ name: CLINE_MCP_SERVER_NAME,
235
+ command: process.execPath,
236
+ args: isStandaloneBinary()
237
+ ? ["__cli-agent-mcp", staged.catalogPath]
238
+ : [CLINE_MCP_SERVER_PATH, staged.catalogPath],
239
+ env: [],
240
+ }]
241
+ : [];
242
+ await (deps.runAcp ?? runClineAcpTurn)({
243
+ binary,
244
+ env,
245
+ cwd: staged.workspaceDir,
246
+ modelId: requestedModel,
247
+ prompt: buildClinePrompt(parsed, vendorNameByWire, fixedSystem),
354
248
  toolNames: vendorNames,
249
+ mcpServers,
250
+ args: acpMode.autoApproveFlag ? ["--acp", "--auto-approve", "false"] : ["--acp"],
355
251
  maxToolCalls: 16,
356
- }),
357
- cleanup: staged.cleanup,
358
- }, incoming, emit, deps);
252
+ requireToolCall: catalog.requireToolCall,
253
+ incoming,
254
+ deps: acpDeps,
255
+ }, emit, acpDeps);
256
+ } catch (error) {
257
+ if (error instanceof ClineSettingsError) emit(settingsError(error));
258
+ else emit(acpFailure(error));
259
+ } finally {
260
+ await staged?.cleanup();
261
+ }
359
262
  }
360
263
 
361
264
  export { resolveClineBinary } from "./binary";