@nyssance/codex-acp-v2 0.7.11 → 0.7.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.12 — 2026-10-13
4
+
5
+ ### Added
6
+
7
+ - `_codex/plugin_set_enabled` enables or disables local and remote plugins,
8
+ advertised through `capabilities._meta.codex.pluginEnable`. Successful changes
9
+ emit `_codex/skills_changed` so clients can refresh the effective catalog.
10
+ - Remote plugin changes use ChatGPT plugin-service, reconcile installed plugins,
11
+ and verify the resulting state. Expired credentials are refreshed once without
12
+ exposing credentials over ACP; local changes report configuration overrides.
13
+
3
14
  ## 0.7.11 — 2026-10-12
4
15
 
5
16
  ### Added
package/dist/index.js CHANGED
@@ -22168,7 +22168,7 @@ var package_default = {
22168
22168
  publishConfig: {
22169
22169
  access: "public"
22170
22170
  },
22171
- version: "0.7.11",
22171
+ version: "0.7.12",
22172
22172
  description: "Native ACP v2 agent for the OpenAI Codex app-server",
22173
22173
  packageManager: "bun@1.4.0",
22174
22174
  engines: {
@@ -22182,7 +22182,8 @@ var package_default = {
22182
22182
  "README.md",
22183
22183
  "CHANGELOG.md",
22184
22184
  "LICENSE",
22185
- "package.json"
22185
+ "package.json",
22186
+ "docs/protocol.md"
22186
22187
  ],
22187
22188
  scripts: {
22188
22189
  prepublishOnly: "bun run build",
@@ -25409,6 +25410,15 @@ class AppServerClient {
25409
25410
  skillsConfigWrite(params) {
25410
25411
  return this.send({ method: "skills/config/write", params });
25411
25412
  }
25413
+ configValueWrite(params) {
25414
+ return this.send({ method: "config/value/write", params });
25415
+ }
25416
+ getAuthStatus(params) {
25417
+ return this.send({ method: "getAuthStatus", params });
25418
+ }
25419
+ pluginReconcile(params) {
25420
+ return this.send({ method: "plugin/reconcile", params });
25421
+ }
25412
25422
  pluginList(params) {
25413
25423
  return this.send({ method: "plugin/list", params });
25414
25424
  }
@@ -25478,6 +25488,44 @@ function threadIdOf(notification) {
25478
25488
  return params && typeof params.threadId === "string" ? params.threadId : null;
25479
25489
  }
25480
25490
 
25491
+ // src/codex/remotePluginEnablement.ts
25492
+ async function setRemotePluginEnabled(codex, remotePluginId, enabled) {
25493
+ const account = await codex.accountRead({ refreshToken: false });
25494
+ const routing = account.workspaceRouting;
25495
+ if (account.account?.type !== "chatgpt" || !routing)
25496
+ throw new Error("Sign in to ChatGPT to change remote plugins");
25497
+ const origin = new URL(routing.backendOrigin);
25498
+ if (origin.protocol !== "https:" || origin.hostname !== "chatgpt.com" && !origin.hostname.endsWith(".chatgpt.com")) {
25499
+ throw new Error("Unsupported ChatGPT backend for remote plugins");
25500
+ }
25501
+ const url2 = new URL(`/backend-api/ps/plugins/${encodeURIComponent(remotePluginId)}/${enabled ? "enable" : "disable"}`, origin);
25502
+ for (const refreshToken of [false, true]) {
25503
+ const auth = await codex.getAuthStatus({ includeToken: true, refreshToken });
25504
+ if (auth.authMethod !== "chatgpt" && auth.authMethod !== "chatgptAuthTokens" || !auth.authToken) {
25505
+ throw new Error("ChatGPT credentials are unavailable; sign in again to change remote plugins");
25506
+ }
25507
+ const response = await fetch(url2, {
25508
+ method: "POST",
25509
+ headers: { Authorization: `Bearer ${auth.authToken}`, "ChatGPT-Account-Id": routing.chatgptAccountId, "OpenAI-Product-SKU": "codex" },
25510
+ redirect: "error",
25511
+ signal: AbortSignal.any([codex.disconnectSignal, AbortSignal.timeout(30000)])
25512
+ });
25513
+ if (response.status === 401 && !refreshToken) {
25514
+ await response.body?.cancel();
25515
+ continue;
25516
+ }
25517
+ if (!response.ok) {
25518
+ await response.body?.cancel();
25519
+ throw new Error(`Remote plugin ${enabled ? "enable" : "disable"} failed (HTTP ${response.status})`);
25520
+ }
25521
+ const result = await response.json();
25522
+ if (typeof result !== "object" || result === null || !("id" in result) || result.id !== remotePluginId || !("enabled" in result) || result.enabled !== enabled) {
25523
+ throw new Error("Remote plugin service did not confirm the requested enabled state");
25524
+ }
25525
+ return;
25526
+ }
25527
+ }
25528
+
25481
25529
  // src/codex/modes.ts
25482
25530
  var MODE_CONFIG_ID = "mode";
25483
25531
  var workspaceWrite = {
@@ -28533,7 +28581,7 @@ class CodexAgent {
28533
28581
  additionalDirectories: {}
28534
28582
  },
28535
28583
  providers: {},
28536
- _meta: { alwith: { tools: { version: 1 }, turns: { version: 2 } }, codex: { forkAtTurn: true, sessionLineage: true, archive: true, sessionList: { version: 1 }, sessionHistory: { version: 2, items: true, modes: ["browse", "export"] }, seedHistory: true, skills: true, plugins: true, providerCatalog: true, rename: true, account: true, fuzzyFileSearch: true } }
28584
+ _meta: { alwith: { tools: { version: 1 }, turns: { version: 2 } }, codex: { forkAtTurn: true, sessionLineage: true, archive: true, sessionList: { version: 1 }, sessionHistory: { version: 2, items: true, modes: ["browse", "export"] }, seedHistory: true, skills: true, plugins: true, pluginEnable: true, providerCatalog: true, rename: true, account: true, fuzzyFileSearch: true } }
28537
28585
  },
28538
28586
  authMethods: authMethods(this.capabilities, this.env)
28539
28587
  };
@@ -29232,6 +29280,31 @@ class CodexAgent {
29232
29280
  this.notifySkillsChanged();
29233
29281
  return response;
29234
29282
  }
29283
+ async pluginSetEnabled(params) {
29284
+ this.requireInitialized("_codex/plugin_set_enabled");
29285
+ if (params.remotePluginId !== undefined) {
29286
+ await this.withCodex(async () => {
29287
+ await setRemotePluginEnabled(this.codex, params.remotePluginId, params.enabled);
29288
+ await this.codex.pluginReconcile({ reason: "plugin-enabled-changed" });
29289
+ const installed = await this.codex.pluginInstalled({});
29290
+ const plugin = installed.marketplaces.flatMap((market) => market.plugins).find((plugin2) => plugin2.remotePluginId === params.remotePluginId);
29291
+ if (!plugin || plugin.enabled !== params.enabled)
29292
+ throw new Error("Remote plugin state could not be synchronized; refresh the plugin catalog");
29293
+ });
29294
+ this.notifySkillsChanged();
29295
+ return { enabled: params.enabled };
29296
+ }
29297
+ const response = await this.withCodex(() => this.codex.configValueWrite({
29298
+ keyPath: `plugins.${JSON.stringify(params.pluginId)}.enabled`,
29299
+ value: params.enabled,
29300
+ mergeStrategy: "upsert"
29301
+ }));
29302
+ this.notifySkillsChanged();
29303
+ if (response.status === "okOverridden") {
29304
+ throw RequestError.invalidRequest(undefined, "Plugin enablement is overridden by a higher-priority configuration");
29305
+ }
29306
+ return { enabled: params.enabled };
29307
+ }
29235
29308
  async pluginList(params) {
29236
29309
  this.requireInitialized("_codex/plugin_list");
29237
29310
  return await this.withCodex(() => this.codex.pluginList(params));
@@ -30168,6 +30241,19 @@ function forkTurnId(meta) {
30168
30241
  }
30169
30242
  return value;
30170
30243
  }
30244
+ function parsePluginSetEnabledParams(value) {
30245
+ if (typeof value !== "object" || value === null)
30246
+ throw RequestError.invalidParams(undefined, "Expected pluginId and enabled");
30247
+ const params = value;
30248
+ if (typeof params["pluginId"] !== "string" || !params["pluginId"].trim() || typeof params["enabled"] !== "boolean") {
30249
+ throw RequestError.invalidParams(undefined, "Expected a non-empty pluginId and boolean enabled");
30250
+ }
30251
+ const remotePluginId = params["remotePluginId"];
30252
+ if (remotePluginId !== undefined && (typeof remotePluginId !== "string" || !remotePluginId.trim())) {
30253
+ throw RequestError.invalidParams(undefined, "Expected a non-empty remotePluginId");
30254
+ }
30255
+ return { pluginId: params["pluginId"], enabled: params["enabled"], ...typeof remotePluginId === "string" ? { remotePluginId } : {} };
30256
+ }
30171
30257
 
30172
30258
  // src/agent/createAgent.ts
30173
30259
  function createAgentApp(options) {
@@ -30179,7 +30265,7 @@ function createAgentApp(options) {
30179
30265
  };
30180
30266
  return agent({ name: options.info.name }).onConnect((connection) => {
30181
30267
  agent2 = new CodexAgent(connection.client, options);
30182
- }).onRequest(methods.agent.initialize, (ctx) => current().initialize(ctx.params)).onRequest(methods.agent.auth.login, (ctx) => current().login(ctx.params, ctx.requestId)).onRequest(methods.agent.auth.logout, (ctx) => current().logout(ctx.params)).onRequest(methods.agent.providers.list, (ctx) => current().listProviders(ctx.params)).onRequest(methods.agent.providers.set, (ctx) => current().setProvider(ctx.params)).onRequest(methods.agent.providers.disable, (ctx) => current().disableProvider(ctx.params)).onRequest(methods.agent.session.new, (ctx) => current().newSession(ctx.params, ctx.signal)).onRequest(methods.agent.session.resume, (ctx) => current().resumeSession(ctx.params, ctx.signal)).onRequest(methods.agent.session.fork, (ctx) => current().forkSession(ctx.params, ctx.signal)).onRequest(methods.agent.session.list, (ctx) => current().listSessions(ctx.params)).onRequest("_codex/session_list", { parse: parseSessionListParams }, (ctx) => current().sessionList(ctx.params)).onRequest(methods.agent.session.close, (ctx) => current().closeSession(ctx.params)).onRequest(methods.agent.session.delete, (ctx) => current().deleteSession(ctx.params)).onRequest("_codex/session_history", { parse: parseSessionHistoryParams }, (ctx) => current().sessionHistory(ctx.params, ctx.signal)).onRequest("_codex/session_history_items", { parse: parseSessionHistoryItemsParams }, (ctx) => current().sessionHistoryItems(ctx.params, ctx.signal)).onRequest("_codex/session_archive", { parse: parseSessionIdParams }, (ctx) => current().archiveSession(ctx.params)).onRequest("_codex/session_unarchive", { parse: parseSessionIdParams }, (ctx) => current().unarchiveSession(ctx.params)).onRequest("_codex/skills_list", { parse: objectParams() }, (ctx) => current().skillsList(ctx.params)).onRequest("_codex/skills_config_write", { parse: objectParams() }, (ctx) => current().skillsConfigWrite(ctx.params)).onRequest("_codex/plugin_list", { parse: objectParams() }, (ctx) => current().pluginList(ctx.params)).onRequest("_codex/plugin_installed", { parse: objectParams() }, (ctx) => current().pluginInstalled(ctx.params)).onRequest("_codex/plugin_install", { parse: objectParams() }, (ctx) => current().pluginInstall(ctx.params)).onRequest("_codex/plugin_uninstall", { parse: objectParams() }, (ctx) => current().pluginUninstall(ctx.params)).onRequest("_codex/plugin_read", { parse: objectParams() }, (ctx) => current().pluginRead(ctx.params)).onRequest("_codex/marketplace_add", { parse: objectParams() }, (ctx) => current().marketplaceAdd(ctx.params)).onRequest("_codex/marketplace_remove", { parse: objectParams() }, (ctx) => current().marketplaceRemove(ctx.params)).onRequest("_codex/marketplace_upgrade", { parse: objectParams() }, (ctx) => current().marketplaceUpgrade(ctx.params)).onRequest("_codex/session_rename", { parse: parseSessionRenameParams }, (ctx) => current().sessionRename(ctx.params)).onRequest("_codex/account_read", { parse: objectParams() }, () => current().accountRead()).onRequest("_codex/rate_limits", { parse: objectParams() }, () => current().rateLimits()).onRequest("_codex/fuzzy_file_search", { parse: parseFuzzyFileSearchParams }, (ctx) => current().fuzzyFileSearch(ctx.params)).onRequest(methods.agent.session.setConfigOption, (ctx) => current().setSessionConfigOption(ctx.params)).onRequest(methods.agent.session.prompt, (ctx) => current().prompt(ctx.params)).onNotification(methods.agent.session.cancel, (ctx) => current().cancel(ctx.params));
30268
+ }).onRequest(methods.agent.initialize, (ctx) => current().initialize(ctx.params)).onRequest(methods.agent.auth.login, (ctx) => current().login(ctx.params, ctx.requestId)).onRequest(methods.agent.auth.logout, (ctx) => current().logout(ctx.params)).onRequest(methods.agent.providers.list, (ctx) => current().listProviders(ctx.params)).onRequest(methods.agent.providers.set, (ctx) => current().setProvider(ctx.params)).onRequest(methods.agent.providers.disable, (ctx) => current().disableProvider(ctx.params)).onRequest(methods.agent.session.new, (ctx) => current().newSession(ctx.params, ctx.signal)).onRequest(methods.agent.session.resume, (ctx) => current().resumeSession(ctx.params, ctx.signal)).onRequest(methods.agent.session.fork, (ctx) => current().forkSession(ctx.params, ctx.signal)).onRequest(methods.agent.session.list, (ctx) => current().listSessions(ctx.params)).onRequest("_codex/session_list", { parse: parseSessionListParams }, (ctx) => current().sessionList(ctx.params)).onRequest(methods.agent.session.close, (ctx) => current().closeSession(ctx.params)).onRequest(methods.agent.session.delete, (ctx) => current().deleteSession(ctx.params)).onRequest("_codex/session_history", { parse: parseSessionHistoryParams }, (ctx) => current().sessionHistory(ctx.params, ctx.signal)).onRequest("_codex/session_history_items", { parse: parseSessionHistoryItemsParams }, (ctx) => current().sessionHistoryItems(ctx.params, ctx.signal)).onRequest("_codex/session_archive", { parse: parseSessionIdParams }, (ctx) => current().archiveSession(ctx.params)).onRequest("_codex/session_unarchive", { parse: parseSessionIdParams }, (ctx) => current().unarchiveSession(ctx.params)).onRequest("_codex/skills_list", { parse: objectParams() }, (ctx) => current().skillsList(ctx.params)).onRequest("_codex/skills_config_write", { parse: objectParams() }, (ctx) => current().skillsConfigWrite(ctx.params)).onRequest("_codex/plugin_list", { parse: objectParams() }, (ctx) => current().pluginList(ctx.params)).onRequest("_codex/plugin_installed", { parse: objectParams() }, (ctx) => current().pluginInstalled(ctx.params)).onRequest("_codex/plugin_install", { parse: objectParams() }, (ctx) => current().pluginInstall(ctx.params)).onRequest("_codex/plugin_uninstall", { parse: objectParams() }, (ctx) => current().pluginUninstall(ctx.params)).onRequest("_codex/plugin_set_enabled", { parse: parsePluginSetEnabledParams }, (ctx) => current().pluginSetEnabled(ctx.params)).onRequest("_codex/plugin_read", { parse: objectParams() }, (ctx) => current().pluginRead(ctx.params)).onRequest("_codex/marketplace_add", { parse: objectParams() }, (ctx) => current().marketplaceAdd(ctx.params)).onRequest("_codex/marketplace_remove", { parse: objectParams() }, (ctx) => current().marketplaceRemove(ctx.params)).onRequest("_codex/marketplace_upgrade", { parse: objectParams() }, (ctx) => current().marketplaceUpgrade(ctx.params)).onRequest("_codex/session_rename", { parse: parseSessionRenameParams }, (ctx) => current().sessionRename(ctx.params)).onRequest("_codex/account_read", { parse: objectParams() }, () => current().accountRead()).onRequest("_codex/rate_limits", { parse: objectParams() }, () => current().rateLimits()).onRequest("_codex/fuzzy_file_search", { parse: parseFuzzyFileSearchParams }, (ctx) => current().fuzzyFileSearch(ctx.params)).onRequest(methods.agent.session.setConfigOption, (ctx) => current().setSessionConfigOption(ctx.params)).onRequest(methods.agent.session.prompt, (ctx) => current().prompt(ctx.params)).onNotification(methods.agent.session.cancel, (ctx) => current().cancel(ctx.params));
30183
30269
  }
30184
30270
 
30185
30271
  // src/codex/process.ts
@@ -0,0 +1,798 @@
1
+ # Wire contract
2
+
3
+ `codex-acp-v2` implements the ACP v2 draft as shipped in `@agentclientprotocol/sdk`
4
+ `experimental/v2`. This page lists what the agent accepts, what it emits, and the
5
+ `_meta.codex` keys it adds. Anything not listed is standard ACP behaviour.
6
+
7
+ ## Handshake
8
+
9
+ `initialize` requires `protocolVersion: 2` and `info`. The response advertises:
10
+
11
+ ```json
12
+ {
13
+ "capabilities": {
14
+ "session": {
15
+ "prompt": {"image": {}, "embeddedContext": {}},
16
+ "mcp": {"stdio": {}, "http": {}},
17
+ "fork": {}, "delete": {}, "additionalDirectories": {}
18
+ }
19
+ },
20
+ "authMethods": [
21
+ {"type": "agent", "methodId": "api-key"},
22
+ {"type": "agent", "methodId": "chat-gpt"},
23
+ {"type": "agent", "methodId": "chat-gpt-device-code"}
24
+ ]
25
+ }
26
+ ```
27
+
28
+ `chat-gpt` is omitted when `NO_BROWSER` is set; `chat-gpt-device-code` is offered only
29
+ to clients that declare `capabilities.elicitation.url`. Every other method returns
30
+ `-32600` until `initialize` has succeeded.
31
+ Concurrent initialization requests share one Codex handshake. A failed handshake
32
+ does not unlock session methods and can be retried.
33
+
34
+ `auth/login` with `api-key` reads `_meta["api-key"].apiKey`, then `CODEX_API_KEY`,
35
+ then `OPENAI_API_KEY`. `session/new` returns `-32000` (auth required) while Codex has
36
+ no account.
37
+
38
+ ## Providers
39
+
40
+ The agent advertises `capabilities.providers` and exposes one slot, `providerId: "openai"`,
41
+ which is where Codex's OpenAI-protocol traffic goes.
42
+
43
+ | Method | Behaviour |
44
+ | --- | --- |
45
+ | `providers/list` | Reports the committed routing for new sessions with `supported: ["openai"]` and its `baseUrl`. |
46
+ | `providers/set` | `{providerId: "openai", apiType: "openai", baseUrl, headers?}` routes Codex through that gateway: new and open sessions get a `model_providers.<id>` config entry and `modelProvider: "<id>"`, where `<id>` is `_meta.codex.id` (default `custom-gateway`). Open sessions with persisted history are unsubscribed and resumed with the new routing; a running turn makes the request fail with `-32600`. |
47
+ | `providers/disable` | `{providerId: "openai"}` restores native routing for every gateway; with `_meta.codex.id` only that gateway is removed (catalog mode). Other provider ids are a no-op. |
48
+
49
+ Accepted hints on `providers/set._meta`:
50
+
51
+ | Hint | Meaning |
52
+ | --- | --- |
53
+ | `codex.id` | Gateway id, `[a-z0-9-]+`, default `custom-gateway`: the `model_providers` entry, the `modelProvider` value and (catalog mode) the select group. Codex's own provider ids (`openai`, the configured `model_provider`) and the native group id `chatgpt` are refused. |
54
+ | `alwith.models` | `[{id, label?, description?}]`: the models the gateway serves; Codex cannot list them. Model ids must be unique across gateways; a clash is refused with `-32602` naming the other gateway. |
55
+ | `alwith.model` | The model to select on the gateway. |
56
+ | `codex.name` | Label of the `model_providers` entry (and of the gateway's select group in catalog mode). |
57
+ | `codex.bearerToken` | Written as `experimental_bearer_token` on the entry, the key DeepSeek's official Codex integration uses; Codex adds the `Authorization` header itself. |
58
+ | `codex.config` | Extra top-level Codex thread-config keys that apply only to threads on the gateway (`model_catalog_json`, `web_search`, `model_reasoning_effort`, …). Must be an object; `model_providers` is refused. When it names a `model_catalog_json` file, the adapter reads it so the gateway's models carry their real reasoning levels, modalities and display names (`model/list` never reflects a per-thread catalog). |
59
+ | `codex.mode` | `"route"` (default): the gateway takes over every session. `"catalog"`: see below. |
60
+
61
+ The gateway must implement the OpenAI Responses API; Codex 0.153 no longer speaks
62
+ chat completions. A session on the gateway does not require an OpenAI login.
63
+
64
+ ### Catalog mode
65
+
66
+ `providers/set` with `_meta.codex.mode: "catalog"` registers a gateway without re-routing
67
+ anything. Any number of gateways can be registered this way, each under its own
68
+ `codex.id`; a route-mode `providers/set` replaces them all. `providers/list` keeps
69
+ reporting native routing, with every gateway under `providers[]._meta.codex.gateways`
70
+ (`[{id, name, baseUrl, models}]`; `_meta.codex.gateway` is the first one, kept for older
71
+ clients), and every session's `model` option becomes one group per source: `chatgpt`
72
+ (named ChatGPT: the models Codex serves through the ChatGPT sign-in) followed by one group per gateway (groupId = its id, named after
73
+ `codex.name`, listing its `alwith.models`). Advertised as
74
+ `capabilities._meta.codex.providerCatalog: true`.
75
+
76
+ - `session/set_config_option {configId: "model"}` with a gateway model moves **only that
77
+ session** to the gateway that serves the model (a thread's config carries only that
78
+ gateway's `model_providers` entry); a native model moves it back. Codex has no per-turn provider
79
+ (`turn/start` overrides the model only), so the move is the same unsubscribe + cold
80
+ `thread/resume` an agent-wide change uses, with the gateway's `model_providers` entry and
81
+ `codex.config` overrides in that thread's config. A thread Codex has not materialized yet
82
+ (no user message) is materialized first by injecting a one-line developer note, because
83
+ Codex refuses to cold-resume it otherwise. `effort` then lists the gateway model's levels.
84
+ A running turn on that session → `-32600`.
85
+ - `session/new` and `session/fork` stay native unless `_meta.alwith.model` names a gateway
86
+ model. `session/resume` opens the thread on what `_meta.alwith.model` asks for, else on
87
+ the provider Codex recorded for it (`thread/read`). Codex records the provider a thread
88
+ was **created** with, not one it was moved to later, so a client that moved a thread
89
+ should pass `_meta.alwith.model` when it resumes it.
90
+ - `providers/disable` returns gateway sessions to native routing and drops the gateway
91
+ groups (an empty gateway session is materialized the same way first); with
92
+ `_meta.codex.id` only that gateway's sessions move and only its group goes.
93
+ Re-registering a gateway under the same id (a rotated key) re-resumes the sessions on it
94
+ with the new entry and leaves sessions on other gateways alone.
95
+ - Catalog mode does not affect ALwith Desktop's agent-wide `providers/set` (no `mode` hint).
96
+
97
+
98
+
99
+ ## Sessions
100
+
101
+ | Method | Notes |
102
+ | --- | --- |
103
+ | `session/new` | `cwd` must be absolute. `additionalDirectories` become trusted projects and sandbox write roots. `mcpServers` (stdio, http) are added to the thread config; names that collide with the user's Codex config are skipped. |
104
+ | `session/resume` | `replayFrom: {type: "start"}` replays the transcript as `session/update` frames before the response, paged through Codex `thread/turns/list` in pages of 50 turns; `null` or omitted restores context only. Other cursors are rejected. When Codex answers "already has an active writer" (its writer lock is a file lock across the Codex home: the thread is open in another Codex client — ChatGPT app, CLI, another app-server — and nothing here can release it) the session opens for viewing instead, the way Codex's TUI does: `thread/read` in place of `thread/resume`, history through the same `thread/turns/list` path, the response carries `_meta: {codex: {readOnly: true, reason: "active_writer"}}`. On such a session `session/prompt` and `session/set_config_option` fail with `-32600` "Thread is open in another Codex client" and `data: {codex: {readOnly: true}}`; `session/cancel` is a notification and is ignored. Resuming the same id again retries the real resume and, when the other client has let go, replaces the viewing session. |
105
+ | `session/fork` | Forks the Codex thread and replays the copied transcript under the new session id. The source session remains open, including any running turn. |
106
+ | `session/list` | `cwd` filters by exact Codex thread cwd; `cursor` pages. |
107
+ | `_codex/session_list` | Without params, returns all non-archived sessions across projects in one response. With params, delegates to `session/list`. Advertised as `capabilities._meta.codex.sessionList: {version: 1}`. |
108
+ | `session/close` | Detaches locally within a single 5-second budget, subject to event-loop scheduling. At most half is spent waiting for an interrupted turn; the remainder is reserved for unsubscribe. Stalled client writes do not prevent cleanup. A late start is interrupted when its id becomes known. |
109
+ | `session/delete` | Close plus `thread/delete` (permanent deletion). |
110
+ | `_codex/session_history` | Read-only turn metadata or full ACP history without opening or subscribing to the session. See the contract below. Advertised as `capabilities._meta.codex.sessionHistory: {version: 2, items: true, modes: ["browse", "export"]}`. |
111
+ | `_codex/session_history_items` | Independent item pagination across a thread or within one turn; supports item anchors and optional native data. Part of the version 2 history capability. |
112
+ | `_codex/session_archive` | `{sessionId}` closes and archives the thread (reversible hiding). Advertised as `capabilities._meta.codex.archive: true`. |
113
+ | `_codex/session_unarchive` | `{sessionId}` restores an archived thread's visibility. |
114
+ | `_codex/session_rename` | `{sessionId, name}` → `thread/name/set`. A loaded thread then reports `thread/name/updated`, delivered as `session_info_update` with the new `title`; a thread that is not loaded gets no notification. Advertised as `capabilities._meta.codex.rename: true`. |
115
+ | `session/set_config_option` | Returns and broadcasts the full option list. |
116
+
117
+ `session/list` with `_meta: {codex: {archived: true}}` lists archived threads.
118
+ Both lists include all model providers, including custom gateways. `cwd` must match
119
+ the stored canonical path exactly (use the path returned by Codex, especially on
120
+ platforms where temporary directories have symlink aliases).
121
+ The default is the non-archived list; each returned session includes
122
+ `_meta.codex.archived`.
123
+
124
+ `_codex/session_list` accepts the same request fields and returns the same session
125
+ entries as `session/list`. Omitted params, `null`, `{}`, or an object containing
126
+ only `undefined` values select the full-list mode. Any defined property (including
127
+ `cwd`, `cursor`, or `_meta`, even when null) selects normal single-page behavior.
128
+ For example, `{_meta: {codex: {archived: true}}}` returns one archive page;
129
+ `{cwd: "/project"}` returns one project page. Standard `session/list` is unchanged.
130
+
131
+ Full-list mode walks native pages of 100 sessions, deduplicates by session ID,
132
+ then supplements native forks once. Title repair reads are limited to 8 at a time.
133
+ It returns `{sessions}` without `nextCursor`, only after every page succeeds.
134
+ A failed page or repeated cursor rejects the request instead of returning a partial
135
+ list. It uses the same source-kind and model-provider filters as `session/list`;
136
+ "all" does not include archived threads or excluded internal subagent sources.
137
+ There is no persistent cache or atomic snapshot guarantee across native pages.
138
+
139
+ `session/new` accepts `_meta.codex.seedHistory: [{role, text}]`, with `role` equal
140
+ to `"user"` or `"assistant"`. These entries are injected into the new thread as
141
+ model-visible history before the first prompt. This extension is advertised as
142
+ `capabilities._meta.codex.seedHistory: true`.
143
+
144
+ Invalid replay cursors, additional directories, and seed history are rejected
145
+ before an existing session is closed or a new thread is created.
146
+
147
+ ### Read-only history (version 2)
148
+
149
+ `capabilities._meta.codex.sessionHistory` advertises
150
+ `{version: 2, items: true, modes: ["browse", "export"]}`. Both history methods require
151
+ `initialize`, but not `session/resume`. They do not acquire a writer, create a runtime,
152
+ change subscriptions, interrupt a turn, unarchive a thread, or publish `session/update`
153
+ notifications. They read Codex's persisted history through its app-server, including
154
+ closed, archived, and other clients' threads. No second transcript store is created.
155
+
156
+ #### Turn pages and item pages
157
+
158
+ Get the latest turn summaries for a history browser:
159
+
160
+ ```json
161
+ {"sessionId": "thread-id", "sortDirection": "desc", "limit": 20}
162
+ ```
163
+
164
+ Send that request to `_codex/session_history`. `itemsView` defaults to `"summary"`:
165
+ only turn metadata is returned, with empty `updates` and `omissions`. Choose `"full"`
166
+ to project whole turns, or use `_codex/session_history_items` for bounded item pages:
167
+
168
+ ```json
169
+ {"sessionId": "thread-id", "turnId": "turn-id", "limit": 20, "includeNative": true}
170
+ ```
171
+
172
+ The item method accepts `turnId` to restrict the result to one turn. Omit it to page
173
+ items across the thread. `anchorItemId` requires `turnId` and starts exclusively after
174
+ that item in ascending order, or before it in descending order; it cannot be combined
175
+ with `cursor`. Continue from the returned cursor without repeating the anchor.
176
+ Turn IDs are obtained from the turn method; no full-turn load is required to locate
177
+ and read a turn's items.
178
+
179
+ Common parameters:
180
+
181
+ | Parameter | Contract |
182
+ | --- | --- |
183
+ | `sessionId` | Required non-empty Codex thread ID. |
184
+ | `cursor` | Opaque next cursor, null/omitted for the first page. Bound to the session, endpoint, direction, mode, view, native-data choice and (for items) turn filter. Preserve those options when continuing. |
185
+ | `limit` | Integer 1–100, default 50. Counts turns or items according to the endpoint. Can change between pages. |
186
+ | `sortDirection` | `"asc"` (default) or `"desc"`. Order within a whole turn remains chronological; the item endpoint orders individual items in the chosen direction. Start a new traversal to change direction. |
187
+ | `mode` | `"browse"` (default) or `"export"`, described below. |
188
+ | `includeNative` | Default false. Retain exact native item records under `_meta.codex`. On the turn method this requires `itemsView: "full"`. |
189
+ | `maxBytes` | Maximum serialized **result payload** size in bytes, excluding the JSON-RPC envelope. Default 1 MiB, allowed range 1 KiB–16 MiB. Can change between pages. |
190
+
191
+ Unknown parameters, invalid types, incompatible options and version 1 cursors fail
192
+ explicitly. Version 2 changes the unreleased version 1 default from full export to
193
+ summary browsing and replaces `omittedItems` with `omissions`.
194
+
195
+ Both responses contain `sessionId`, `cwd`, `title` (explicit name or preview, nullable),
196
+ `createdAt`, `updatedAt` (Unix **seconds**), `forkedFromId`, `running`, `consistency`,
197
+ `revision`, `nextCursor`, and `complete`. `running` is the observed thread/local prompt
198
+ state, not a subscription or a guarantee about the state after the response. It is
199
+ null when Codex reports an unloaded/unknown state: absence of a local runtime does
200
+ not prove that another client is idle.
201
+
202
+ - Turn responses add `turns[]`: `turnId`, `status`, `error`, `startedAt`, `completedAt`
203
+ (nullable Unix **seconds**), `durationMs`, `itemsView`, `updates`, `omissions`, and
204
+ optional `_meta.codex.items` containing full native `ThreadItem` records.
205
+ - Item responses add `items[]`: `turnId`, `itemId` (native item ID), `startedAtMs`,
206
+ `completedAtMs` (nullable Unix **milliseconds**), `updates`, `omissions`, and optional
207
+ `_meta.codex.item`. Item timestamps are not misreported as turn timestamps.
208
+ - `updates` contains standard ACP update payloads as response data, never notifications.
209
+ Apply tool upserts in order. Message IDs preserve the existing receipt-ID convention;
210
+ retained `/plan <task>` and `/review` commands use their original ACP receipt and
211
+ content, while internal plan implementation prompts have no ACP message update.
212
+ `_meta.codex.turnId` and the enclosing turn/item identify provenance. Full-turn message
213
+ updates also carry `turnStartedAt` in milliseconds when known.
214
+ - `complete` means exactly `nextCursor === null`, the end of **this traversal**. It is
215
+ not a complete-content, immutable-snapshot, or full-thread assertion: a traversal
216
+ may be anchored, turn-filtered, summary-only, or missing previously collected pages.
217
+
218
+ #### Browsing versus export
219
+
220
+ `mode: "browse"` allows running sessions and returns `consistency: "live"`,
221
+ `revision: null`. The cursor remains usable when a new turn is appended. Codex may
222
+ change a returned in-progress item, or reject an invalidated cursor after rollback;
223
+ clients should upsert by turn/item identity and refresh or restart as appropriate.
224
+ This is suitable for history viewers, loading older messages, inspection and monitoring.
225
+ Each page requires one metadata read and one turn/item page read.
226
+
227
+ `mode: "export"` returns `consistency: "optimistic"` and a change fingerprint in
228
+ `revision`. It rejects a known running session, including a locally accepted prompt
229
+ not yet persisted by Codex. Before and after each page it compares thread metadata
230
+ and the last turn's **summary**, with the fingerprint carried by continuation cursors.
231
+ It no longer reloads a large final tool result merely to check every page boundary.
232
+ A detected change invalidates the traversal: discard collected pages and restart.
233
+ Use the same mode throughout a traversal and wait for the thread to become quiescent.
234
+
235
+ Example for textual/ACP export:
236
+
237
+ ```json
238
+ {"sessionId": "thread-id", "mode": "export", "itemsView": "full", "limit": 10}
239
+ ```
240
+
241
+ Export mode is a best-effort conflict check, **not an atomic snapshot or a content
242
+ hash of the entire history**. Codex has no transactional history version and its
243
+ thread timestamps have second precision. Changes to earlier items, or details absent
244
+ from a summary, can evade detection with unchanged timestamps/summary. No guarantee
245
+ extends past the final check. Strict backup/audit snapshots require stronger support
246
+ from Codex itself; version 2 does not claim to provide them.
247
+
248
+ #### Content fidelity and size
249
+
250
+ The new endpoints use a history-specific projection without changing live replay.
251
+ Mixed text, audio, images, skills and mentions retain separate content blocks. URL
252
+ and local media become `resource_link` blocks with `_meta.codex.inputType`; local paths
253
+ become file URIs. File-ID-only images use `codex-file:<encoded-id>` and retain `fileId`.
254
+ These are references, not fetched media or an automatic resource resolver. Text spans
255
+ are retained under `_meta.codex.textElements`.
256
+
257
+ `omissions[]` records `{itemId, field, reason}` for known projection losses, including
258
+ unmapped items (`field: "*"`), reasoning content replaced by a summary, and unrepresented
259
+ assistant fields. It is a diagnostic list, not a proof that every native field was
260
+ represented. For lossless capture of **native item records returned by Codex**, request
261
+ `includeNative: true`; the raw records remain namespaced under `_meta.codex` and follow
262
+ Codex's versioned schema. These records cannot recover data Codex never persisted,
263
+ truncated tool output, missing host metadata, or unavailable external files.
264
+
265
+ Responses exceeding `maxBytes` fail with `history_page_too_large` and `bytes`/`maxBytes`
266
+ diagnostics. Nothing is truncated and no continuation is consumed: retry the same
267
+ cursor with a smaller limit or a larger budget. To switch from whole turns to item
268
+ pagination, start the item endpoint without reusing a turn cursor. A single item can
269
+ still exceed the maximum; this fails explicitly rather than silently omitting it.
270
+ The cap applies after the native reply has been materialized; it bounds outgoing
271
+ payloads, not Codex's allocations or peak adapter memory. There is no chunked blob
272
+ transfer in this protocol.
273
+
274
+ #### Errors and cancellation
275
+
276
+ History errors include `data.reason` and `data.retryable`. Native failures also retain
277
+ `data.native.code` and `data.native.data`; numeric native RPC codes are preserved.
278
+ `retryable: null` means unknown, not an invitation to retry automatically. Known native
279
+ not-found/cursor failures are classified; unknown native errors remain intact.
280
+
281
+ | Reason | Caller action |
282
+ | --- | --- |
283
+ | `history_invalid_params` / `history_invalid_cursor` | Correct the request or start a new traversal; false. |
284
+ | `history_busy` | Wait for idle, then retry export; true. |
285
+ | `history_changed` | Discard all collected pages and restart without a cursor; true. |
286
+ | `history_not_found` | Report the missing source; false. |
287
+ | `history_unsupported` | Upgrade the Codex app-server; false. |
288
+ | `history_unavailable` | Retry after the reported source/transport problem is resolved; true. |
289
+ | `history_source_error` | Inspect preserved native diagnostics; retryability unknown. |
290
+ | `history_incomplete` / `history_invalid_page` | Do not publish an export; investigate the native response; false. |
291
+ | `history_page_too_large` | Retry with a smaller page or larger budget; true. |
292
+ | `history_cancelled` | Stop this traversal; false. |
293
+ | `command_history_incomplete` | A command receipt mapping is unconfirmed in this thread or an ancestor; wait for saving to finish, or restore the command history data; retryability unknown. |
294
+
295
+ Invalid inputs use `-32602`; local state conflicts/size/cancellation use `-32600`;
296
+ incomplete/non-progressing native pages use `-32603`. No error becomes an empty
297
+ successful archive. Clients should track continuation cursors to detect longer cycles.
298
+
299
+ ACP `$/cancel_request` cancels an individual history request. It stops waiting and
300
+ prevents further native reads, including while saved command receipts are being
301
+ loaded. An already started native or receipt read may finish in the background;
302
+ the cancelled request returns `history_cancelled`. It never sends `turn/interrupt`.
303
+ These methods do not upload exports or
304
+ perform the separate reversible `_codex/session_archive` operation.
305
+
306
+ ### Config options
307
+
308
+ | `configId` | `category` | Type | Values |
309
+ | --- | --- | --- | --- |
310
+ | `mode` | `mode` | select | `read-only`, `agent`, `agent-full-access` |
311
+ | `model` | `model` | select | Codex catalog ids (the current id is always listed) |
312
+ | `effort` | `thought_level` | select | efforts supported by the current model |
313
+ | `collaboration_mode` | `model_config` | select | `default`, `plan` |
314
+ | `fast_mode` | `model_config` | boolean | only when the model offers the `fast` service tier |
315
+
316
+ Changing `model` re-validates `effort` and clears `fast_mode` if the new model does
317
+ not support it.
318
+
319
+ ## Skills and plugins
320
+
321
+ Codex's skill and plugin catalogs are exposed as `_codex/*` requests whose params and
322
+ results are the Codex app-server v2 shapes, passed through verbatim (see
323
+ `src/app-server/v2/`). Codex validates the fields; the adapter only checks that params
324
+ are an object and requires a successful `initialize`. Codex errors propagate unchanged.
325
+ Advertised as `capabilities._meta.codex.skills: true` and `capabilities._meta.codex.plugins: true`.
326
+
327
+ | Method | Codex request | Params → result |
328
+ | --- | --- | --- |
329
+ | `_codex/skills_list` | `skills/list` | `SkillsListParams` → `SkillsListResponse` |
330
+ | `_codex/skills_config_write` | `skills/config/write` | `SkillsConfigWriteParams` → `SkillsConfigWriteResponse` |
331
+ | `_codex/plugin_list` | `plugin/list` | `PluginListParams` → `PluginListResponse` |
332
+ | `_codex/plugin_installed` | `plugin/installed` | `PluginInstalledParams` → `PluginInstalledResponse` |
333
+ | `_codex/plugin_install` | `plugin/install` | `PluginInstallParams` → `PluginInstallResponse` |
334
+ | `_codex/plugin_uninstall` | `plugin/uninstall` | `PluginUninstallParams` → `{}` |
335
+ | `_codex/plugin_set_enabled` | Local: `config/value/write`; remote: plugin-service and `plugin/reconcile` | `{pluginId: string, enabled: boolean, remotePluginId?: string}` → `{enabled: boolean}` |
336
+ | `_codex/plugin_read` | `plugin/read` | `PluginReadParams` → `PluginReadResponse` |
337
+ | `_codex/marketplace_add` | `marketplace/add` | `MarketplaceAddParams` → `MarketplaceAddResponse` |
338
+ | `_codex/marketplace_remove` | `marketplace/remove` | `MarketplaceRemoveParams` → `{}` |
339
+ | `_codex/marketplace_upgrade` | `marketplace/upgrade` | `MarketplaceUpgradeParams` → `MarketplaceUpgradeResponse` |
340
+
341
+ The agent sends the `_codex/skills_changed` notification (params `{}`) whenever Codex
342
+ emits `skills/changed`, and after each successful `_codex/skills_config_write`,
343
+ `_codex/plugin_install`, `_codex/plugin_uninstall`, `_codex/plugin_set_enabled` and `_codex/marketplace_*` request.
344
+ Plugins ship skills, so one signal covers both catalogs; a host refetches on it.
345
+ The adapter's own skill snapshot and `available_commands_update` handling is unchanged.
346
+
347
+ ## Account and file search
348
+
349
+ Pass-throughs in the same style as the skills surface (params and results are the Codex
350
+ shapes, `initialize` required, Codex errors propagate unchanged). Advertised as
351
+ `capabilities._meta.codex.account: true` and `capabilities._meta.codex.fuzzyFileSearch: true`.
352
+
353
+ | Method | Codex request | Params → result |
354
+ | --- | --- | --- |
355
+ | `_codex/account_read` | `account/read {refreshToken: false}` | `{}` → `GetAccountResponse` |
356
+ | `_codex/rate_limits` | `account/rateLimits/read` | `{}` → `GetAccountRateLimitsResponse` |
357
+ | `_codex/fuzzy_file_search` | `fuzzyFileSearch` | `{query, roots, cancellationToken?}` → `FuzzyFileSearchResponse` (`cancellationToken` defaults to `null`) |
358
+
359
+ Notifications the agent forwards with Codex's payload verbatim: `account/rateLimits/updated`
360
+ → `_codex/rate_limits_updated`, `fuzzyFileSearch/sessionUpdated` →
361
+ `_codex/fuzzy_file_search_updated`, `fuzzyFileSearch/sessionCompleted` →
362
+ `_codex/fuzzy_file_search_completed`. (Inside a session, fuzzy search frames are also
363
+ mapped to `tool_call_update`s as before.)
364
+
365
+ ## Prompts and state
366
+
367
+ `session/prompt` inserts a new foreground prompt into the adapter's live conversation
368
+ and starts publishing its `user_message` before returning `{messageId}`, without
369
+ waiting for Codex startup (or returns `-32602` for an empty prompt,
370
+ an image on a text-only model, or an unknown session). The id is minted by the adapter
371
+ unless the request names it in `_meta.alwith.messageId` (a non-empty string a host
372
+ chooses so it can recognise the turn's frames before the response arrives), and it is
373
+ passed to Codex as `clientUserMessageId`, so the `user_message` echo and every
374
+ replay of that message report under it (`userMessage.clientId`; a message written by
375
+ another Codex client has no `clientId` and keeps its item id). While a turn runs, another
376
+ prompt on the same session is injected into it with `turn/steer` and returns
377
+ `{messageId, _meta: {codex: {steered: "<turnId>"}}}`.
378
+
379
+ Turn correlation (`capabilities._meta.alwith.turns = {version: 2}`): every `state_update`
380
+ of a prompted turn (`running`, `requires_action`, `idle`) carries `_meta.alwith.messageId`,
381
+ the receipt of the prompt that owns the turn. A turn Codex started elsewhere (observed
382
+ foreign turn) has no receipt and no such field. An `idle` that belongs to an older turn is
383
+ never published after a newer prompt started its turn on the session.
384
+ Image capability validation also applies to steering. Once `idle` is published,
385
+ the next prompt starts a new turn rather than steering the completed one.
386
+
387
+ State frames:
388
+
389
+ | `state` | When |
390
+ | --- | --- |
391
+ | `running` | Turn accepted. Also after every blocking client request resolves. |
392
+ | `requires_action` | The first open `session/request_permission` or `elicitation/create`. |
393
+ | `idle` | Turn over. `stopReason` is `end_turn` or `cancelled`; `usage` carries the last turn's tokens when known. |
394
+
395
+ A failed turn emits an `agent_message_chunk` with the error text and
396
+ `_meta.codex.error {message, codexErrorInfo, additionalDetails}`, then
397
+ `idle` with `stopReason: "error"` and a standard JSON-RPC `error {code, message, data}`.
398
+ `data.codex` retains category and retryability; `_meta.codex.error` retains Codex diagnostics.
399
+
400
+ `session/cancel` calls `turn/interrupt`; the turn ends with `idle` / `cancelled`.
401
+ Concurrent cancel requests coalesce; native interrupt RPCs coalesce only while in flight within the recovery budget.
402
+ A timed-out RPC does not block a later retry.
403
+ If completion is missing after three seconds, the adapter checks `thread/read` with a
404
+ three-second deadline. Only native `idle` or `notLoaded` confirms interruption and
405
+ allows the adapter to reconcile open tools and publish `idle` / `cancelled`. An
406
+ unloaded thread requires reopening before the next prompt. An active or unreachable
407
+ thread gets one interrupt retry and another bounded status check. If cancellation
408
+ still cannot be confirmed, an actionable agent message is emitted while the session remains
409
+ running; another Stop retries. Recovery is scoped to the original turn and cannot
410
+ cancel or finalize a newer turn or another session.
411
+ Restoring an active turn bounds each command-receipt read to three seconds. Stop
412
+ cancels the restore wait; late reads cannot publish old messages into a later turn.
413
+ Unknown user-message mappings are not replaced with native IDs. A restore failure
414
+ does not mark a still-running native turn idle: updates continue, and normal native
415
+ completion reports the history error; confirmed cancellation still reports
416
+ `idle` / `cancelled`.
417
+ When `replayFrom` is omitted or `null`, restoring the active turn only rebuilds internal
418
+ state; it emits no stored message or tool snapshots, before or after the resume response.
419
+ New live tool activity announces any required initial tool/terminal state before its
420
+ updates. Tools never announced to the client are not manufactured merely to finalize
421
+ them. Explicit `replayFrom: {type: "start"}` still replays retained history.
422
+ Cancellation before `turn/start` prevents that turn from being sent, even if
423
+ the preceding skills refresh is still pending. For `/compact`, cancellation
424
+ ends the adapter's wait and reports `idle` / `cancelled`; Codex exposes no
425
+ compaction-specific interrupt, so background compaction may still finish.
426
+
427
+ Blocking requests are counted per turn: a late response from a previous turn
428
+ cannot clear the current turn's `requires_action` state. A lost Codex connection
429
+ also ends waits for compaction and plan approval with `error`.
430
+
431
+ ## Session updates
432
+
433
+ ### Chat branches
434
+
435
+ `capabilities._meta.codex.forkAtTurn: true` advertises an optional
436
+ `session/fork._meta.codex.lastTurnId` non-empty string. It maps to Codex
437
+ `thread/fork.lastTurnId`: the referenced completed turn is included and later
438
+ turns are omitted. Codex rejects unknown or in-progress boundaries. Omitting
439
+ the hint preserves the whole-thread fork. Message/item ids are not turn ids.
440
+
441
+ Live `user_message` / `agent_message_chunk` and replayed `user_message` /
442
+ `agent_message` updates carry `_meta.codex.turnId` for their enclosing Codex
443
+ turn, alongside existing phase metadata.
444
+ When Codex supplies the turn's original `startedAt`, those messages also carry
445
+ `_meta.codex.turnStartedAt` as Unix milliseconds, on live delivery and history
446
+ replay alike. It is omitted when native timing is unavailable; clients must not
447
+ display a replay arrival time as the historical turn time.
448
+
449
+ `capabilities._meta.codex.sessionLineage: true` advertises
450
+ `_meta.codex.{nativeSessionId, forkedFromId}` on `session/new`, `session/resume`,
451
+ `session/fork` responses and each `session/list` entry. These are Codex's native
452
+ `Thread.sessionId` and `Thread.forkedFromId`. ACP `sessionId` still identifies
453
+ one thread. Despite its upstream type comment, native `Thread.sessionId` can
454
+ change on fork (verified with Codex 0.156.1); clients must build branch relations
455
+ from `forkedFromId`, not equality of `nativeSessionId`. A root has
456
+ `forkedFromId: null`. Subagent `parentThreadId` is unrelated. Clients must page
457
+ the list fully when looking for branches; a branch can have a different cwd.
458
+ The adapter does not persist a separate branch index.
459
+
460
+ | Update | Source |
461
+ | --- | --- |
462
+ | `agent_message_chunk` | `item/agentMessage/delta`; `messageId` is the Codex item id; `_meta.codex.phase` is `commentary` or `final_answer`. Notices (warnings, model reroutes) use `_meta.codex.notice: true`. |
463
+ | `agent_thought_chunk` | reasoning deltas, keyed by item id |
464
+ | `user_message` | local prompt insertion, native `userMessage` upserts, and retained history replay; the ACP receipt ID identifies prompts inserted through `session/prompt` |
465
+ | `agent_message`, `agent_thought` | history replay; `agent_message` also reports cancellation recovery and command-save problems requiring user action |
466
+ | `tool_call_update` | see below |
467
+ | `terminal_update`, `terminal_output_chunk` | shell commands; `terminalId` equals the tool call id; data is base64 |
468
+ | `plan_update` | `turn/plan/updated` → `{type: "items", planId: "codex-turn-plan"}`; plan-mode drafts → `{type: "markdown", planId: <item id>}` |
469
+ | `usage_update` | `thread/tokenUsage/updated`: `used` = last turn total tokens, `size` = model context window |
470
+ | `compaction_update` | `compactionId` = Codex `contextCompaction` item id; `in_progress` on item/started, `completed` on item/completed (history snapshots report `completed`). No `compaction_summary_chunk`: Codex exposes no summary text. |
471
+ | `session_info_update` | `title` from Codex thread names, or the first prompt line as a fallback; `_meta.codex.retry` for transient errors Codex is retrying |
472
+ | `available_commands_update` | built-in slash commands plus `$<skill>` entries |
473
+ | `config_option_update` | after every `session/set_config_option` and `/plan` |
474
+
475
+ ### Tool calls
476
+
477
+ The first frame for a `toolCallId` carries `name`, `title`, and `kind`; later frames
478
+ omit `name` and patch `status`, `content`, `rawOutput`. Codex `interrupted` items map
479
+ to status `cancelled`, and a cancelled turn marks every still-open tool call
480
+ `cancelled` before the `idle` frame.
481
+
482
+ | `name` | `kind` | Codex item |
483
+ | --- | --- | --- |
484
+ | `shell` | `execute` | unclassified command; `content: [{type: "terminal"}]` |
485
+ | `read_file`, `list_files` | `read` | command classified as a read or listing |
486
+ | `search` | `search` | command classified as a search |
487
+ | `apply_patch` | `edit` | file change; `content` is `diff` with `changes` and `patch {format: "git_patch"}`, `locations` lists every path |
488
+ | `mcp` | `execute` | MCP tool call; progress arrives as text content |
489
+ | `dynamic_tool` | `execute` | dynamic tool call |
490
+ | `web_search` | `fetch` | web search |
491
+ | `view_image` | `read` | image view |
492
+ | `image_generation` | `other` | image generation; result as image content |
493
+ | `subagent`, `collab` | `other` | sub-agent activity and collaboration calls; `_meta.codex.subagent` / `_meta.codex.collaboration` |
494
+ | `fuzzy_file_search` | `search` | Codex fuzzy file search sessions |
495
+ | `guardian_review` | `think` | auto-approval reviews |
496
+ | `mcp_startup` | `other` | failed MCP server startups (status `failed`) |
497
+ | `plan_review` | `switch_mode` | plan approval prompt |
498
+
499
+ ## Permissions
500
+
501
+ All approvals use `session/request_permission` with `title`, optional `description`
502
+ (the Codex reason), and `subject: {type: "tool_call", toolCall}` whose `toolCallId`
503
+ is the Codex item id. Clients answer with an advertised `optionId`; `cancelled`,
504
+ unknown ids, and transport errors fail closed.
505
+
506
+ | Title | Options (`optionId` → Codex decision) |
507
+ | --- | --- |
508
+ | `Run command?` / `Allow network access?` | `allow_once` → accept, `allow_for_session` → acceptForSession, `accept_execpolicy_amendment` → acceptWithExecpolicyAmendment, `apply_network_policy_amendment:<n>` → applyNetworkPolicyAmendment, `decline`, `cancel`. When Codex sends `availableDecisions` that list is authoritative. |
509
+ | `Make edits?` | `allow_once` → accept, `allow_for_session` → acceptForSession, `cancel` |
510
+ | `Grant permissions?` | `allow_permissions_turn`, `allow_permissions_turn_strict_auto_review`, `allow_permissions_session`, `reject_permissions` |
511
+ | MCP approvals | `allow_once` / `accept`, `allow_session`, `allow_always` (only when Codex advertises `persist`), `decline`, `cancel` |
512
+ | `Implement this plan?` | `implement_plan`, `revise_plan` |
513
+
514
+ Option descriptions ride in `_meta.codex.description`.
515
+
516
+ ## Elicitation
517
+
518
+ - MCP form elicitations use `elicitation/create` (`mode: "form"`) when the client
519
+ declares `elicitation.form`; MCP `enum`/`enumNames` schemas are converted to `oneOf`.
520
+ A structured form the client cannot render is cancelled rather than degraded.
521
+ - MCP URL elicitations use `mode: "url"` when the client declares `elicitation.url`;
522
+ `elicitation/complete` follows once Codex resolves the request.
523
+ - Message-only MCP requests fall back to `session/request_permission`.
524
+ - Codex user-input questions (`item/tool/requestUserInput`) become a form whose
525
+ `toolCallId` is the Codex item; questions with an "other" answer add a
526
+ `<id>__other` text field.
527
+
528
+ ### Shutdown and routing failure details
529
+
530
+ `session/close` reports `_meta.codex.close` with `attempted: true`,
531
+ `localDetached: true`, and `remoteUnsubscribe: "confirmed" | "timed_out" | "failed"`
532
+ when a loaded session is closed. Closing an unknown session remains a no-op.
533
+ A timeout confirms only local cleanup, not remote unsubscription. Terminal updates
534
+ are best effort: a completed local write is not acknowledgement by the client.
535
+ Codex stdout EOF drains already received RPC frames through the dispatcher before
536
+ publishing connection loss; an already dispatched turn completion remains authoritative.
537
+ Shutdown does not wait for user approvals to finish.
538
+
539
+ Provider changes are staged and committed only after all open sessions rebind.
540
+ All sessions' history is checked before changing subscriptions. Codex cannot resume
541
+ an empty thread whose history storage has not yet been created; in that case the
542
+ request fails with `-32600` before changing routing. Close empty sessions, configure
543
+ the provider, then create them again. Rebinding unsubscribes before resume because
544
+ Codex treats resume of a subscribed thread as rejoin and ignores routing overrides.
545
+ Competing provider changes are serialized. During a switch, prompt admission,
546
+ session lifecycle changes, and config changes fail with a retryable `-32600` error.
547
+ A switch also fails if lifecycle/config work or a turn is already active.
548
+ Overlapping lifecycle/config operations on the same session and prompts during
549
+ those operations are rejected with `-32600`; retry after the operation finishes. Inference
550
+ turns do not hold a global lifecycle lock.
551
+
552
+ On failure, the adapter retains the previous committed routing and attempts remote
553
+ compensation for every attempted session, including the request that failed.
554
+ The error data includes `attemptedSessions` and `staleSessions`. Failed compensation
555
+ marks affected sessions with `_meta.codex.routing.stale: true` on a standard
556
+ `config_option_update`; their prompts are rejected until a successful resume or
557
+ provider switch. Successful switching emits `stale: false`. The config update does
558
+ not contain or attest to a provider URL. JSON-RPC errors do not imply no remote side
559
+ effects; uncertain remote state is reported explicitly.
560
+
561
+ Wire logging redacts structured credential/header fields and complete `env` maps,
562
+ including arbitrarily named MCP environment variables. Logs are not guaranteed to
563
+ be secret-free: command arguments, free-form tool output, and Codex stderr may
564
+ contain sensitive text and are not heuristically redacted.
565
+
566
+ A timed-out unsubscribe fences subsequent resume/fork of that thread until the
567
+ outstanding RPC settles. These attempts fail promptly with `-32600`, rather than
568
+ racing a late unsubscribe against a new subscription. Delete/archive requests
569
+ after local close still wait for Codex to acknowledge the remote mutation; the
570
+ local close deadline is not a deadline for those separate remote operations.
571
+
572
+ ### Recovery and terminal outcomes
573
+
574
+ Live `agent_message` completion updates carry the authoritative full content for
575
+ that message id. ACP v2 clients replace previously accumulated content when a
576
+ concrete `content` array arrives; they must not append the snapshot to earlier
577
+ `agent_message_chunk` text. History replay uses the same snapshot mapping.
578
+ Completed-only tools, and progress/patch events received without a start, emit a
579
+ complete first upsert with `name`, `title`, and `kind`.
580
+
581
+ Before an idle update, unfinished tool calls are reconciled to the enclosing
582
+ turn's outcome and marked `_meta.codex.reconciled: true`. Orphan terminals receive
583
+ an `exitStatus` with unknown (`null`) exit code and signal; this does not assert
584
+ that a process exited successfully or received a particular OS signal. Their
585
+ `_meta.codex.turnOutcome` records `completed`, `failed`, or `cancelled`.
586
+
587
+ Terminal snapshots retain full output. For all commands, duplicate
588
+ `tool_call_update.rawOutput.output` is capped at 16,384 UTF-16 code units, without
589
+ splitting a surrogate pair. A capped result has `_meta.codex.outputTruncated`,
590
+ `outputCharacters`, and (for terminal-backed commands) `terminalId`; obtain the
591
+ complete output from the terminal or the classified tool call’s text content.
592
+
593
+ Context-window exhaustion ends with `max_tokens`; Codex policy violations end
594
+ with `refusal`. Other failures retain `error`. Error metadata under
595
+ `_meta.codex.error` includes `category` and `retryable`; retryability is advisory
596
+ and never causes automatic resubmission of a failed model turn.
597
+
598
+ A rejected steer becomes a new prompt only after a matching completion or local
599
+ turn finalization proves the old turn ended. Unexpected `turn/started` events are
600
+ observed as running foreground work, including cancellation and finalization.
601
+ Resuming an active thread discovers its current turn through paged history.
602
+
603
+ Repeated cursors in automatically paginated model catalogs or history fail with
604
+ an actionable error. Failed-open cleanup has the same bounded unsubscribe wait
605
+ and pending-unsubscribe fence as session close.
606
+
607
+
608
+ ### Cache freshness and cancellation
609
+
610
+ Skill metadata is reused for an unchanged working-directory context. A Codex
611
+ `skills/changed` notification invalidates it. Commands refresh immediately for the
612
+ current root context; other sessions refresh on their next foreground prompt.
613
+ Notifications never switch roots, preventing delayed watcher echoes from causing
614
+ a refresh loop.
615
+ Because extra skill roots are process-global in Codex, context changes are
616
+ serialized through thread/turn start acknowledgement; model inference can run
617
+ concurrently. Switching roots invalidates the previous snapshot. Model catalogs
618
+ are shared for up to 30 seconds, invalidated on `account/updated` and local login/logout, and forcibly
619
+ refreshed for provider changes. Account notifications also refresh live session
620
+ account-dependent settings.
621
+
622
+ Request cancellation for `session/new`, `session/resume`, and `session/fork`
623
+ returns `-32800` promptly. A remote request already in flight may still finish;
624
+ its late result is unsubscribed rather than left as an inaccessible open session.
625
+ Lifecycle admission stays held until that operation settles, so a replacement or
626
+ provider transition cannot race the cleanup. Cancellation is not a rollback of
627
+ already completed remote actions.
628
+
629
+
630
+ Skill-root serialization covers foreground turns started by this adapter and
631
+ steering into those turns. It is not thread or tenant isolation. Codex-created
632
+ subagent/automatic turns and turns started by other app-server clients snapshot
633
+ process-global roots at their own start time and are outside this guarantee.
634
+ The pinned Codex 0.153 implementation constructs the skill snapshot before
635
+ returning a started turn: [turn input handling](https://github.com/openai/codex/blob/rust-v0.158.0/codex-rs/core/src/session/turn_input.rs)
636
+ and [turn context construction](https://github.com/openai/codex/blob/rust-v0.158.0/codex-rs/core/src/session/turn_context.rs).
637
+
638
+ Turn-scoped `item/*` notifications are ignored while idle or when they name a
639
+ known different active turn. Items are accepted while a local turn's id is still
640
+ unknown, because Codex may deliver them before its start response.
641
+ # Module-owned client tools
642
+
643
+ The adapter advertises `capabilities._meta.alwith.tools = {version: 1}`. On
644
+ `session/new`, `session/resume` and `session/fork`, a trusted host may supply
645
+ `_meta.alwith.tools = {version: 1, revision, definitions}`. Definitions contain
646
+ unique `name`, `description` and object `inputSchema` fields (at most 128 tools).
647
+ New threads receive these as Codex dynamic functions before their first turn.
648
+ Codex persists dynamic definitions across resume/fork; the host must retain the
649
+ original revision and declarations with the session and refuse incompatible
650
+ restores. Supplying a new set on resume does not replace persisted definitions.
651
+
652
+ Calls use the reverse request `_alwith/tool/call` with
653
+ `{sessionId, turnId, toolCallId, name, arguments, toolSetRevision}`. Tool names in
654
+ Codex have an `alwith_client_` prefix; callback names are the original names.
655
+ Results are `{success, contentItems}`. Items are `{type:"text",text}` or bounded
656
+ base64 `{type:"image"|"audio",mimeType,data}`. Arbitrary resource URLs are never
657
+ fetched by the adapter. Results have a 16 MiB serialized limit.
658
+
659
+ Declaring a tool does not grant permission. Non-full-access modes use the
660
+ existing ACP permission channel before execution. Only this user decision
661
+ enters `requires_action`; executing the host callback remains `running`.
662
+ The module must still validate scope, arguments, task ownership and revision.
663
+ Cancellation uses standard ACP `$/cancel_request` with `requestId`. Callbacks
664
+ are deduplicated within a turn; conflicting call IDs fail closed. Cancel,
665
+ session close and engine disconnect terminate waiting, and late results cannot
666
+ update a subsequent turn. Hosts must separately preserve operation identity
667
+ for writes whose outcome is unknown across process loss.
668
+
669
+ Runtime hosts route callbacks privately to the module that owns the Agent
670
+ process. They must not expose tool arguments/results on the public event bus
671
+ or replay journal. UI progress uses normal `tool_call_update` frames.
672
+
673
+ ### Host session instructions and initial mode
674
+
675
+ `session/new`, `session/resume`, and `session/fork` accept
676
+ `_meta.alwith.appendSystemPrompt` (a string) as Codex `developerInstructions`.
677
+ The adapter preserves Codex base instructions; host text is never injected as a user message.
678
+ `_meta.codex.mode` explicitly selects `read-only`, `agent`, or `agent-full-access`
679
+ for that session, overriding the process default. Unknown modes are rejected before opening a thread.
680
+
681
+ Empty-preview fork compatibility: Codex 0.156.1 omits such threads from `thread/list`
682
+ even after continuation. For `session/list` the adapter reads only
683
+ `session_meta` headers beneath the server-reported `codexHome` (`sessions` or
684
+ `archived_sessions`), discovers native `forked_from_id` records, and resolves their
685
+ summaries with `thread/read`. The hydrated preview from `thread/read` can differ from the list index; the adapter
686
+ checks every native page before supplementing IDs to avoid duplicates. Only missing forks from the requested
687
+ source/archive/project scope are appended. Native cursors are retained. Native list rows can also omit `forkedFromId` after
688
+ unload; each page is enriched with the lineage confirmed by `thread/read`.
689
+ No Codex file is written and no second conversation index is persisted. This also
690
+ allows a fresh adapter process to discover forks before they have a new user turn.
691
+
692
+ Fork/resume responses with history replay also include `_meta.codex.forkedAtTurnId`
693
+ (string or null) for forks. This is the last native turn ID shared with the parent,
694
+ resolved from both native histories each time; it identifies where an inherited-history
695
+ marker belongs even after the child continues and the adapter restarts. Null means
696
+ there is no shared visible turn (for example an empty fork).
697
+
698
+ ### Interrupted history and inline images
699
+
700
+ History replay updates from an interrupted Codex turn carry
701
+ `_meta.codex.historyInterrupted: true`, preserving the turn ID and other metadata.
702
+ This describes the persisted turn status; it does not synthesize missing output.
703
+ Inline base64 image inputs replay as ACP image blocks. Remote image URLs and
704
+ file IDs replay as resource links, without fetching their contents.
705
+
706
+ ### File-reference replay and fallback titles
707
+
708
+ Standalone `[@name](file://...)` text blocks written by the adapter replay as ACP
709
+ `resource_link` blocks, including existing histories. Inline Markdown links and
710
+ embedded resource context remain text. Native mention inputs also replay as
711
+ resource links. No sidecar transcript or file-content fetch is involved.
712
+ Fallback titles prefer the user's text over file references; attachment-only
713
+ prompts use the first resource name. Explicit Codex thread names are preserved.
714
+ Session listing reads the first history page for unnamed attachment-prefixed
715
+ previews, including truncated previews, so list and replay titles agree.
716
+
717
+ Live interrupted turns upsert their last user message with
718
+ `_meta.codex.turnInterrupted: true`. This remains attached to that prompt after
719
+ another turn starts; it must not be inferred from the currently selected chat or
720
+ from a later turn's stop reason. Native history replay continues to use
721
+ `historyInterrupted` with its existing incomplete-history explanation.
722
+
723
+ ### Native image attachment replay
724
+
725
+ Native `localImage` user inputs are replayed as inline ACP `image` blocks, including
726
+ attachments outside the thread directory. Only native attachment references grant
727
+ read access; Markdown links and client-supplied metadata do not. Reads are async,
728
+ limited to regular raster image files up to 32 MiB per image, and do not create a
729
+ second history store. Missing, oversized or unsupported files become a
730
+ `resource_link` with URI `codex-image:unavailable` and
731
+ `_meta.codex.imageUnavailable: true`; clients should show a non-interactive image
732
+ placeholder without displaying local paths.
733
+
734
+ A complete Desktop image attachment envelope is removed from display text only
735
+ when every declared filename/path is well formed, all native local-image paths
736
+ match, and the remaining entries match the number of native inline Base64 images.
737
+ This normalization never reads a path mentioned only in the envelope. The
738
+ text after `## My request:` becomes the user body. Ordinary text and incomplete or
739
+ mismatched envelopes remain unchanged. Raw history export retains native input.
740
+
741
+ History replay updates carry `_meta.codex.historyReplay: true`. When Codex provides
742
+ `durationMs` (or both start and completion timestamps), updates also carry
743
+ `_meta.codex.turnDurationMs` in milliseconds. Clients must not derive historical
744
+ work duration from replay arrival times; when native timing is absent, show a
745
+ non-numeric completed-work label.
746
+
747
+
748
+ ### Message clocks and work duration
749
+
750
+ Live user-message upserts preserve the client-recorded `_meta.codex.messageSentAt`
751
+ from the prompt request; clients without it use the native item's `startedAtMs`. Final assistant snapshots carry `messageCompletedAt` from the native
752
+ item's `completedAtMs`. `turnCompletedAt` is the native turn completion in Unix
753
+ milliseconds, used for restored replies when item-level completion is unavailable.
754
+ The opening historical user message uses the native turn submission/start time;
755
+ subsequent user messages in the same historical turn omit a timestamp because the
756
+ history API does not expose their individual send times.
757
+
758
+ Live `workStartedAt` uses the opening prompt's recorded send time when provided,
759
+ otherwise the native turn start. The first synchronous final-answer
760
+ item start seals `workDurationMs`; streaming the final answer does not extend it.
761
+ These fields are routed by turn ID and retained on normal message upserts.
762
+ Native history does not expose this work boundary, so it only carries
763
+ `turnDurationMs`. Clients label that value as total time, never as work time.
764
+ Missing timing remains absent; replay arrival times are not substitutes.
765
+
766
+ ### Desktop questionnaire replies
767
+
768
+ A complete `send_user_message_question_reply` envelope containing a non-empty
769
+ array of `{questionItemId, question, answer}` strings is replayed as ordered ACP
770
+ text blocks. Each block contains readable `question\nanswer` text and
771
+ `_meta.codex.questionReply: {questionItemId, question, answer}`. Clients may render
772
+ this metadata as a single-line question summary above the literal answer. Copy
773
+ and search retain readable text. Native history remains unchanged. Malformed,
774
+ partial, or quoted envelopes remain ordinary text; no entries are silently lost.
775
+ For locally submitted envelopes, message-level `_meta.codex.questionReplyParts`
776
+ also contains ordered `{text, question?}` display parts. Receipt-adopting clients
777
+ that retain optimistic prompt content use these parts for display and copying;
778
+ this does not alter the original input sent to Codex.
779
+
780
+ Review results: when `exitedReviewMode.review` repeats a non-commentary agent message
781
+ in the same turn, live updates and full-turn history omit the duplicate exit message.
782
+ Review exits are buffered until the terminal drain to cover either native event order.
783
+ A standalone or different review result remains visible. Item-level history retains
784
+ its per-item projection because the matching answer may be outside the requested page.
785
+
786
+ ### Plugin enablement
787
+
788
+ `capabilities._meta.codex.pluginEnable: true` advertises `_codex/plugin_set_enabled`.
789
+ For local plugins the adapter upserts only the requested plugin's `enabled` field
790
+ through Codex's config API. For remote plugins clients must also pass the catalog's
791
+ `remotePluginId`. The adapter calls ChatGPT plugin-service's enable/disable endpoint
792
+ using credentials supplied by Codex, then calls `plugin/reconcile` and verifies
793
+ the installed catalog. Remote state overrides local config in Codex. Credentials
794
+ never cross the ACP boundary. It rejects malformed inputs and reports an
795
+ error if a higher-priority configuration overrides a local write. It emits
796
+ `_codex/skills_changed` after a saved change so clients can reload the effective catalog.
797
+ Remote plugin read/install requests should pass the catalog's `remotePluginId`
798
+ as `pluginName` when present; generated app aliases are not remote service IDs.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.7.11",
6
+ "version": "0.7.12",
7
7
  "description": "Native ACP v2 agent for the OpenAI Codex app-server",
8
8
  "packageManager": "bun@1.4.0",
9
9
  "engines": {
@@ -17,7 +17,8 @@
17
17
  "README.md",
18
18
  "CHANGELOG.md",
19
19
  "LICENSE",
20
- "package.json"
20
+ "package.json",
21
+ "docs/protocol.md"
21
22
  ],
22
23
  "scripts": {
23
24
  "prepublishOnly": "bun run build",