@nyssance/codex-acp-v2 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.js +289 -76
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-09-11
4
+
5
+ ### Added
6
+
7
+ - `providers/set` hints `_meta.codex.bearerToken` (written as `experimental_bearer_token`)
8
+ and `_meta.codex.config` (top-level Codex thread-config overrides that ride with the
9
+ gateway; a `model_catalog_json` file is read so gateway models carry their real reasoning
10
+ levels and modalities). `providers/disable` removes both from live threads.
11
+ - Catalog mode (`_meta.codex.mode: "catalog"`): the gateway's models are offered next to
12
+ Codex's in every session's `model` option as their own select group; selecting one moves
13
+ only that session to the gateway (unsubscribe + resume, materializing an empty thread
14
+ first), selecting a Codex model moves it back. `session/new` / `session/fork` honour
15
+ `_meta.alwith.model`; `session/resume` follows the provider Codex recorded for the thread.
16
+ Advertised as `capabilities._meta.codex.providerCatalog`.
17
+
18
+ ### Fixed
19
+
20
+ - A thread another Codex client is writing ("already has an active writer", a file lock
21
+ across the Codex home) opens as a read-only session through `thread/read`, like Codex's
22
+ TUI: history replays, the response carries `_meta.codex.readOnly`, prompts and config
23
+ changes are refused with `-32600`, and a later resume retries the real thing.
24
+
3
25
  ## 0.4.0 — 2026-09-10
4
26
 
5
27
  ### Added
package/dist/index.js CHANGED
@@ -3406,8 +3406,8 @@ var require_main = __commonJS(function(exports) {
3406
3406
  exports.createMessageConnection = createMessageConnection;
3407
3407
  var ril_1 = __importDefault(require_ril());
3408
3408
  ril_1.default.install();
3409
- var path7 = __importStar(__require("path"));
3410
- var os2 = __importStar(__require("os"));
3409
+ var path8 = __importStar(__require("path"));
3410
+ var os3 = __importStar(__require("os"));
3411
3411
  var fs7 = __importStar(__require("fs"));
3412
3412
  var crypto_1 = __require("crypto");
3413
3413
  var net_1 = __require("net");
@@ -3555,7 +3555,7 @@ var require_main = __commonJS(function(exports) {
3555
3555
  }
3556
3556
  let randomLength = 32;
3557
3557
  const fixedLength = "/lsp-.sock".length;
3558
- const tmpDir = fs7.realpathSync(XDG_RUNTIME_DIR ?? os2.tmpdir());
3558
+ const tmpDir = fs7.realpathSync(XDG_RUNTIME_DIR ?? os3.tmpdir());
3559
3559
  const limit = safeIpcPathLengths.get(process.platform);
3560
3560
  if (limit !== undefined) {
3561
3561
  randomLength = Math.min(limit - tmpDir.length - fixedLength, randomLength);
@@ -3564,7 +3564,7 @@ var require_main = __commonJS(function(exports) {
3564
3564
  throw new Error(`Unable to generate a random pipe name with ${randomLength} characters.`);
3565
3565
  }
3566
3566
  const randomSuffix = (0, crypto_1.randomBytes)(Math.floor(randomLength / 2)).toString("hex");
3567
- return path7.join(tmpDir, `lsp-${randomSuffix}.sock`);
3567
+ return path8.join(tmpDir, `lsp-${randomSuffix}.sock`);
3568
3568
  }
3569
3569
  function createClientPipeTransport(pipeName, encoding = "utf-8") {
3570
3570
  let connectResolve;
@@ -21721,7 +21721,7 @@ var package_default = {
21721
21721
  publishConfig: {
21722
21722
  access: "public"
21723
21723
  },
21724
- version: "0.4.0",
21724
+ version: "0.5.0",
21725
21725
  description: "Native ACP v2 agent for the OpenAI Codex app-server",
21726
21726
  packageManager: "bun@1.4.0",
21727
21727
  engines: {
@@ -21838,7 +21838,7 @@ function classifyTurnError(info) {
21838
21838
  }
21839
21839
 
21840
21840
  // src/agent/CodexAgent.ts
21841
- import path6 from "path";
21841
+ import path7 from "path";
21842
21842
 
21843
21843
  // src/util/logger.ts
21844
21844
  import fs from "fs";
@@ -23982,7 +23982,7 @@ function modelLacksReasoning(model) {
23982
23982
  const options = supportedEfforts(model);
23983
23983
  return options.length > 0 && options.every((option) => option.reasoningEffort === "none");
23984
23984
  }
23985
- function modelConfigOption(catalog, current) {
23985
+ function modelConfigOption(catalog, current, gatewayGroup = null) {
23986
23986
  const visible = catalog.filter((model) => !model.hidden || model.id === current);
23987
23987
  const options = visible.map((model) => ({
23988
23988
  value: model.id,
@@ -23992,14 +23992,25 @@ function modelConfigOption(catalog, current) {
23992
23992
  if (!options.some((option) => option.value === current)) {
23993
23993
  options.unshift({ value: current, name: current, description: null });
23994
23994
  }
23995
- return {
23995
+ const base = {
23996
23996
  configId: MODEL_CONFIG_ID,
23997
23997
  name: "Model",
23998
23998
  description: "Model Codex uses for this session",
23999
23999
  category: "model",
24000
24000
  type: "select",
24001
- currentValue: current,
24002
- options
24001
+ currentValue: current
24002
+ };
24003
+ if (gatewayGroup === null)
24004
+ return { ...base, options };
24005
+ const gatewayIds = new Set(gatewayGroup.modelIds);
24006
+ const native = options.filter((option) => !gatewayIds.has(option.value));
24007
+ const gateway = options.filter((option) => gatewayIds.has(option.value));
24008
+ return {
24009
+ ...base,
24010
+ options: [
24011
+ { groupId: "codex", name: "Codex", options: native },
24012
+ { groupId: "custom-gateway", name: gatewayGroup.name, options: gateway }
24013
+ ]
24003
24014
  };
24004
24015
  }
24005
24016
  function effortConfigOption(efforts, current) {
@@ -25955,7 +25966,7 @@ function sessionConfigOptions(session) {
25955
25966
  const model = findModel(session.catalog, session.model.model);
25956
25967
  const options = [
25957
25968
  modeConfigOption(session.mode),
25958
- modelConfigOption(session.catalog, session.model.model),
25969
+ modelConfigOption(session.catalog, session.model.model, session.gatewayGroup),
25959
25970
  effortConfigOption(supportedEfforts(model), session.model.effort),
25960
25971
  collaborationModeConfigOption(session.collaborationMode)
25961
25972
  ];
@@ -26057,10 +26068,12 @@ function firstLine(text2) {
26057
26068
  }
26058
26069
 
26059
26070
  // src/agent/providers.ts
26071
+ import { readFileSync } from "fs";
26072
+ import os2 from "os";
26073
+ import path6 from "path";
26060
26074
  var OPENAI_PROVIDER_ID = "openai";
26061
26075
  var GATEWAY_MODEL_PROVIDER = "custom-gateway";
26062
26076
  var DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
26063
-
26064
26077
  class ProviderRouting {
26065
26078
  baseConfig;
26066
26079
  configuredProvider;
@@ -26078,12 +26091,14 @@ class ProviderRouting {
26078
26091
  return this.gateway;
26079
26092
  }
26080
26093
  list() {
26094
+ const routed = this.gateway !== null && this.gateway.mode === "route";
26081
26095
  return {
26082
26096
  providers: [{
26083
26097
  providerId: OPENAI_PROVIDER_ID,
26084
26098
  supported: ["openai"],
26085
26099
  required: false,
26086
- current: this.gateway ? { apiType: "openai", baseUrl: this.gateway.baseUrl } : { apiType: "openai", baseUrl: this.nativeBaseUrl() }
26100
+ current: routed && this.gateway ? { apiType: "openai", baseUrl: this.gateway.baseUrl } : { apiType: "openai", baseUrl: this.nativeBaseUrl() },
26101
+ ...this.gateway && !routed ? { _meta: { codex: { mode: "catalog", gateway: { name: this.gateway.name, baseUrl: this.gateway.baseUrl, models: this.gateway.models.map((model) => model.id) } } } } : {}
26087
26102
  }]
26088
26103
  };
26089
26104
  }
@@ -26104,37 +26119,83 @@ class ProviderRouting {
26104
26119
  headers: { ...request.headers ?? {} },
26105
26120
  name: hints.name ?? "Client-configured gateway",
26106
26121
  models: hints.models,
26107
- model: hints.model
26122
+ model: hints.model,
26123
+ bearerToken: hints.bearerToken,
26124
+ config: hints.config,
26125
+ mode: hints.mode,
26126
+ catalogEntries: hints.catalogEntries
26108
26127
  };
26109
26128
  }
26129
+ get mode() {
26130
+ return this.gateway?.mode ?? "route";
26131
+ }
26132
+ isGatewayModel(modelId) {
26133
+ return this.gateway !== null && this.gateway.models.some((model) => model.id === modelId);
26134
+ }
26135
+ gatewayGroup() {
26136
+ if (!this.gateway || this.gateway.mode !== "catalog" || this.gateway.models.length === 0)
26137
+ return null;
26138
+ return { name: this.gateway.name, modelIds: this.gateway.models.map((model) => model.id) };
26139
+ }
26110
26140
  disable(request) {
26111
26141
  if (request.providerId === OPENAI_PROVIDER_ID)
26112
26142
  this.gateway = null;
26113
26143
  }
26114
26144
  modelProvider() {
26115
- return this.gateway ? GATEWAY_MODEL_PROVIDER : this.configuredProvider;
26145
+ return this.gateway && this.gateway.mode === "route" ? GATEWAY_MODEL_PROVIDER : this.configuredProvider;
26146
+ }
26147
+ routesByDefault() {
26148
+ return this.gateway !== null && this.gateway.mode === "route";
26116
26149
  }
26117
- threadConfig() {
26118
- if (!this.gateway)
26150
+ threadConfig(onGateway = this.routesByDefault()) {
26151
+ if (!this.gateway || !onGateway)
26119
26152
  return this.baseConfig;
26120
- const existing = isRecord2(this.baseConfig["model_providers"]) ? this.baseConfig["model_providers"] : {};
26153
+ return this.applyGateway(this.baseConfig, this.gateway);
26154
+ }
26155
+ routeConfig(config2, onGateway) {
26156
+ return this.rebind(config2, this, onGateway);
26157
+ }
26158
+ overrideKeys() {
26159
+ return this.gateway ? Object.keys(this.gateway.config) : [];
26160
+ }
26161
+ rebind(config2, previous, onGateway = this.routesByDefault()) {
26162
+ const next = { ...config2 };
26163
+ for (const key of previous.overrideKeys()) {
26164
+ if (key in this.baseConfig)
26165
+ next[key] = this.baseConfig[key];
26166
+ else
26167
+ delete next[key];
26168
+ }
26169
+ if (!this.gateway || !onGateway) {
26170
+ delete next["model_providers"];
26171
+ if (isRecord2(this.baseConfig["model_providers"]))
26172
+ next["model_providers"] = this.baseConfig["model_providers"];
26173
+ return next;
26174
+ }
26175
+ return this.applyGateway(next, this.gateway);
26176
+ }
26177
+ applyGateway(config2, gateway) {
26178
+ const existing = isRecord2(config2["model_providers"]) ? config2["model_providers"] : {};
26179
+ const entry = {
26180
+ name: gateway.name,
26181
+ base_url: gateway.baseUrl,
26182
+ wire_api: "responses",
26183
+ http_headers: { "X-Client-Feature-ID": "codex", ...gateway.headers }
26184
+ };
26185
+ if (gateway.bearerToken !== null)
26186
+ entry["experimental_bearer_token"] = gateway.bearerToken;
26121
26187
  return {
26122
- ...this.baseConfig,
26123
- model_providers: {
26124
- ...existing,
26125
- [GATEWAY_MODEL_PROVIDER]: {
26126
- name: this.gateway.name,
26127
- base_url: this.gateway.baseUrl,
26128
- wire_api: "responses",
26129
- http_headers: { "X-Client-Feature-ID": "codex", ...this.gateway.headers }
26130
- }
26131
- }
26188
+ ...config2,
26189
+ ...gateway.config,
26190
+ model_providers: { ...existing, [GATEWAY_MODEL_PROVIDER]: entry }
26132
26191
  };
26133
26192
  }
26134
26193
  catalog(codexCatalog) {
26135
26194
  if (!this.gateway || this.gateway.models.length === 0)
26136
26195
  return codexCatalog;
26137
- return this.gateway.models.map((model, index) => gatewayModel(model, index === 0, codexCatalog[0]));
26196
+ const gateway = this.gateway;
26197
+ const own = gateway.models.map((model, index) => gatewayModel(model, gateway.mode === "route" && index === 0, codexCatalog[0], gateway.catalogEntries.get(model.id)));
26198
+ return gateway.mode === "catalog" ? [...codexCatalog, ...own] : own;
26138
26199
  }
26139
26200
  nativeBaseUrl() {
26140
26201
  const providers = this.baseConfig["model_providers"];
@@ -26165,22 +26226,80 @@ function readHints(meta) {
26165
26226
  }
26166
26227
  const model = typeof alwith["model"] === "string" && alwith["model"].length > 0 ? alwith["model"] : null;
26167
26228
  const name = typeof codex["name"] === "string" && codex["name"].length > 0 ? codex["name"] : null;
26168
- return { models, model, name };
26229
+ const bearerToken = typeof codex["bearerToken"] === "string" && codex["bearerToken"].length > 0 ? codex["bearerToken"] : null;
26230
+ const rawConfig = codex["config"];
26231
+ if (rawConfig !== undefined && !isRecord2(rawConfig)) {
26232
+ throw RequestError.invalidParams({ config: rawConfig }, "_meta.codex.config must be an object of top-level Codex config keys");
26233
+ }
26234
+ const config2 = {};
26235
+ if (isRecord2(rawConfig)) {
26236
+ for (const [key, value] of Object.entries(rawConfig)) {
26237
+ if (key === "model_providers")
26238
+ throw RequestError.invalidParams({ config: rawConfig }, "_meta.codex.config cannot set model_providers; that entry is derived from baseUrl and headers");
26239
+ config2[key] = value;
26240
+ }
26241
+ }
26242
+ const rawMode = codex["mode"];
26243
+ if (rawMode !== undefined && rawMode !== "route" && rawMode !== "catalog") {
26244
+ throw RequestError.invalidParams({ mode: rawMode }, '_meta.codex.mode must be "route" or "catalog"');
26245
+ }
26246
+ const mode = rawMode === "catalog" ? "catalog" : "route";
26247
+ if (mode === "catalog" && models.length === 0) {
26248
+ throw RequestError.invalidParams(undefined, `_meta.codex.mode "catalog" needs _meta.alwith.models: the models offered next to Codex's`);
26249
+ }
26250
+ const catalogPath = config2["model_catalog_json"];
26251
+ const catalogEntries = typeof catalogPath === "string" ? readCatalogFile(catalogPath) : new Map;
26252
+ return { models, model, name, bearerToken, config: config2, mode, catalogEntries };
26253
+ }
26254
+ function readCatalogFile(catalogPath) {
26255
+ const resolved = catalogPath.startsWith("~/") ? path6.join(os2.homedir(), catalogPath.slice(2)) : catalogPath;
26256
+ let parsed;
26257
+ try {
26258
+ parsed = JSON.parse(readFileSync(resolved, "utf8"));
26259
+ } catch (error40) {
26260
+ throw RequestError.invalidParams({ model_catalog_json: catalogPath }, `model_catalog_json could not be read as JSON: ${error40 instanceof Error ? error40.message : String(error40)}`);
26261
+ }
26262
+ const models = isRecord2(parsed) && Array.isArray(parsed["models"]) ? parsed["models"] : null;
26263
+ if (models === null)
26264
+ throw RequestError.invalidParams({ model_catalog_json: catalogPath }, "model_catalog_json must be {models: [...]} as Codex expects");
26265
+ const entries = new Map;
26266
+ for (const entry of models) {
26267
+ if (!isRecord2(entry) || typeof entry["slug"] !== "string")
26268
+ continue;
26269
+ const levels = Array.isArray(entry["supported_reasoning_levels"]) ? entry["supported_reasoning_levels"] : [];
26270
+ const efforts = [];
26271
+ for (const level of levels) {
26272
+ if (!isRecord2(level) || typeof level["effort"] !== "string")
26273
+ continue;
26274
+ efforts.push({ reasoningEffort: level["effort"], description: typeof level["description"] === "string" ? level["description"] : "" });
26275
+ }
26276
+ const modalities = Array.isArray(entry["input_modalities"]) ? entry["input_modalities"].filter((value) => value === "text" || value === "image") : null;
26277
+ entries.set(entry["slug"], {
26278
+ displayName: typeof entry["display_name"] === "string" ? entry["display_name"] : null,
26279
+ description: typeof entry["description"] === "string" ? entry["description"] : null,
26280
+ efforts,
26281
+ defaultEffort: typeof entry["default_reasoning_level"] === "string" ? entry["default_reasoning_level"] : null,
26282
+ inputModalities: modalities
26283
+ });
26284
+ }
26285
+ return entries;
26169
26286
  }
26170
- function gatewayModel(model, isDefault, template) {
26287
+ function gatewayModel(model, isDefault, template, entry) {
26288
+ const efforts = entry && entry.efforts.length > 0 ? entry.efforts : template?.supportedReasoningEfforts ?? [];
26289
+ const defaultEffort = entry?.defaultEffort ?? template?.defaultReasoningEffort ?? "medium";
26171
26290
  return {
26172
26291
  id: model.id,
26173
26292
  model: model.id,
26174
26293
  upgrade: null,
26175
26294
  upgradeInfo: null,
26176
26295
  availabilityNux: null,
26177
- displayName: model.name,
26178
- description: model.description ?? "",
26296
+ displayName: model.name !== model.id ? model.name : entry?.displayName ?? model.name,
26297
+ description: model.description ?? entry?.description ?? "",
26179
26298
  modelSpecialty: null,
26180
26299
  hidden: false,
26181
- supportedReasoningEfforts: template?.supportedReasoningEfforts ?? [],
26182
- defaultReasoningEffort: template?.defaultReasoningEffort ?? "medium",
26183
- inputModalities: ["text", "image"],
26300
+ supportedReasoningEfforts: efforts,
26301
+ defaultReasoningEffort: defaultEffort,
26302
+ inputModalities: entry?.inputModalities ?? ["text", "image"],
26184
26303
  supportsPersonality: false,
26185
26304
  multiAgentVersion: null,
26186
26305
  additionalSpeedTiers: [],
@@ -26323,7 +26442,7 @@ class CodexAgent {
26323
26442
  additionalDirectories: {}
26324
26443
  },
26325
26444
  providers: {},
26326
- _meta: { codex: { archive: true, seedHistory: true, skills: true, plugins: true } }
26445
+ _meta: { codex: { archive: true, seedHistory: true, skills: true, plugins: true, providerCatalog: true } }
26327
26446
  },
26328
26447
  authMethods: authMethods(this.capabilities, this.env)
26329
26448
  };
@@ -26408,23 +26527,27 @@ class CodexAgent {
26408
26527
  try {
26409
26528
  change(candidate);
26410
26529
  const catalog = await this.withCodex(() => this.codex.allModels(true));
26411
- const modelProvider = await this.resolveModelProvider(candidate);
26412
- for (const runtime of this.sessions.values()) {
26530
+ const nativeProvider = await this.resolveModelProvider(candidate);
26531
+ const wholesale = this.providers.active !== null && this.providers.mode === "route" || candidate.active !== null && candidate.mode === "route";
26532
+ const moving = [...this.sessions.values()].filter((runtime) => wholesale || runtime.modelProvider === GATEWAY_MODEL_PROVIDER);
26533
+ const targets = new Map(moving.map((runtime) => [runtime, candidate.active !== null && (candidate.mode === "route" || runtime.modelProvider === GATEWAY_MODEL_PROVIDER)]));
26534
+ for (const runtime of moving) {
26413
26535
  try {
26414
26536
  await this.codex.threadTurnsList({ threadId: runtime.session.id, limit: 1, itemsView: "notLoaded" });
26415
26537
  } catch (error40) {
26416
- throw RequestError.invalidRequest({ sessionId: runtime.session.id, details: errorMessage(error40) }, "Cannot reload this session's history for a provider change; close empty sessions and configure the provider before creating them, or retry after history is available");
26538
+ if (wholesale) {
26539
+ throw RequestError.invalidRequest({ sessionId: runtime.session.id, details: errorMessage(error40) }, "Cannot reload this session's history for a provider change; close empty sessions and configure the provider before creating them, or retry after history is available");
26540
+ }
26541
+ await this.withCodex(() => this.codex.threadInjectItems({ threadId: runtime.session.id, items: [providerChangeNote()] }));
26417
26542
  }
26418
26543
  }
26419
- for (const runtime of this.sessions.values()) {
26420
- const config2 = { ...runtime.config };
26421
- const providers = candidate.threadConfig()["model_providers"];
26422
- if (providers === undefined)
26423
- delete config2["model_providers"];
26424
- else
26425
- config2["model_providers"] = providers;
26426
- const nextCatalog = candidate.catalog(catalog);
26427
- const requestedModel = candidate.active?.model ?? (findModel(nextCatalog, runtime.session.model.model) ? runtime.session.model.model : null);
26544
+ const nextCatalog = candidate.catalog(catalog);
26545
+ for (const runtime of moving) {
26546
+ const onGateway = targets.get(runtime) === true;
26547
+ const config2 = candidate.rebind(runtime.config, this.providers, onGateway);
26548
+ const modelProvider = onGateway ? GATEWAY_MODEL_PROVIDER : nativeProvider;
26549
+ const keep = findModel(nextCatalog, runtime.session.model.model) && candidate.isGatewayModel(runtime.session.model.model) === onGateway;
26550
+ const requestedModel = candidate.mode === "route" ? candidate.active?.model ?? (keep ? runtime.session.model.model : null) : keep ? runtime.session.model.model : null;
26428
26551
  const selection = resolveModelSelection(nextCatalog, requestedModel, requestedModel === runtime.session.model.model ? runtime.session.model.effort : null);
26429
26552
  attempted.push(runtime);
26430
26553
  await this.codex.threadUnsubscribe({ threadId: runtime.session.id });
@@ -26436,15 +26559,21 @@ class CodexAgent {
26436
26559
  model: selection.model,
26437
26560
  excludeTurns: true
26438
26561
  }));
26439
- staged.set(runtime, { config: config2, catalog: nextCatalog, model: resolveModelSelection(nextCatalog, candidate.active?.model ?? thread.model, thread.reasoningEffort) });
26562
+ staged.set(runtime, { config: config2, catalog: nextCatalog, model: resolveModelSelection(nextCatalog, requestedModel ?? thread.model, thread.reasoningEffort) });
26440
26563
  }
26441
26564
  this.providers = candidate;
26442
- for (const [runtime, next] of staged) {
26443
- runtime.config = next.config;
26444
- runtime.modelProvider = modelProvider;
26445
- runtime.session.catalog = next.catalog;
26446
- runtime.session.model = next.model;
26447
- runtime.stale = false;
26565
+ for (const runtime of this.sessions.values()) {
26566
+ const next = staged.get(runtime);
26567
+ if (next) {
26568
+ runtime.config = next.config;
26569
+ runtime.modelProvider = targets.get(runtime) === true ? GATEWAY_MODEL_PROVIDER : nativeProvider;
26570
+ runtime.session.model = next.model;
26571
+ runtime.stale = false;
26572
+ }
26573
+ runtime.session.catalog = nextCatalog;
26574
+ runtime.session.gatewayGroup = candidate.gatewayGroup();
26575
+ if (!next)
26576
+ runtime.session.model = resolveModelSelection(nextCatalog, findModel(nextCatalog, runtime.session.model.model) ? runtime.session.model.model : null, runtime.session.model.effort);
26448
26577
  runtime.client.update({ sessionUpdate: "config_option_update", configOptions: sessionConfigOptions(runtime.session), _meta: { codex: { routing: { stale: false } } } }).catch((error40) => logger.error("Provider config notification failed", error40));
26449
26578
  }
26450
26579
  } catch (error40) {
@@ -26483,7 +26612,8 @@ class CodexAgent {
26483
26612
  }
26484
26613
  async resumeSession(params, signal) {
26485
26614
  const runtime = await this.openSession({ kind: "resume", request: params }, signal);
26486
- return { configOptions: sessionConfigOptions(runtime.session) };
26615
+ const configOptions = sessionConfigOptions(runtime.session);
26616
+ return runtime.readOnly ? { configOptions, _meta: { codex: { readOnly: true, reason: "active_writer" } } } : { configOptions };
26487
26617
  }
26488
26618
  async forkSession(params, signal) {
26489
26619
  const runtime = await this.openSession({ kind: "fork", request: params }, signal);
@@ -26519,7 +26649,7 @@ class CodexAgent {
26519
26649
  if (open2.kind !== "new")
26520
26650
  this.assertUnsubscribeSettled(open2.request.sessionId);
26521
26651
  const { request } = open2;
26522
- if (typeof request.cwd !== "string" || !path6.isAbsolute(request.cwd)) {
26652
+ if (typeof request.cwd !== "string" || !path7.isAbsolute(request.cwd)) {
26523
26653
  throw RequestError.invalidParams({ cwd: request.cwd }, "cwd must be an absolute path");
26524
26654
  }
26525
26655
  if (open2.kind === "resume" && open2.request.replayFrom != null && open2.request.replayFrom.type !== "start") {
@@ -26532,25 +26662,44 @@ class CodexAgent {
26532
26662
  this.assertUnsubscribeSettled(open2.request.sessionId);
26533
26663
  }
26534
26664
  const mcpServers = request.mcpServers ?? [];
26665
+ const requestedModel = requestedModelOf(request._meta);
26666
+ let onGateway = this.providers.routesByDefault();
26667
+ if (this.providers.active !== null && this.providers.mode === "catalog") {
26668
+ if (requestedModel !== null)
26669
+ onGateway = this.providers.isGatewayModel(requestedModel);
26670
+ else if (open2.kind === "resume") {
26671
+ const known = await this.withCodex(() => this.codex.threadRead({ threadId: open2.request.sessionId, includeTurns: false }));
26672
+ onGateway = known.thread.modelProvider === GATEWAY_MODEL_PROVIDER;
26673
+ }
26674
+ }
26535
26675
  let accountVersion = this.accountGeneration;
26536
26676
  const account = await this.withCodex(() => this.codex.accountRead({ refreshToken: false }));
26537
- if (this.providers.active === null && account.requiresOpenaiAuth && account.account === null) {
26677
+ if (!onGateway && account.requiresOpenaiAuth && account.account === null) {
26538
26678
  throw RequestError.authRequired(undefined, "Log in to Codex first (auth/login)");
26539
26679
  }
26540
26680
  const existingMcp = mcpServers.length > 0 ? await this.withCodex(() => this.configuredMcpServerNames(request.cwd)) : new Set;
26541
- const config2 = buildThreadConfig(this.providers.threadConfig(), request.cwd, additionalDirectories, mcpServers, existingMcp);
26681
+ const config2 = buildThreadConfig(this.providers.threadConfig(onGateway), request.cwd, additionalDirectories, mcpServers, existingMcp);
26542
26682
  const mcpStartupGeneration = this.codex.mcpStartupGeneration;
26543
- const modelProvider = await this.withCodex(() => this.resolveModelProvider());
26683
+ const modelProvider = onGateway ? GATEWAY_MODEL_PROVIDER : await this.withCodex(() => this.resolveModelProvider());
26684
+ const startModel = onGateway ? requestedModel ?? this.providers.active?.model ?? null : requestedModel;
26685
+ let readOnly = false;
26544
26686
  const { thread, skills } = await this.withSkillsContext(request.cwd, additionalDirectories, async (skills2) => ({ skills: skills2, thread: await this.withCodex(async () => {
26545
26687
  if (signal?.aborted)
26546
26688
  throw RequestError.requestCancelled(undefined, "Session opening was cancelled");
26547
26689
  switch (open2.kind) {
26548
26690
  case "new":
26549
- return await this.codex.threadStart({ config: config2, cwd: request.cwd, modelProvider });
26691
+ return await this.codex.threadStart({ config: config2, cwd: request.cwd, modelProvider, model: startModel });
26550
26692
  case "resume":
26551
- return await this.codex.threadResume({ threadId: open2.request.sessionId, config: config2, cwd: request.cwd, modelProvider, excludeTurns: true });
26693
+ try {
26694
+ return await this.codex.threadResume({ threadId: open2.request.sessionId, config: config2, cwd: request.cwd, modelProvider, model: startModel, excludeTurns: true });
26695
+ } catch (error40) {
26696
+ if (!errorMessage(error40).includes("already has an active writer"))
26697
+ throw error40;
26698
+ readOnly = true;
26699
+ return readOnlyThread((await this.codex.threadRead({ threadId: open2.request.sessionId, includeTurns: false })).thread, request.cwd);
26700
+ }
26552
26701
  case "fork":
26553
- return await this.codex.threadFork({ threadId: open2.request.sessionId, config: config2, cwd: request.cwd, modelProvider, excludeTurns: true });
26702
+ return await this.codex.threadFork({ threadId: open2.request.sessionId, config: config2, cwd: request.cwd, modelProvider, model: startModel, excludeTurns: true });
26554
26703
  }
26555
26704
  }) }));
26556
26705
  const sessionId = thread.thread.id;
@@ -26578,15 +26727,15 @@ class CodexAgent {
26578
26727
  accountVersion = this.accountGeneration;
26579
26728
  openedAccount = (await this.withCodex(() => this.codex.accountRead({ refreshToken: false }))).account;
26580
26729
  }
26581
- const gateway = this.providers.active;
26582
26730
  const catalog = this.providers.catalog(codexCatalog);
26583
- const model = resolveModelSelection(catalog, gateway?.model ?? thread.model, thread.reasoningEffort);
26731
+ const model = resolveModelSelection(catalog, startModel ?? thread.model, thread.reasoningEffort);
26584
26732
  const session = {
26585
26733
  id: sessionId,
26586
26734
  cwd: request.cwd,
26587
26735
  additionalDirectories,
26588
26736
  mcpServerNames: mcpServerNames(mcpServers).filter((name) => !existingMcp.has(name)),
26589
26737
  catalog,
26738
+ gatewayGroup: this.providers.gatewayGroup(),
26590
26739
  model,
26591
26740
  mode: initialAgentMode(this.env),
26592
26741
  collaborationMode: DEFAULT_COLLABORATION_MODE,
@@ -26599,7 +26748,7 @@ class CodexAgent {
26599
26748
  contextWindow: null,
26600
26749
  closed: false
26601
26750
  };
26602
- const runtime = this.installRuntime(session, capabilities, config2, thread.modelProvider);
26751
+ const runtime = this.installRuntime(session, capabilities, config2, onGateway ? GATEWAY_MODEL_PROVIDER : thread.modelProvider, readOnly);
26603
26752
  const cancelOpening = () => {
26604
26753
  runtime.session.closed = true;
26605
26754
  runtime.lifetime.abort();
@@ -26646,7 +26795,7 @@ class CodexAgent {
26646
26795
  stopObservingOpen();
26647
26796
  }
26648
26797
  }
26649
- installRuntime(session, capabilities, config2, modelProvider) {
26798
+ installRuntime(session, capabilities, config2, modelProvider, readOnly = false) {
26650
26799
  const client = new ClientSession(session.id, this.link, capabilities);
26651
26800
  const bridge = new EventBridge(client, session);
26652
26801
  const turnContext = new TurnContext(session.id);
@@ -26656,7 +26805,7 @@ class CodexAgent {
26656
26805
  };
26657
26806
  const approval = new CodexApprovalHandler(client, turnContext, signal);
26658
26807
  const elicitation = new CodexElicitationHandler(client, turnContext, signal);
26659
- const runtime = { config: config2, modelProvider, stale: false, session, client, bridge, turnContext, elicitation, lifetime: new AbortController, queue: Promise.resolve() };
26808
+ const runtime = { config: config2, modelProvider, stale: false, readOnly, session, client, bridge, turnContext, elicitation, lifetime: new AbortController, queue: Promise.resolve() };
26660
26809
  const drained = (operation) => async (params) => {
26661
26810
  await abortable(this.drain(runtime), this.codex.disconnectSignal);
26662
26811
  return await operation(params);
@@ -26923,7 +27072,7 @@ class CodexAgent {
26923
27072
  runtime.session.activeTurn = null;
26924
27073
  turn.resolveFinished();
26925
27074
  }
26926
- const unsubscribe = this.unsubscribeThread(sessionId);
27075
+ const unsubscribe = runtime.readOnly ? Promise.resolve() : this.unsubscribeThread(sessionId);
26927
27076
  let remoteUnsubscribe = "confirmed";
26928
27077
  try {
26929
27078
  if (!await within(unsubscribe, Math.max(0, deadline - performance.now()))) {
@@ -26952,11 +27101,46 @@ class CodexAgent {
26952
27101
  }
26953
27102
  async setSessionConfigOptionInternal(params) {
26954
27103
  const runtime = this.runtime(params.sessionId, "session/set_config_option");
27104
+ assertWritable(runtime);
27105
+ if (params.configId === MODEL_CONFIG_ID && this.providers.mode === "catalog" && typeof params.value === "string") {
27106
+ const toGateway = this.providers.isGatewayModel(params.value);
27107
+ if (toGateway !== (runtime.modelProvider === GATEWAY_MODEL_PROVIDER))
27108
+ await this.rerouteSession(runtime, toGateway, params.value);
27109
+ }
26955
27110
  await this.withCodex(() => applyConfigOption(runtime.session, this.codex, params));
26956
27111
  const configOptions = sessionConfigOptions(runtime.session);
26957
27112
  await runtime.client.update({ sessionUpdate: "config_option_update", configOptions });
26958
27113
  return { configOptions };
26959
27114
  }
27115
+ async rerouteSession(runtime, toGateway, modelId) {
27116
+ const { session } = runtime;
27117
+ this.assertRoutingAvailable();
27118
+ if (session.activeTurn !== null) {
27119
+ throw RequestError.invalidRequest({ sessionId: session.id }, "The model provider cannot change while a turn is running; cancel it or wait for idle");
27120
+ }
27121
+ try {
27122
+ await this.codex.threadTurnsList({ threadId: session.id, limit: 1, itemsView: "notLoaded" });
27123
+ } catch {
27124
+ await this.withCodex(() => this.codex.threadInjectItems({ threadId: session.id, items: [providerChangeNote()] }));
27125
+ }
27126
+ const config2 = this.providers.routeConfig(runtime.config, toGateway);
27127
+ const modelProvider = toGateway ? GATEWAY_MODEL_PROVIDER : await this.withCodex(() => this.resolveModelProvider());
27128
+ await this.codex.threadUnsubscribe({ threadId: session.id });
27129
+ try {
27130
+ await this.withCodex(() => this.codex.threadResume({ threadId: session.id, cwd: session.cwd, config: config2, modelProvider, model: modelId, excludeTurns: true }));
27131
+ } catch (error40) {
27132
+ try {
27133
+ await this.codex.threadResume({ threadId: session.id, cwd: session.cwd, config: runtime.config, modelProvider: runtime.modelProvider, model: session.model.model, excludeTurns: true });
27134
+ } catch (rollbackError) {
27135
+ runtime.stale = true;
27136
+ logger.error("Provider rollback failed", rollbackError, { sessionId: session.id });
27137
+ }
27138
+ throw RequestError.internalError({ sessionId: session.id, stale: runtime.stale }, `Could not move the session to ${toGateway ? "the gateway" : "Codex's provider"}: ${errorMessage(error40)}`);
27139
+ }
27140
+ runtime.config = config2;
27141
+ runtime.modelProvider = modelProvider;
27142
+ runtime.stale = false;
27143
+ }
26960
27144
  runtime(sessionId, method) {
26961
27145
  this.requireInitialized(method);
26962
27146
  const runtime = this.sessions.get(sessionId);
@@ -26972,6 +27156,7 @@ class CodexAgent {
26972
27156
  if (!allowReentry && this.sessionMutations.has(params.sessionId))
26973
27157
  throw RequestError.invalidRequest({ sessionId: params.sessionId }, "Session lifecycle or configuration work is in progress; retry when it finishes");
26974
27158
  const runtime = this.runtime(params.sessionId, "session/prompt");
27159
+ assertWritable(runtime);
26975
27160
  if (runtime.stale)
26976
27161
  throw RequestError.invalidRequest({ sessionId: params.sessionId, _meta: { codex: { routing: { stale: true } } } }, "Session routing is stale; resume the session or successfully change providers before prompting");
26977
27162
  if (!Array.isArray(params.prompt) || params.prompt.length === 0) {
@@ -27270,7 +27455,7 @@ ${error40.additionalDetails}` : error40.message;
27270
27455
  }
27271
27456
  withSkillsContext(cwd, additionalDirectories, operation) {
27272
27457
  const run = async () => {
27273
- const roots = additionalDirectories.map((root) => path6.join(root, ".agents", "skills"));
27458
+ const roots = additionalDirectories.map((root) => path7.join(root, ".agents", "skills"));
27274
27459
  if (roots.length !== this.skillRoots.length || roots.some((root, index) => root !== this.skillRoots[index])) {
27275
27460
  this.changingSkillRoots = true;
27276
27461
  try {
@@ -27304,7 +27489,7 @@ ${error40.additionalDetails}` : error40.message;
27304
27489
  }
27305
27490
  async refreshAvailableCommands(runtime) {
27306
27491
  const run = async () => {
27307
- const roots = runtime.session.additionalDirectories.map((root) => path6.join(root, ".agents", "skills"));
27492
+ const roots = runtime.session.additionalDirectories.map((root) => path7.join(root, ".agents", "skills"));
27308
27493
  if (runtime.session.closed || roots.length !== this.skillRoots.length || roots.some((root, i) => root !== this.skillRoots[i]))
27309
27494
  return;
27310
27495
  return await this.loadSkills(runtime.session.cwd, runtime.session.additionalDirectories);
@@ -27428,6 +27613,34 @@ function archivedFilter(meta) {
27428
27613
  const codex = meta?.codex;
27429
27614
  return codex?.archived === true;
27430
27615
  }
27616
+ function providerChangeNote() {
27617
+ return { type: "message", role: "developer", content: [{ type: "input_text", text: "The user changed this conversation's model before sending a message." }] };
27618
+ }
27619
+ function assertWritable(runtime) {
27620
+ if (!runtime.readOnly)
27621
+ return;
27622
+ throw RequestError.invalidRequest({ codex: { readOnly: true } }, "Thread is open in another Codex client");
27623
+ }
27624
+ function readOnlyThread(thread, cwd) {
27625
+ return {
27626
+ thread,
27627
+ model: thread.model ?? "",
27628
+ modelProvider: thread.modelProvider,
27629
+ serviceTier: null,
27630
+ cwd,
27631
+ instructionSources: [],
27632
+ approvalPolicy: "on-request",
27633
+ approvalsReviewer: "user",
27634
+ sandbox: { type: "readOnly", networkAccess: false },
27635
+ reasoningEffort: thread.reasoningEffort,
27636
+ turnsBackwardsCursor: null,
27637
+ itemsBackwardsCursor: null
27638
+ };
27639
+ }
27640
+ function requestedModelOf(meta) {
27641
+ const raw = meta?.alwith?.model;
27642
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
27643
+ }
27431
27644
  function seedHistoryOf(meta) {
27432
27645
  const raw = meta?.codex?.seedHistory;
27433
27646
  if (raw === undefined || raw === null)
@@ -27922,7 +28135,7 @@ function threadIdOf(notification) {
27922
28135
 
27923
28136
  // src/codex/process.ts
27924
28137
  import { existsSync } from "fs";
27925
- import path7 from "path";
28138
+ import path8 from "path";
27926
28139
  import { spawn, spawnSync } from "child_process";
27927
28140
  import { createRequire } from "module";
27928
28141
 
@@ -28215,11 +28428,11 @@ function resolveCodexLauncher(codexPath) {
28215
28428
  const target = targets[platform2];
28216
28429
  if (!target)
28217
28430
  throw new Error(`Unsupported Codex platform: ${platform2}`);
28218
- let vendor = path7.resolve(path7.dirname(launcher), "..", "vendor");
28431
+ let vendor = path8.resolve(path8.dirname(launcher), "..", "vendor");
28219
28432
  try {
28220
- vendor = path7.join(path7.dirname(platformRequire.resolve(`@openai/codex-${platform2}/package.json`)), "vendor");
28433
+ vendor = path8.join(path8.dirname(platformRequire.resolve(`@openai/codex-${platform2}/package.json`)), "vendor");
28221
28434
  } catch {}
28222
- const command = path7.join(vendor, target, "bin", process.platform === "win32" ? "codex.exe" : "codex");
28435
+ const command = path8.join(vendor, target, "bin", process.platform === "win32" ? "codex.exe" : "codex");
28223
28436
  if (!existsSync(command))
28224
28437
  throw new Error(`Missing Codex platform binary: ${command}`);
28225
28438
  return { command, prefixArgs: [], shell: false };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.0",
6
+ "version": "0.5.0",
7
7
  "description": "Native ACP v2 agent for the OpenAI Codex app-server",
8
8
  "packageManager": "bun@1.4.0",
9
9
  "engines": {