codex-copilot-dx 0.4.43 → 0.4.44

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/README.md CHANGED
@@ -23,10 +23,13 @@ Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adap
23
23
  ## Usage
24
24
 
25
25
  ```bash
26
- npx codex-copilot-dx@latest
26
+ npm install -g codex-copilot-dx@latest
27
+ ccdx
27
28
  ```
28
29
 
29
- > Tip: the `@latest` suffix forces `npx` to fetch the newest release instead of using a stale cached copy.
30
+ The shorter `ccdx` command is the primary launcher. The existing `codex-copilot-dx`
31
+ command remains an equivalent compatibility alias. For a one-off run without a
32
+ global install, use `npx codex-copilot-dx@latest`.
30
33
 
31
34
  On first run, it will:
32
35
  1. Authenticate with GitHub via device flow (if needed), after first trying compatible local Copilot token sources
@@ -42,14 +45,14 @@ If an existing `codex-copilot-dx` adapter is already running on the configured h
42
45
 
43
46
  The running adapter reports its package and protocol versions. After upgrading `codex-copilot-dx`, stop the old process before starting the new version; the new CLI refuses to silently reuse an incompatible adapter.
44
47
 
45
- Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `codex-copilot-dx` write the local config files instead. If you previously exported those variables, remove them from shell startup files and restart the terminal before launching Claude Code.
48
+ Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `ccdx` write the local config files instead. If you previously exported those variables, remove them from shell startup files and restart the terminal before launching Claude Code.
46
49
 
47
50
  ### Diagnostics
48
51
 
49
52
  Run a read-only config check without starting the adapter or changing files:
50
53
 
51
54
  ```bash
52
- codex-copilot-dx doctor
55
+ ccdx doctor
53
56
  ```
54
57
 
55
58
  The command exits with status `1` when it finds an invalid configuration and `0` when checks contain only OK or warning results.
@@ -59,7 +62,7 @@ The doctor checks the GitHub token, Codex config, Claude Code settings, Claude A
59
62
  For a read-only live check of the saved GitHub token, Copilot entitlement, and models endpoint, run:
60
63
 
61
64
  ```bash
62
- codex-copilot-dx doctor --online
65
+ ccdx doctor --online
63
66
  ```
64
67
 
65
68
  The online doctor never starts device flow, scans for replacement tokens, or changes the saved token.
@@ -67,7 +70,7 @@ The online doctor never starts device flow, scans for replacement tokens, or cha
67
70
  To actively verify the protocol path through an already-running adapter, run:
68
71
 
69
72
  ```bash
70
- codex-copilot-dx doctor --compat
73
+ ccdx doctor --compat
71
74
  ```
72
75
 
73
76
  The compatibility doctor sends a few minimal Copilot requests to check Codex Auto-review, native Responses, streaming history, compaction, image tool namespace handling, and Anthropic streaming. It consumes a small amount of Copilot usage, never starts the adapter or device flow, and does not change client configuration. Combine it with `--online` when both the saved-token entitlement check and the adapter protocol checks are needed.
@@ -78,19 +81,19 @@ For live runtime diagnostics, query the running adapter from the same machine:
78
81
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
79
82
  ```
80
83
 
81
- The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, latency totals, admission pressure, memory use, response-history size, image queue state, model cache counts, and token expiry state. It never includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
84
+ The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms for streaming routes, admission pressure, memory use, response-history size, image queue/cache state, model cache counts, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
82
85
 
83
86
  ```bash
84
- codex-copilot-dx --show-request-id
87
+ ccdx --show-request-id
85
88
  ```
86
89
 
87
- When the saved token is missing, `codex-copilot-dx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery and in-process token rotation accept only the same GitHub account. Concurrent callers share token refresh work without sharing cancellation, and a still-valid Copilot token may be used briefly after a transient refresh failure. Explicit token variables remain the intentional way to switch accounts.
90
+ When the saved token is missing, `ccdx` first looks for compatible local Copilot GitHub tokens, validates them with GitHub and Copilot, and imports a valid one before starting device login. It checks explicit token sources (`CCDX_GITHUB_TOKEN`, `CCDX_GITHUB_TOKEN_PATH`, `CCDX_GITHUB_TOKEN_PATHS`) plus common local `auth.json` layouts under application config directories. It does not rely on a specific app name. Generic discovery refuses to choose silently when valid tokens for multiple GitHub accounts are found. After an account is selected, automatic `401`/`403` recovery and in-process token rotation accept only the same GitHub account. Concurrent callers share token refresh work without sharing cancellation, and a still-valid Copilot token may be used briefly after a transient refresh failure. Explicit token variables remain the intentional way to switch accounts.
88
91
 
89
92
  If Copilot token refresh still fails with `401` or `403`, the saved GitHub token may be expired, revoked, or missing Copilot access. Delete the saved token and start the tool again to trigger GitHub device login:
90
93
 
91
94
  ```bash
92
95
  rm ~/.local/share/copilot-api/github_token
93
- codex-copilot-dx
96
+ ccdx
94
97
  ```
95
98
 
96
99
  ### Claude App opt-in
@@ -141,6 +144,7 @@ Environment variables:
141
144
  | `CCDX_IMG_MIN_BYTES` | `100000` | In-bounds images smaller than this are left untouched; oversized images are still downscaled |
142
145
  | `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image optimization tasks; values above `12` are capped at `12` |
143
146
  | `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
147
+ | `CCDX_IMG_CACHE_MAX_BYTES` | `67108864` | Process-local byte ceiling for cached image optimization results; set to `0` to disable result caching |
144
148
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
145
149
  | `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
146
150
  | `CCDX_CLAUDE_DESKTOP_API_KEY` | managed profile or generated for opt-in setup | Explicit bearer key written into the Claude App profile and recognized by the adapter for model discovery |
@@ -170,7 +174,7 @@ The adapter records token usage metadata to `~/.local/share/codex-copilot-dx/usa
170
174
  Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or set `CCDX_LOG_PATH` to a custom file. Add `CCDX_LOG_LEVEL=debug` to include upstream request attempts, retry causes, status codes, and timings. Debug logs do not include prompts, completions, request bodies, or authorization tokens.
171
175
 
172
176
  ```bash
173
- codex-copilot-dx usage
177
+ ccdx usage
174
178
  ```
175
179
 
176
180
  ### Image optimization
@@ -181,9 +185,13 @@ The adapter automatically downsamples embedded screenshots to model-appropriate
181
185
 
182
186
  The final serialized UTF-8 request body is measured before forwarding. Above the configured byte limit, unique source images are processed largest-first from their originals at quality 75 / 1600 px and then quality 65 / 1280 px, stopping as soon as the body fits. If necessary, the forwarded view omits older duplicate images, historical tool images, other historical images, and finally old tool outputs while preserving current input and tool-call skeletons. A request that still cannot fit is rejected locally with a structured `413` instead of sending an oversized body upstream.
183
187
 
188
+ Completed image transforms are reused from a byte-bounded process-local LRU cache, and concurrent requests for the same transform share one encode. The cache key includes source content, MIME type, model-aware dimensions, WebP quality, and encoder settings; failures are not cached and request cancellation remains isolated.
189
+
184
190
  When expanded Responses history exceeds Copilot's 50-image request limit, the adapter removes older duplicate image occurrences first, then keeps the 50 most recent images. This applies only to the forwarded request: local history remains complete, current images are preferred over older history, and requests at or below the limit are unchanged.
185
191
 
186
- After a successful `/v1/responses/compact` result containing a compaction item, the compact output becomes a new local history root. Later turns continue from that snapshot without re-expanding pre-compaction history; existing older response branches remain available until normal history eviction.
192
+ For `/v1/responses/compact`, the adapter sends one terminal `compaction_trigger` in unary mode, validates that Copilot returned completed compaction state, and rebuilds a replayable snapshot from recent system, developer, user, and assistant messages plus every compaction item. Only a validated snapshot becomes a new local history root. Later turns continue from it without re-expanding pre-compaction history; existing older response branches remain available until normal history eviction.
193
+
194
+ Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Inference responses forward safe quota, retry, model, trace, and upstream request-ID metadata; cookies, authorization, body-length, and encoding headers are not forwarded.
187
195
 
188
196
  Newer ChatGPT/Codex clients can advertise an `image_gen` namespace that already exists upstream. The adapter removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image inputs and screenshot optimization remain enabled.
189
197
 
package/bin/cli.mjs CHANGED
@@ -27,7 +27,7 @@ try {
27
27
  CLI = parseCliArgs(process.argv.slice(2));
28
28
  } catch (e) {
29
29
  console.error(e.message);
30
- console.error("Run codex-copilot-dx --help for usage.");
30
+ console.error("Run ccdx --help for usage.");
31
31
  process.exit(2);
32
32
  }
33
33
 
@@ -107,7 +107,7 @@ async function refreshClaudeDesktopModelDefs() {
107
107
  const models = JSON.parse(body);
108
108
  const autoReview = codexAutoReviewModelStatus(models);
109
109
  if (!autoReview.available) {
110
- console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run codex-copilot-dx doctor --compat to verify the live path.`));
110
+ console.log(status("warn", `Auto-review target ${autoReview.upstreamModel} is unavailable: ${autoReview.reason}. Run ccdx doctor --compat to verify the live path.`));
111
111
  }
112
112
  const gptModelIds = gptModelIdsFromCopilotModels(models);
113
113
  if (gptModelIds.length) {
@@ -169,7 +169,7 @@ async function reuseRunningAdapterIfAvailable() {
169
169
  });
170
170
  if (running.incompatible) {
171
171
  const found = running.data?.version || "unknown";
172
- throw new Error(`Adapter ${found} is already running at ${running.baseUrl}, but this CLI is ${LOCAL_VERSION}. Stop the existing process and run codex-copilot-dx again.`);
172
+ throw new Error(`Adapter ${found} is already running at ${running.baseUrl}, but this CLI is ${LOCAL_VERSION}. Stop the existing process and run ccdx again.`);
173
173
  }
174
174
  if (!running.ok) return false;
175
175
 
@@ -197,7 +197,7 @@ async function reuseRunningAdapterIfAvailable() {
197
197
 
198
198
  await openCodex();
199
199
  console.log(`
200
- ${status("ok", "Ready, using the existing codex-copilot-dx adapter")}
200
+ ${status("ok", "Ready, using the existing ccdx adapter")}
201
201
 
202
202
  Adapter: ${running.baseUrl}
203
203
  `);
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.4.43",
3
+ "version": "0.4.44",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
7
7
  "bin": {
8
+ "ccdx": "bin/cli.mjs",
8
9
  "codex-copilot-dx": "bin/cli.mjs"
9
10
  },
10
11
  "files": [
package/src/adapter.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  runtimeStatusPayload,
12
12
  } from "./observability.mjs";
13
13
  import { createRequestId, runWithRequestContext } from "./request-context.mjs";
14
+ import { createStreamPerformanceMetrics } from "./stream-performance.mjs";
14
15
  import { status } from "./status.mjs";
15
16
  import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
16
17
  import { createResponsesCompactHandler, createResponsesHandler } from "./responses-handler.mjs";
@@ -98,6 +99,7 @@ export function createAdapterHandler(options = {}) {
98
99
  const streamIdleTimeoutMs = parsePositiveInteger(options.streamIdleTimeoutMs, ADAPTER_RUNTIME_CONFIG.streamIdleTimeoutMs);
99
100
  const acquireRequest = options.acquireRequest || createRequestAdmission();
100
101
  const requestMetrics = options.requestMetrics || createRequestMetrics();
102
+ const streamPerformanceMetrics = options.streamPerformanceMetrics || createStreamPerformanceMetrics();
101
103
  const claudeDesktopModelOptions = () => {
102
104
  const modelDefs = options.modelRegistry?.modelDefs || options.claudeDesktopModelDefs;
103
105
  return Array.isArray(modelDefs) ? { modelDefs } : {};
@@ -141,6 +143,7 @@ export function createAdapterHandler(options = {}) {
141
143
  res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
142
144
  res.end(JSON.stringify(runtimeStatusPayload({
143
145
  metrics: requestMetrics,
146
+ streamPerformance: streamPerformanceMetrics,
144
147
  admission: acquireRequest,
145
148
  modelRegistry: options.modelRegistry,
146
149
  })));
@@ -224,12 +227,16 @@ export function createAdapterHandler(options = {}) {
224
227
  }
225
228
 
226
229
  const trackRequest = pathname !== ADAPTER_HEALTH_PATH && pathname !== ADAPTER_STATUS_PATH;
227
- const complete = trackRequest
228
- ? requestMetrics.begin(classifyAdapterRoute(req.method, pathname))
229
- : () => {};
230
+ const routeName = classifyAdapterRoute(req.method, pathname);
231
+ const complete = trackRequest ? requestMetrics.begin(routeName) : () => {};
232
+ const streamPerformance = trackRequest ? streamPerformanceMetrics.begin(routeName) : null;
233
+ const finishRequest = ({ statusCode = 0, aborted = false } = {}) => {
234
+ complete({ statusCode, aborted });
235
+ streamPerformance?.finish({ failed: aborted || statusCode >= 400 });
236
+ };
230
237
  if (trackRequest && typeof res.once === "function") {
231
- res.once("finish", () => complete({ statusCode: res.statusCode }));
232
- res.once("close", () => complete({
238
+ res.once("finish", () => finishRequest({ statusCode: res.statusCode }));
239
+ res.once("close", () => finishRequest({
233
240
  statusCode: res.statusCode,
234
241
  aborted: !res.writableFinished && !res.writableEnded,
235
242
  }));
@@ -240,16 +247,17 @@ export function createAdapterHandler(options = {}) {
240
247
  requestId,
241
248
  pathname,
242
249
  showRequestId: options.showRequestId === true,
250
+ streamPerformance,
243
251
  }, () => dispatch(req, res, pathname));
244
252
  if (result && typeof result.then === "function") {
245
253
  return result.catch((error) => {
246
- complete({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
254
+ finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
247
255
  throw error;
248
256
  });
249
257
  }
250
258
  return result;
251
259
  } catch (error) {
252
- complete({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
260
+ finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
253
261
  throw error;
254
262
  }
255
263
  };
@@ -273,7 +281,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
273
281
  return new Promise((resolve, reject) => {
274
282
  const onListenError = (e) => {
275
283
  if (e?.code === "EADDRINUSE") {
276
- reject(new Error(`Adapter address http://${host}:${port} is already in use. Stop the existing codex-copilot-dx process or set ADAPTER_PORT to another port.`));
284
+ reject(new Error(`Adapter address http://${host}:${port} is already in use. Stop the existing ccdx process or set ADAPTER_PORT to another port.`));
277
285
  return;
278
286
  }
279
287
  reject(e);
@@ -16,6 +16,8 @@ import { resolveAnthropicModel } from "./models.mjs";
16
16
  import { status } from "./status.mjs";
17
17
  import { webStreamLines } from "./stream.mjs";
18
18
  import { endStreamWithError } from "./stream-errors.mjs";
19
+ import { requireUpstreamEventStream, withChatStreamUsage } from "./stream-contract.mjs";
20
+ import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
19
21
  import { recordAnthropicUsage } from "./usage.mjs";
20
22
 
21
23
  export function createAnthropicCountTokensHandler(options) {
@@ -67,15 +69,19 @@ export function createAnthropicMessagesHandler(options) {
67
69
  const chatReq = anthropicToChat(parsed, { upstreamModel });
68
70
  const forceRequestedModel = upstreamModel !== requestedModel;
69
71
  if (parsed.stream) {
70
- const upstream = await chatCompletionsFn({ ...chatReq, stream: true }, { signal: abort.signal });
72
+ const upstream = await chatCompletionsFn(withChatStreamUsage(chatReq), { signal: abort.signal });
71
73
  releaseRequest();
72
74
  if (!upstream.ok) {
73
- if (!res.headersSent) res.writeHead(upstream.status);
74
- res.end(await upstream.text());
75
+ sendUpstreamError(res, upstream, await upstream.text());
75
76
  return;
76
77
  }
78
+ await requireUpstreamEventStream(upstream);
77
79
  abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout");
78
- res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
80
+ res.writeHead(200, {
81
+ ...safeUpstreamResponseHeaders(upstream.headers, { contentType: "text/event-stream" }),
82
+ "Cache-Control": "no-cache",
83
+ Connection: "keep-alive",
84
+ });
79
85
  let messageId;
80
86
  let usage;
81
87
  try {
@@ -114,7 +120,9 @@ export function createAnthropicMessagesHandler(options) {
114
120
  responseId: anthropicMessage.id,
115
121
  usage: anthropicMessage.usage,
116
122
  });
117
- res.writeHead(200, { "Content-Type": "application/json" });
123
+ res.writeHead(200, safeUpstreamResponseHeaders(upstream.headers, {
124
+ contentType: "application/json",
125
+ }));
118
126
  res.end(JSON.stringify(anthropicMessage));
119
127
  }
120
128
  } catch (error) {
package/src/anthropic.mjs CHANGED
@@ -1,4 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import {
3
+ incompleteUpstreamStream,
4
+ invalidUpstreamStream,
5
+ ToolArgumentDeltaGuard,
6
+ upstreamChatStreamError,
7
+ } from "./stream-contract.mjs";
8
+ import { isChatOutputDelta, markFirstOutput, markOutputTokens } from "./stream-performance.mjs";
2
9
 
3
10
  let tokenizerModulePromise = null;
4
11
 
@@ -169,6 +176,14 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
169
176
  let sawToolUse = false;
170
177
  const trailingTextChunks = [];
171
178
  let outputTokens = 0;
179
+ let sawDone = false;
180
+ const toolArgumentGuard = new ToolArgumentDeltaGuard();
181
+ let performanceOutputSeen = false;
182
+ const markPerformanceOutput = () => {
183
+ if (performanceOutputSeen) return;
184
+ performanceOutputSeen = true;
185
+ markFirstOutput();
186
+ };
172
187
 
173
188
  const ensureStart = async () => {
174
189
  if (started) return;
@@ -196,14 +211,30 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
196
211
  for await (const line of lineIterator) {
197
212
  if (!line.startsWith("data: ")) continue;
198
213
  const data = line.slice(6).trim();
199
- if (data === "[DONE]") break;
214
+ if (data === "[DONE]") {
215
+ sawDone = true;
216
+ break;
217
+ }
200
218
  let parsed;
201
- try { parsed = JSON.parse(data); } catch { continue; }
219
+ try {
220
+ parsed = JSON.parse(data);
221
+ } catch {
222
+ throw invalidUpstreamStream(
223
+ "Upstream Chat Completions SSE event contained invalid JSON",
224
+ "upstream_stream_json_invalid",
225
+ );
226
+ }
227
+ const upstreamError = upstreamChatStreamError(parsed);
228
+ if (upstreamError) throw upstreamError;
202
229
  if (!options.forceModel && parsed.model) actualModel = parsed.model;
203
- if (parsed.usage?.completion_tokens != null) outputTokens = parsed.usage.completion_tokens;
230
+ if (parsed.usage?.completion_tokens != null) {
231
+ outputTokens = parsed.usage.completion_tokens;
232
+ markOutputTokens(outputTokens);
233
+ }
204
234
  const choice = parsed.choices?.[0];
205
235
  if (!choice) continue;
206
236
  const delta = choice.delta || {};
237
+ if (isChatOutputDelta(delta)) markPerformanceOutput();
207
238
  await ensureStart();
208
239
 
209
240
  if (delta.content) {
@@ -227,9 +258,11 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
227
258
  await emit("content_block_start", { type: "content_block_start", index: blockIndex,
228
259
  content_block: { type: "tool_use", id: tc.id || `tu_${uid()}`, name: tc.function?.name || "", input: {} } });
229
260
  }
230
- if (tc.function?.arguments) {
261
+ const argumentDelta = tc.function?.arguments;
262
+ toolArgumentGuard.observe(oi, argumentDelta);
263
+ if (argumentDelta) {
231
264
  await emit("content_block_delta", { type: "content_block_delta", index: toolBlocks[oi].anthropicIndex,
232
- delta: { type: "input_json_delta", partial_json: tc.function.arguments } });
265
+ delta: { type: "input_json_delta", partial_json: argumentDelta } });
233
266
  }
234
267
  }
235
268
  }
@@ -237,6 +270,8 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
237
270
  if (choice.finish_reason) finishReason = choice.finish_reason;
238
271
  }
239
272
 
273
+ if (!sawDone) throw incompleteUpstreamStream("[DONE]");
274
+
240
275
  await ensureStart();
241
276
  if (textOpen) await closeText();
242
277
  for (const oi of Object.keys(toolBlocks)) {
package/src/auth.mjs CHANGED
@@ -33,9 +33,9 @@ export function githubTokenMetadataPath(home = os.homedir()) {
33
33
  export function githubReauthMessage(reason, home = os.homedir()) {
34
34
  const tokenPath = githubTokenPath(home);
35
35
  return `${reason}
36
- Delete the saved GitHub token, then run codex-copilot-dx again to log in:
36
+ Delete the saved GitHub token, then run ccdx again to log in:
37
37
  rm '${tokenPath}'
38
- codex-copilot-dx`;
38
+ ccdx`;
39
39
  }
40
40
 
41
41
  // Map GitHub polling responses to a small local state machine.
@@ -0,0 +1,180 @@
1
+ function byteLength(value) {
2
+ return Buffer.byteLength(String(value));
3
+ }
4
+
5
+ function abortError(signal) {
6
+ if (signal?.reason instanceof Error) return signal.reason;
7
+ const error = new Error("The operation was aborted");
8
+ error.name = "AbortError";
9
+ return error;
10
+ }
11
+
12
+ function throwIfAborted(signal) {
13
+ if (signal?.aborted) throw abortError(signal);
14
+ }
15
+
16
+ export function createByteLruCache({ maxBytes }) {
17
+ const limit = Math.max(0, Number.parseInt(maxBytes, 10) || 0);
18
+ const entries = new Map();
19
+ let bytes = 0;
20
+ let hits = 0;
21
+ let misses = 0;
22
+ let evictions = 0;
23
+
24
+ const remove = (key) => {
25
+ const entry = entries.get(key);
26
+ if (!entry) return false;
27
+ entries.delete(key);
28
+ bytes -= entry.bytes;
29
+ return true;
30
+ };
31
+
32
+ return {
33
+ get(key) {
34
+ const entry = entries.get(key);
35
+ if (!entry) {
36
+ misses += 1;
37
+ return undefined;
38
+ }
39
+ hits += 1;
40
+ entries.delete(key);
41
+ entries.set(key, entry);
42
+ return entry.value;
43
+ },
44
+ set(key, value) {
45
+ if (typeof key !== "string" || typeof value !== "string") return false;
46
+ const entry = { value, bytes: byteLength(key) + byteLength(value) };
47
+ if (entry.bytes > limit) return false;
48
+ remove(key);
49
+ entries.set(key, entry);
50
+ bytes += entry.bytes;
51
+ while (bytes > limit && entries.size) {
52
+ remove(entries.keys().next().value);
53
+ evictions += 1;
54
+ }
55
+ return entries.has(key);
56
+ },
57
+ clear() {
58
+ entries.clear();
59
+ bytes = 0;
60
+ hits = 0;
61
+ misses = 0;
62
+ evictions = 0;
63
+ },
64
+ stats() {
65
+ return {
66
+ entries: entries.size,
67
+ bytes,
68
+ max_bytes: limit,
69
+ hits,
70
+ misses,
71
+ evictions,
72
+ };
73
+ },
74
+ };
75
+ }
76
+
77
+ export function createAbortAwareSingleflight() {
78
+ const flights = new Map();
79
+
80
+ const createFlight = (key, task) => {
81
+ const controller = new AbortController();
82
+ const flight = {
83
+ controller,
84
+ settled: false,
85
+ waiters: 0,
86
+ promise: null,
87
+ };
88
+ flight.promise = Promise.resolve()
89
+ .then(() => task(controller.signal))
90
+ .finally(() => {
91
+ flight.settled = true;
92
+ if (flights.get(key) === flight) flights.delete(key);
93
+ });
94
+ // The shared task can outlive every cancelled waiter. Keep its eventual
95
+ // rejection observed even when no caller remains to await it.
96
+ flight.promise.catch(() => {});
97
+ flights.set(key, flight);
98
+ return flight;
99
+ };
100
+
101
+ const waitForFlight = (key, flight, signal) => new Promise((resolve, reject) => {
102
+ let done = false;
103
+ flight.waiters += 1;
104
+
105
+ const finish = () => {
106
+ if (done) return false;
107
+ done = true;
108
+ signal?.removeEventListener("abort", onAbort);
109
+ flight.waiters -= 1;
110
+ if (flight.waiters === 0 && !flight.settled) {
111
+ if (flights.get(key) === flight) flights.delete(key);
112
+ flight.controller.abort(abortError());
113
+ }
114
+ return true;
115
+ };
116
+ const onAbort = () => {
117
+ if (!finish()) return;
118
+ reject(abortError(signal));
119
+ };
120
+
121
+ signal?.addEventListener("abort", onAbort, { once: true });
122
+ if (signal?.aborted) {
123
+ onAbort();
124
+ return;
125
+ }
126
+ flight.promise.then(
127
+ (value) => {
128
+ if (!finish()) return;
129
+ resolve(value);
130
+ },
131
+ (error) => {
132
+ if (!finish()) return;
133
+ reject(error);
134
+ },
135
+ );
136
+ });
137
+
138
+ return {
139
+ run(key, task, { signal } = {}) {
140
+ try {
141
+ throwIfAborted(signal);
142
+ } catch (error) {
143
+ return Promise.reject(error);
144
+ }
145
+ const flight = flights.get(key) || createFlight(key, task);
146
+ return waitForFlight(key, flight, signal);
147
+ },
148
+ stats() {
149
+ return { inflight: flights.size };
150
+ },
151
+ };
152
+ }
153
+
154
+ export function createBoundedResultCache({ maxBytes }) {
155
+ const cache = createByteLruCache({ maxBytes });
156
+ const singleflight = createAbortAwareSingleflight();
157
+
158
+ return {
159
+ async getOrCreate(key, createValue, { signal } = {}) {
160
+ throwIfAborted(signal);
161
+ const cached = cache.get(key);
162
+ if (cached !== undefined) return cached;
163
+ return singleflight.run(key, async (sharedSignal) => {
164
+ const value = await createValue(sharedSignal);
165
+ throwIfAborted(sharedSignal);
166
+ if (typeof value === "string") cache.set(key, value);
167
+ return value;
168
+ }, { signal });
169
+ },
170
+ set(key, value) {
171
+ return cache.set(key, value);
172
+ },
173
+ clear() {
174
+ cache.clear();
175
+ },
176
+ stats() {
177
+ return { ...cache.stats(), ...singleflight.stats() };
178
+ },
179
+ };
180
+ }
@@ -41,7 +41,7 @@ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH, host
41
41
  settings = JSON.parse(raw);
42
42
  } catch (e) {
43
43
  console.log(status("warn", `Could not parse ~/.claude/settings.json: ${e.message}`));
44
- console.log(status("info", "Fix the JSON file and rerun codex-copilot-dx to configure Claude Code automatically."));
44
+ console.log(status("info", "Fix the JSON file and rerun ccdx to configure Claude Code automatically."));
45
45
  return;
46
46
  }
47
47
 
@@ -82,9 +82,11 @@ export function parseRuntimeOptions(env = process.env) {
82
82
 
83
83
  export function cliHelp() {
84
84
  return `Usage:
85
- codex-copilot-dx [--configure-claude-desktop] [--show-request-id]
86
- codex-copilot-dx doctor [--online] [--compat]
87
- codex-copilot-dx usage
88
- codex-copilot-dx --version
89
- codex-copilot-dx --help`;
85
+ ccdx [--configure-claude-desktop] [--show-request-id]
86
+ ccdx doctor [--online] [--compat]
87
+ ccdx usage
88
+ ccdx --version
89
+ ccdx --help
90
+
91
+ Compatibility alias: codex-copilot-dx`;
90
92
  }
package/src/copilot.mjs CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  } from "./github-identity.mjs";
20
20
  import { prepareResponsesPayload, summarizeReqBody } from "./image-optimization.mjs";
21
21
  import { enforceResponsesPayloadByteBudget } from "./responses-byte-budget.mjs";
22
+ import { withChatStreamUsage } from "./stream-contract.mjs";
23
+ import { markUpstreamStarted } from "./stream-performance.mjs";
22
24
 
23
25
  export {
24
26
  optimizeImageDataUrl,
@@ -317,7 +319,7 @@ export function copilotRuntimeStatus(now = Date.now()) {
317
319
  function getGithubToken({ home = os.homedir() } = {}) {
318
320
  const GITHUB_TOKEN_PATH = githubTokenPath(home);
319
321
  if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
320
- throw new Error("GitHub token not found. Run codex-copilot-dx again to log in.");
322
+ throw new Error("GitHub token not found. Run ccdx again to log in.");
321
323
  }
322
324
  const token = fs.readFileSync(GITHUB_TOKEN_PATH, "utf-8").trim();
323
325
  if (!token) throw new Error(githubReauthMessage("GitHub token file is empty."));
@@ -343,7 +345,7 @@ function cacheCopilotTokenData(data, {
343
345
  const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
344
346
  if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
345
347
  && !githubIdentitiesEqual(copilotTokenGithubIdentity, identity)) {
346
- throw new Error(`Refusing to switch GitHub Copilot account from ${githubIdentityLabel(copilotTokenGithubIdentity)} to ${githubIdentityLabel(identity)} while the adapter is running. Restart codex-copilot-dx to switch accounts intentionally.`);
348
+ throw new Error(`Refusing to switch GitHub Copilot account from ${githubIdentityLabel(copilotTokenGithubIdentity)} to ${githubIdentityLabel(identity)} while the adapter is running. Restart ccdx to switch accounts intentionally.`);
347
349
  }
348
350
  if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
349
351
  const error = new Error("The saved GitHub token changed, but its account could not be verified. Keeping the running adapter bound to the existing GitHub account.");
@@ -508,8 +510,11 @@ export async function chatCompletions(chatReq, {
508
510
  bodyText,
509
511
  } = {}) {
510
512
  const token = await getCopilotToken({ signal });
511
- const messages = chatReq.messages || [];
512
- const serializedBody = typeof bodyText === "string" ? bodyText : JSON.stringify(chatReq);
513
+ const upstreamReq = chatReq.stream === true ? withChatStreamUsage(chatReq) : chatReq;
514
+ const messages = upstreamReq.messages || [];
515
+ const serializedBody = typeof bodyText === "string" && upstreamReq === chatReq
516
+ ? bodyText
517
+ : JSON.stringify(upstreamReq);
513
518
  const headers = buildHeaders({
514
519
  token,
515
520
  version: getVSCodeVersion(),
@@ -517,6 +522,7 @@ export async function chatCompletions(chatReq, {
517
522
  vision: computeVision(messages),
518
523
  });
519
524
  headers["Content-Length"] = String(Buffer.byteLength(serializedBody));
525
+ if (upstreamReq.stream === true) markUpstreamStarted();
520
526
  return fetchCopilotUpstream(`${getApiBase()}/chat/completions`, {
521
527
  method: "POST",
522
528
  headers,
@@ -624,6 +630,7 @@ export async function responses(reqBody, {
624
630
  headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
625
631
 
626
632
  try {
633
+ if (reqBody.stream === true) markUpstreamStarted();
627
634
  return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
628
635
  method: "POST",
629
636
  headers,