codex-copilot-dx 0.4.42 → 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
@@ -125,7 +128,7 @@ Environment variables:
125
128
  | `CCDX_MAX_INFLIGHT_BODY_BYTES` | `33554432` | Shared byte budget for admitted request bodies; a larger single request runs exclusively |
126
129
  | `CCDX_MAX_QUEUED_REQUESTS` | `16` | Maximum body requests waiting for the shared byte budget |
127
130
  | `CCDX_REQUEST_QUEUE_TIMEOUT_MS` | `120000` | Maximum wait for request-body admission before returning `503` |
128
- | `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Responses body target; larger image payloads are compressed more aggressively before forwarding |
131
+ | `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Strict maximum forwarded Responses body size; larger payloads are adapted locally or rejected with `413` |
129
132
  | `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
130
133
  | `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
131
134
  | `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
@@ -137,10 +140,11 @@ Environment variables:
137
140
  | `CCDX_LOG_LEVEL` | `info` | Set to `debug` to include upstream request attempts, status codes, retry causes, and timings |
138
141
  | `CCDX_LOG_MAX_BYTES` | `16777216` | Rotate the debug log at this size, retaining one `.1` backup; set to `0` to disable rotation |
139
142
  | `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
140
- | `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
141
- | `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
143
+ | `CCDX_IMG_QUALITY` | `82` | Initial WebP quality used when re-encoding images |
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,17 +174,25 @@ 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
177
181
 
178
- Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `413 Payload Too Large`.
182
+ Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `408 Request Timeout` while reading the body or `413 Payload Too Large`.
179
183
 
180
- The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`. It keeps an existing in-bounds WebP unchanged, never replaces an image with a larger encoding, and applies one global concurrency limit across direct and tool-output images.
184
+ The adapter automatically downsamples embedded screenshots to model-appropriate pixel bounds (with a conservative 2048 px long-edge fallback) and initially re-encodes them as WebP quality 82 before forwarding `/v1/responses`. It never replaces an image with a larger encoding and applies one global concurrency limit across direct, function-tool, and custom-tool output images.
185
+
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.
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.
181
189
 
182
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.
183
191
 
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.
195
+
184
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.
185
197
 
186
198
  ## License
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.42",
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 } : {};
@@ -106,6 +108,7 @@ export function createAdapterHandler(options = {}) {
106
108
  acquireRequest,
107
109
  chatCompletionsFn,
108
110
  openAIModelEnv,
111
+ responsesPayloadOptions: options.responsesPayloadOptions,
109
112
  responsesFn,
110
113
  streamHandshakeTimeoutMs,
111
114
  streamIdleTimeoutMs,
@@ -140,6 +143,7 @@ export function createAdapterHandler(options = {}) {
140
143
  res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
141
144
  res.end(JSON.stringify(runtimeStatusPayload({
142
145
  metrics: requestMetrics,
146
+ streamPerformance: streamPerformanceMetrics,
143
147
  admission: acquireRequest,
144
148
  modelRegistry: options.modelRegistry,
145
149
  })));
@@ -223,12 +227,16 @@ export function createAdapterHandler(options = {}) {
223
227
  }
224
228
 
225
229
  const trackRequest = pathname !== ADAPTER_HEALTH_PATH && pathname !== ADAPTER_STATUS_PATH;
226
- const complete = trackRequest
227
- ? requestMetrics.begin(classifyAdapterRoute(req.method, pathname))
228
- : () => {};
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
+ };
229
237
  if (trackRequest && typeof res.once === "function") {
230
- res.once("finish", () => complete({ statusCode: res.statusCode }));
231
- res.once("close", () => complete({
238
+ res.once("finish", () => finishRequest({ statusCode: res.statusCode }));
239
+ res.once("close", () => finishRequest({
232
240
  statusCode: res.statusCode,
233
241
  aborted: !res.writableFinished && !res.writableEnded,
234
242
  }));
@@ -239,16 +247,17 @@ export function createAdapterHandler(options = {}) {
239
247
  requestId,
240
248
  pathname,
241
249
  showRequestId: options.showRequestId === true,
250
+ streamPerformance,
242
251
  }, () => dispatch(req, res, pathname));
243
252
  if (result && typeof result.then === "function") {
244
253
  return result.catch((error) => {
245
- complete({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
254
+ finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
246
255
  throw error;
247
256
  });
248
257
  }
249
258
  return result;
250
259
  } catch (error) {
251
- complete({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
260
+ finishRequest({ statusCode: res.statusCode >= 400 ? res.statusCode : 500 });
252
261
  throw error;
253
262
  }
254
263
  };
@@ -272,7 +281,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
272
281
  return new Promise((resolve, reject) => {
273
282
  const onListenError = (e) => {
274
283
  if (e?.code === "EADDRINUSE") {
275
- 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.`));
276
285
  return;
277
286
  }
278
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
  }