codex-copilot-dx 0.6.19 → 0.6.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.19",
3
+ "version": "0.6.20",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -12,7 +12,7 @@ import {
12
12
  normalizeGithubIdentity,
13
13
  } from "./github-identity.mjs";
14
14
  import { withFileLock } from "./lock.mjs";
15
- import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
15
+ import { parseSafePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
16
16
 
17
17
  export const AUTH_PROFILE_CODEX = "codex";
18
18
  export const AUTH_PROFILE_CLAUDE = "claude";
@@ -117,8 +117,8 @@ export function readAuthProfileCredentials(profile, { home = os.homedir() } = {}
117
117
 
118
118
  function lockOptions(env = process.env) {
119
119
  return {
120
- timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
121
- staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
120
+ timeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
121
+ staleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
122
122
  };
123
123
  }
124
124
 
package/src/auth.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  githubTokenFingerprint,
9
9
  normalizeGithubIdentity,
10
10
  } from "./github-identity.mjs";
11
- import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
11
+ import { parseSafePositiveInteger, parseTimerMs, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
12
12
  import { status } from "./status.mjs";
13
13
  import { withFileLock } from "./lock.mjs";
14
14
 
@@ -20,6 +20,7 @@ const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
20
20
  const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
21
21
  const DEFAULT_DEVICE_EXPIRES_SECONDS = 900;
22
22
  const DEFAULT_DEVICE_CODE_TIMEOUT_MS = 30 * 1000;
23
+ const DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS = 30 * 1000;
23
24
  const MAX_DEVICE_FLOW_JSON_BYTES = 64 * 1024;
24
25
  const DISABLE_TOKEN_DISCOVERY_VALUES = new Set(["1", "true", "yes"]);
25
26
  const MAX_AUTH_JSON_BYTES = 1024 * 1024;
@@ -258,8 +259,8 @@ function isTokenDiscoveryDisabled(env = process.env) {
258
259
 
259
260
  function tokenLockOptions(env = process.env) {
260
261
  return {
261
- timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
262
- staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
262
+ timeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
263
+ staleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
263
264
  };
264
265
  }
265
266
 
@@ -425,19 +426,47 @@ function expectedGithubIdentity(home, env, token) {
425
426
  return token ? readGithubTokenMetadata(home, token) : null;
426
427
  }
427
428
 
428
- export async function fetchGithubIdentity(token, { fetchImpl = fetch, signal } = {}) {
429
+ function githubIdentityTimeoutError(timeoutMs) {
430
+ const error = new Error(`GitHub identity request timed out after ${timeoutMs}ms`);
431
+ error.code = "CCDX_GITHUB_IDENTITY_TIMEOUT";
432
+ return error;
433
+ }
434
+
435
+ export async function fetchGithubIdentity(token, {
436
+ fetchImpl = fetch,
437
+ signal,
438
+ timeoutMs = DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS,
439
+ } = {}) {
429
440
  if (typeof token !== "string" || !token.trim()) return { ok: false, reason: "empty_token" };
441
+ const deadlineMs = parseTimerMs(timeoutMs, DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS);
442
+ const controller = new AbortController();
443
+ const onCallerAbort = () => controller.abort(signal?.reason);
444
+ signal?.addEventListener("abort", onCallerAbort, { once: true });
445
+ if (signal?.aborted) onCallerAbort();
446
+ const timer = setTimeout(() => controller.abort(githubIdentityTimeoutError(deadlineMs)), deadlineMs);
447
+ let resp;
430
448
  try {
431
- const resp = await fetchImpl(`${GITHUB_API}/user`, {
449
+ resp = await awaitWithSignal(() => fetchImpl(`${GITHUB_API}/user`, {
432
450
  headers: { Authorization: `token ${token.trim()}`, Accept: "application/json" },
433
- signal,
434
- });
451
+ signal: controller.signal,
452
+ }), controller.signal);
435
453
  if (!resp.ok) return { ok: false, status: resp.status, reason: "github_user_failed" };
436
- const data = await resp.json();
454
+ const data = await awaitWithSignal(() => resp.json(), controller.signal);
437
455
  const identity = normalizeGithubIdentity(data);
438
456
  return identity ? { ok: true, ...identity } : { ok: false, reason: "github_identity_missing" };
439
457
  } catch (error) {
440
- return { ok: false, transient: true, reason: "github_user_request_failed", error };
458
+ cancelResponseBody(resp);
459
+ const timedOut = controller.signal.aborted && !signal?.aborted
460
+ && controller.signal.reason?.code === "CCDX_GITHUB_IDENTITY_TIMEOUT";
461
+ return {
462
+ ok: false,
463
+ transient: true,
464
+ reason: timedOut ? "github_user_timeout" : "github_user_request_failed",
465
+ error,
466
+ };
467
+ } finally {
468
+ clearTimeout(timer);
469
+ signal?.removeEventListener("abort", onCallerAbort);
441
470
  }
442
471
  }
443
472
 
@@ -661,6 +690,7 @@ export async function ensureAuth({
661
690
  log = console.log,
662
691
  openAndCopyFn = openAndCopy,
663
692
  deviceCodeTimeoutMs = DEFAULT_DEVICE_CODE_TIMEOUT_MS,
693
+ githubIdentityTimeoutMs = DEFAULT_GITHUB_IDENTITY_TIMEOUT_MS,
664
694
  sleepImpl = sleep,
665
695
  now = Date.now,
666
696
  } = {}) {
@@ -720,7 +750,11 @@ export async function ensureAuth({
720
750
  now,
721
751
  });
722
752
  if (result.state === "done") {
723
- const identity = await fetchGithubIdentity(result.token, { fetchImpl, signal });
753
+ const identity = await fetchGithubIdentity(result.token, {
754
+ fetchImpl,
755
+ signal,
756
+ timeoutMs: githubIdentityTimeoutMs,
757
+ });
724
758
  writeToken(result.token, home, identity.ok ? identity : null);
725
759
  log(status("ok", "Login successful"));
726
760
  return;
@@ -12,12 +12,12 @@ import {
12
12
  readUserSettings,
13
13
  writeAutoReviewModel,
14
14
  } from "./user-settings.mjs";
15
+ import { parseTimerMs } from "./runtime-config.mjs";
15
16
 
16
17
  const DEFAULT_MODEL_FETCH_TIMEOUT_MS = 5000;
17
18
 
18
19
  function modelFetchTimeout(env) {
19
- const value = Number(env.CCDX_MODEL_REFRESH_TIMEOUT_MS);
20
- return Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_MODEL_FETCH_TIMEOUT_MS;
20
+ return parseTimerMs(env.CCDX_MODEL_REFRESH_TIMEOUT_MS, DEFAULT_MODEL_FETCH_TIMEOUT_MS);
21
21
  }
22
22
 
23
23
  function sortedModelIds(modelIds, currentModel) {
package/src/cli-auth.mjs CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  withAuthProfileLock,
16
16
  writeClaudeAuthProfile,
17
17
  } from "./auth-profile.mjs";
18
- import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
18
+ import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
19
19
  import {
20
20
  githubIdentitiesEqual,
21
21
  githubTokenFingerprint,
@@ -178,7 +178,7 @@ async function fetchCopilotCatalog(tokenData, {
178
178
  const timer = setTimeout(() => controller.abort(catalogTimeoutError(label, deadlineMs)), deadlineMs);
179
179
  let modelResponse;
180
180
  try {
181
- const apiBase = tokenData?.endpoints?.api || DEFAULT_API_BASE;
181
+ const apiBase = parseApiBase(tokenData);
182
182
  const headers = buildHeaders({
183
183
  token: tokenData.token,
184
184
  version: vscodeVersion,
package/src/cli-error.mjs CHANGED
@@ -22,7 +22,13 @@ function boundedTerminalText(value, fallback, maxCharacters) {
22
22
  }
23
23
 
24
24
  export function formatCliError(error) {
25
- const message = boundedTerminalText(error?.message, "Command failed", MAX_MESSAGE_CHARACTERS);
25
+ let rawMessage;
26
+ try {
27
+ rawMessage = error?.message;
28
+ } catch {
29
+ rawMessage = undefined;
30
+ }
31
+ const message = boundedTerminalText(rawMessage, "Command failed", MAX_MESSAGE_CHARACTERS);
26
32
  let details;
27
33
  try {
28
34
  details = error?.details;
@@ -5,7 +5,7 @@ import {
5
5
  AUTH_PROFILE_CODEX,
6
6
  readAuthProfileCredentials,
7
7
  } from "./auth-profile.mjs";
8
- import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
8
+ import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
9
9
  import {
10
10
  isClaudeCopilotCatalogEntry,
11
11
  isClaudeCopilotModel,
@@ -134,7 +134,7 @@ export async function fetchLiveCopilotModels({
134
134
  if (!validation.ok) throw new Error(validationFailure(validation));
135
135
 
136
136
  const tokenData = validation.copilotTokenData;
137
- const apiBase = tokenData.endpoints?.api || DEFAULT_API_BASE;
137
+ const apiBase = parseApiBase(tokenData);
138
138
  const headers = buildHeaders({
139
139
  token: tokenData.token,
140
140
  version: vscodeVersion,
@@ -1,3 +1,5 @@
1
+ import { MAX_TIMER_DELAY_MS } from "./runtime-config.mjs";
2
+
1
3
  const HELP_COMMANDS = new Set(["help", "--help", "-h"]);
2
4
  const VERSION_COMMANDS = new Set(["version", "--version", "-v"]);
3
5
  const START_OPTIONS = new Map([
@@ -262,13 +264,13 @@ export function parseRuntimeOptions(env = process.env) {
262
264
  return {
263
265
  adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
264
266
  adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
265
- modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1 }),
266
- existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
267
- modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0 }),
268
- upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1 }),
269
- streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1 }),
270
- streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1 }),
271
- shutdownTimeoutMs: integerEnv(env, "CCDX_SHUTDOWN_TIMEOUT_MS", 5000, { min: 1 }),
267
+ modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1, max: MAX_TIMER_DELAY_MS }),
268
+ existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1, max: MAX_TIMER_DELAY_MS }),
269
+ modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0, max: MAX_TIMER_DELAY_MS }),
270
+ upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
271
+ streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
272
+ streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1, max: MAX_TIMER_DELAY_MS }),
273
+ shutdownTimeoutMs: integerEnv(env, "CCDX_SHUTDOWN_TIMEOUT_MS", 5000, { min: 1, max: MAX_TIMER_DELAY_MS }),
272
274
  };
273
275
  }
274
276
 
@@ -276,7 +278,7 @@ export function parseAdapterProbeOptions(env = process.env) {
276
278
  return {
277
279
  adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
278
280
  adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
279
- existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
281
+ existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1, max: MAX_TIMER_DELAY_MS }),
280
282
  };
281
283
  }
282
284
 
@@ -60,6 +60,20 @@ function refreshError(message, { statusCode, transient = false } = {}) {
60
60
  return error;
61
61
  }
62
62
 
63
+ function transientRefreshError(error) {
64
+ let message = "Copilot token request failed";
65
+ try {
66
+ if (typeof error?.message === "string" && error.message) message = error.message;
67
+ } catch {}
68
+ const wrapped = new Error(message, { cause: error });
69
+ try {
70
+ if (typeof error?.code === "string") wrapped.code = error.code;
71
+ if (Number.isInteger(error?.statusCode)) wrapped.statusCode = error.statusCode;
72
+ } catch {}
73
+ wrapped.transient = true;
74
+ return wrapped;
75
+ }
76
+
63
77
  function copilotProfileLabel(profile) {
64
78
  if (profile === "codex") return "Copilot";
65
79
  const name = String(profile || "profile");
@@ -189,6 +203,7 @@ export function createCopilotTokenSession({
189
203
  allowAccountSwitch = false,
190
204
  }) {
191
205
  if (!data.token) throw new Error("Copilot token response missing token field");
206
+ const nextApiBase = parseApiBase(data);
192
207
  const fingerprint = githubTokenFingerprint(githubToken);
193
208
  const normalizedIdentity = normalizeGithubIdentity(identity);
194
209
  const tokenChanged = Boolean(githubFingerprint && fingerprint !== githubFingerprint);
@@ -204,7 +219,7 @@ export function createCopilotTokenSession({
204
219
  throw error;
205
220
  }
206
221
  copilotToken = data.token;
207
- apiBase = parseApiBase(data);
222
+ apiBase = nextApiBase;
208
223
  copilotTokenExpiry = typeof data.expires_at === "number"
209
224
  ? data.expires_at * 1000
210
225
  : Date.now() + 25 * 60 * 1000;
@@ -232,7 +247,7 @@ export function createCopilotTokenSession({
232
247
  signal,
233
248
  }, { fetchImpl, ...tokenRetryOptions });
234
249
  } catch (error) {
235
- if (!isAbortError(error, signal)) error.transient = true;
250
+ if (!isAbortError(error, signal)) throw transientRefreshError(error);
236
251
  throw error;
237
252
  }
238
253
  if (!response.ok) {
package/src/copilot.mjs CHANGED
@@ -33,9 +33,28 @@ export function parseUpstreamRetryDelayMs(value) {
33
33
  }
34
34
 
35
35
  export function parseApiBase(data) {
36
- return (data && data.endpoints && typeof data.endpoints.api === "string" && data.endpoints.api)
37
- ? data.endpoints.api
38
- : DEFAULT_API_BASE;
36
+ const configured = data?.endpoints?.api;
37
+ if (typeof configured !== "string" || !configured.trim()) return DEFAULT_API_BASE;
38
+ let parsed;
39
+ try {
40
+ parsed = new URL(configured.trim());
41
+ } catch {
42
+ throw Object.assign(new Error("Copilot API endpoint is invalid"), {
43
+ code: "CCDX_INVALID_COPILOT_API_ENDPOINT",
44
+ });
45
+ }
46
+ if (parsed.protocol !== "https:"
47
+ || !parsed.hostname
48
+ || parsed.username
49
+ || parsed.password
50
+ || parsed.search
51
+ || parsed.hash) {
52
+ throw Object.assign(new Error("Copilot API endpoint is invalid"), {
53
+ code: "CCDX_INVALID_COPILOT_API_ENDPOINT",
54
+ });
55
+ }
56
+ const pathname = parsed.pathname.replace(/\/+$/, "");
57
+ return `${parsed.origin}${pathname && pathname !== "/" ? pathname : ""}`;
39
58
  }
40
59
 
41
60
  export function responsesEndpointPath() {
package/src/doctor.mjs CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  } from "./auth-profile.mjs";
11
11
  import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
12
12
  import { status } from "./status.mjs";
13
- import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
13
+ import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
14
14
  import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
15
15
  import { CODEX_AUTO_REVIEW_MODEL, isClaudeCopilotModel } from "./models.mjs";
16
16
  import { loadModelCache } from "./model-cache.mjs";
@@ -322,7 +322,7 @@ export async function inspectGitHubTokenOnline({
322
322
  }
323
323
 
324
324
  const tokenData = validation.copilotTokenData;
325
- const apiBase = tokenData.endpoints?.api || DEFAULT_API_BASE;
325
+ const apiBase = parseApiBase(tokenData);
326
326
  const headers = buildHeaders({
327
327
  token: tokenData.token,
328
328
  version: FALLBACK_VSCODE_VERSION,
@@ -407,6 +407,9 @@ export function createRequestAdmission({
407
407
  continue;
408
408
  }
409
409
  if (activeBytes + entry.weight > byteLimit) {
410
+ // Unknown-length requests reserve the whole budget. Once one is waiting,
411
+ // do not let later arrivals keep extending its wait indefinitely.
412
+ if (entry.weight === byteLimit) break;
410
413
  index += 1;
411
414
  continue;
412
415
  }
@@ -422,11 +425,13 @@ export function createRequestAdmission({
422
425
  return Promise.reject(admissionAbortError(signal));
423
426
  }
424
427
  const weight = requestAdmissionWeight(req, byteLimit);
425
- if (queue.length >= queueLimit && activeBytes + weight > byteLimit) {
428
+ const blockedByExclusive = queue.some((entry) => !entry.cancelled && entry.weight === byteLimit);
429
+ const mustWait = blockedByExclusive || activeBytes + weight > byteLimit;
430
+ if (queue.length >= queueLimit && mustWait) {
426
431
  counters.rejected += 1;
427
432
  return Promise.reject(httpError(`Request queue is full (${queueLimit} waiting)`, 503));
428
433
  }
429
- if (activeBytes + weight > byteLimit) counters.queued += 1;
434
+ if (mustWait) counters.queued += 1;
430
435
 
431
436
  return new Promise((resolve, reject) => {
432
437
  let timer;
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { sha256Hex } from "./pm-studio-asar.mjs";
5
+
6
+ const BUNDLE_CONTENT_SCHEME = "ccdx-bundle-content-v2";
7
+
8
+ function setupError(code, message, details) {
9
+ const error = new Error(message);
10
+ error.code = code;
11
+ if (details !== undefined) error.details = details;
12
+ return error;
13
+ }
14
+
15
+ function hashRegularFile(filePath, expected, readBuffer) {
16
+ const hash = createHash("sha256");
17
+ const file = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
18
+ const buffer = readBuffer || Buffer.allocUnsafe(1024 * 1024);
19
+ try {
20
+ const before = fs.fstatSync(file, { bigint: true });
21
+ if (before.dev !== BigInt(expected.dev)
22
+ || before.ino !== BigInt(expected.ino)
23
+ || before.size !== BigInt(expected.size)) {
24
+ throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while reading ${filePath}`);
25
+ }
26
+ for (;;) {
27
+ const length = fs.readSync(file, buffer, 0, buffer.length, null);
28
+ if (length === 0) break;
29
+ hash.update(buffer.subarray(0, length));
30
+ }
31
+ const after = fs.fstatSync(file, { bigint: true });
32
+ if (before.dev !== after.dev
33
+ || before.ino !== after.ino
34
+ || before.size !== after.size
35
+ || before.mtimeNs !== after.mtimeNs
36
+ || before.ctimeNs !== after.ctimeNs) {
37
+ throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while hashing ${filePath}`);
38
+ }
39
+ } finally {
40
+ fs.closeSync(file);
41
+ }
42
+ return hash.digest("hex");
43
+ }
44
+
45
+ function parseXattrHex(output, root, ignoredXattrs) {
46
+ const records = [];
47
+ const ignored = new Set(ignoredXattrs);
48
+ const seen = new Set();
49
+ let current = null;
50
+ const flush = () => {
51
+ if (!current) return;
52
+ if (!ignored.has(current.name)) {
53
+ const key = `${current.path}\0${current.name}`;
54
+ if (seen.has(key)) throw setupError("PM_STUDIO_XATTR_INVALID", `Duplicate extended attribute ${current.name}`);
55
+ seen.add(key);
56
+ const value = Buffer.from(current.hex.join(""), "hex");
57
+ records.push(["xattr", current.path, current.name, value.length, sha256Hex(value)]);
58
+ }
59
+ current = null;
60
+ };
61
+
62
+ for (const line of String(output || "").split(/\r?\n/)) {
63
+ if (!line) continue;
64
+ const separator = line.lastIndexOf(": ");
65
+ if ((line === `${root}:` || line.startsWith(`${root}: `) || line.startsWith(`${root}${path.sep}`))
66
+ && separator >= root.length && line.endsWith(":")) {
67
+ flush();
68
+ const target = line.slice(0, separator);
69
+ const relative = target === root ? "." : path.relative(root, target).split(path.sep).join("/");
70
+ if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
71
+ throw setupError("PM_STUDIO_XATTR_INVALID", `Extended attribute path escapes PM Studio: ${target}`);
72
+ }
73
+ current = { path: relative, name: line.slice(separator + 2, -1), hex: [] };
74
+ continue;
75
+ }
76
+ if (!current) throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attributes");
77
+ const data = line.includes(" |") ? line.slice(0, line.indexOf(" |")) : line;
78
+ const tokens = data.trim().split(/\s+/);
79
+ if (!/^[0-9a-f]{8}$/i.test(tokens[0]) || tokens.slice(1).some((token) => !/^[0-9a-f]{2}$/i.test(token))) {
80
+ throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attribute bytes");
81
+ }
82
+ current.hex.push(...tokens.slice(1));
83
+ }
84
+ flush();
85
+ return records.sort((left, right) => Buffer.compare(
86
+ Buffer.from(`${left[1]}\0${left[2]}`),
87
+ Buffer.from(`${right[1]}\0${right[2]}`),
88
+ ));
89
+ }
90
+
91
+ export function inspectBundleContent(appPath, {
92
+ xattrOutput = "",
93
+ ignoredXattrs = [],
94
+ readBuffer,
95
+ } = {}) {
96
+ const root = path.resolve(appPath);
97
+ const rootStat = fs.lstatSync(root);
98
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
99
+ throw setupError("PM_STUDIO_APP_INVALID", `PM Studio path is not a regular app bundle: ${appPath}`);
100
+ }
101
+ const records = [];
102
+ const normalizedPaths = new Map();
103
+ let regularFileCount = 0;
104
+ let regularBytes = 0;
105
+ let symlinkCount = 0;
106
+
107
+ const rememberPath = (relative) => {
108
+ const normalized = relative.normalize("NFC");
109
+ const existing = normalizedPaths.get(normalized);
110
+ if (existing && existing !== relative) {
111
+ throw setupError("PM_STUDIO_BUNDLE_PATH_COLLISION", `PM Studio contains colliding paths: ${existing} and ${relative}`);
112
+ }
113
+ normalizedPaths.set(normalized, relative);
114
+ };
115
+ rememberPath(".");
116
+ records.push(["directory", ".", rootStat.mode & 0o7777]);
117
+ const visit = (directory) => {
118
+ const names = fs.readdirSync(directory).sort((left, right) =>
119
+ Buffer.compare(Buffer.from(left), Buffer.from(right)));
120
+ for (const name of names) {
121
+ const fullPath = path.join(directory, name);
122
+ const stat = fs.lstatSync(fullPath);
123
+ const relative = path.relative(root, fullPath).split(path.sep).join("/");
124
+ const mode = stat.mode & 0o7777;
125
+ rememberPath(relative);
126
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
127
+ records.push(["directory", relative, mode]);
128
+ visit(fullPath);
129
+ } else if (stat.isSymbolicLink()) {
130
+ const target = fs.readlinkSync(fullPath);
131
+ const resolved = path.resolve(path.dirname(fullPath), target);
132
+ if (path.isAbsolute(target)
133
+ || (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
134
+ || !fs.existsSync(resolved)) {
135
+ throw setupError("PM_STUDIO_BUNDLE_SYMLINK_INVALID", `PM Studio contains an unsafe symlink: ${relative}`);
136
+ }
137
+ symlinkCount += 1;
138
+ records.push(["symlink", relative, mode, target]);
139
+ } else if (stat.isFile()) {
140
+ if (stat.nlink !== 1) {
141
+ throw setupError("PM_STUDIO_BUNDLE_HARDLINK_INVALID", `PM Studio contains a hard-linked file: ${relative}`);
142
+ }
143
+ regularFileCount += 1;
144
+ regularBytes += stat.size;
145
+ records.push(["file", relative, mode, stat.size, hashRegularFile(fullPath, stat, readBuffer)]);
146
+ } else {
147
+ throw setupError("PM_STUDIO_BUNDLE_ENTRY_INVALID", `PM Studio contains an unsupported entry: ${relative}`);
148
+ }
149
+ }
150
+ };
151
+ visit(root);
152
+
153
+ const xattrRecords = parseXattrHex(xattrOutput, root, ignoredXattrs);
154
+ const hash = createHash("sha256");
155
+ for (const record of [...records, ...xattrRecords]) hash.update(`${JSON.stringify(record)}\n`);
156
+ return {
157
+ scheme: BUNDLE_CONTENT_SCHEME,
158
+ sha256: hash.digest("hex"),
159
+ entryCount: records.length,
160
+ regularFileCount,
161
+ regularBytes,
162
+ symlinkCount,
163
+ xattrCount: xattrRecords.length,
164
+ };
165
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { createHash, randomUUID } from "node:crypto";
4
+ import { randomUUID } from "node:crypto";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import {
7
7
  AUTH_PROFILE_CLAUDE,
@@ -20,12 +20,14 @@ import {
20
20
  patchAsarBuffer,
21
21
  sha256Hex,
22
22
  } from "./pm-studio-asar.mjs";
23
+ import { inspectBundleContent } from "./pm-studio-bundle-content.mjs";
23
24
  import { withPmStudioSetupLock } from "./pm-studio-setup-lock.mjs";
24
25
 
26
+ export { inspectBundleContent } from "./pm-studio-bundle-content.mjs";
27
+
25
28
  export const DEFAULT_PM_STUDIO_APP_PATH = "/Applications/PM Studio.app";
26
29
  export const PM_STUDIO_CLAUDE_AUTH_COMMAND = "ccdx auth login claude --reauth --github-login <personal-login>";
27
30
  const MIN_FREE_MARGIN_BYTES = 16 * 1024 * 1024;
28
- const BUNDLE_CONTENT_SCHEME = "ccdx-bundle-content-v2";
29
31
 
30
32
  function setupError(code, message, details) {
31
33
  const error = new Error(message);
@@ -60,158 +62,6 @@ function runChecked(processRunner, command, args, label) {
60
62
  return result;
61
63
  }
62
64
 
63
- function hashRegularFile(filePath, expected, readBuffer) {
64
- const hash = createHash("sha256");
65
- const file = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
66
- const buffer = readBuffer || Buffer.allocUnsafe(1024 * 1024);
67
- try {
68
- const before = fs.fstatSync(file, { bigint: true });
69
- if (before.dev !== BigInt(expected.dev)
70
- || before.ino !== BigInt(expected.ino)
71
- || before.size !== BigInt(expected.size)) {
72
- throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while reading ${filePath}`);
73
- }
74
- for (;;) {
75
- const length = fs.readSync(file, buffer, 0, buffer.length, null);
76
- if (length === 0) break;
77
- hash.update(buffer.subarray(0, length));
78
- }
79
- const after = fs.fstatSync(file, { bigint: true });
80
- if (before.dev !== after.dev
81
- || before.ino !== after.ino
82
- || before.size !== after.size
83
- || before.mtimeNs !== after.mtimeNs
84
- || before.ctimeNs !== after.ctimeNs) {
85
- throw setupError("PM_STUDIO_BUNDLE_CHANGED", `PM Studio bundle changed while hashing ${filePath}`);
86
- }
87
- } finally {
88
- fs.closeSync(file);
89
- }
90
- return hash.digest("hex");
91
- }
92
-
93
- function parseXattrHex(output, root, ignoredXattrs) {
94
- const records = [];
95
- const ignored = new Set(ignoredXattrs);
96
- const seen = new Set();
97
- let current = null;
98
- const flush = () => {
99
- if (!current) return;
100
- if (!ignored.has(current.name)) {
101
- const key = `${current.path}\0${current.name}`;
102
- if (seen.has(key)) throw setupError("PM_STUDIO_XATTR_INVALID", `Duplicate extended attribute ${current.name}`);
103
- seen.add(key);
104
- const value = Buffer.from(current.hex.join(""), "hex");
105
- records.push(["xattr", current.path, current.name, value.length, sha256Hex(value)]);
106
- }
107
- current = null;
108
- };
109
-
110
- for (const line of String(output || "").split(/\r?\n/)) {
111
- if (!line) continue;
112
- const separator = line.lastIndexOf(": ");
113
- if ((line === `${root}:` || line.startsWith(`${root}: `) || line.startsWith(`${root}${path.sep}`))
114
- && separator >= root.length && line.endsWith(":")) {
115
- flush();
116
- const target = line.slice(0, separator);
117
- const relative = target === root ? "." : path.relative(root, target).split(path.sep).join("/");
118
- if (!relative || relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
119
- throw setupError("PM_STUDIO_XATTR_INVALID", `Extended attribute path escapes PM Studio: ${target}`);
120
- }
121
- current = { path: relative, name: line.slice(separator + 2, -1), hex: [] };
122
- continue;
123
- }
124
- if (!current) throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attributes");
125
- const data = line.includes(" |") ? line.slice(0, line.indexOf(" |")) : line;
126
- const tokens = data.trim().split(/\s+/);
127
- if (!/^[0-9a-f]{8}$/i.test(tokens[0]) || tokens.slice(1).some((token) => !/^[0-9a-f]{2}$/i.test(token))) {
128
- throw setupError("PM_STUDIO_XATTR_INVALID", "Could not parse PM Studio extended attribute bytes");
129
- }
130
- current.hex.push(...tokens.slice(1));
131
- }
132
- flush();
133
- return records.sort((left, right) => Buffer.compare(
134
- Buffer.from(`${left[1]}\0${left[2]}`),
135
- Buffer.from(`${right[1]}\0${right[2]}`),
136
- ));
137
- }
138
-
139
- export function inspectBundleContent(appPath, {
140
- xattrOutput = "",
141
- ignoredXattrs = [],
142
- readBuffer,
143
- } = {}) {
144
- const root = path.resolve(appPath);
145
- const rootStat = fs.lstatSync(root);
146
- if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
147
- throw setupError("PM_STUDIO_APP_INVALID", `PM Studio path is not a regular app bundle: ${appPath}`);
148
- }
149
- const records = [];
150
- const normalizedPaths = new Map();
151
- let regularFileCount = 0;
152
- let regularBytes = 0;
153
- let symlinkCount = 0;
154
-
155
- const rememberPath = (relative) => {
156
- const normalized = relative.normalize("NFC");
157
- const existing = normalizedPaths.get(normalized);
158
- if (existing && existing !== relative) {
159
- throw setupError("PM_STUDIO_BUNDLE_PATH_COLLISION", `PM Studio contains colliding paths: ${existing} and ${relative}`);
160
- }
161
- normalizedPaths.set(normalized, relative);
162
- };
163
- rememberPath(".");
164
- records.push(["directory", ".", rootStat.mode & 0o7777]);
165
- const visit = (directory) => {
166
- const names = fs.readdirSync(directory).sort((left, right) =>
167
- Buffer.compare(Buffer.from(left), Buffer.from(right)));
168
- for (const name of names) {
169
- const fullPath = path.join(directory, name);
170
- const stat = fs.lstatSync(fullPath);
171
- const relative = path.relative(root, fullPath).split(path.sep).join("/");
172
- const mode = stat.mode & 0o7777;
173
- rememberPath(relative);
174
- if (stat.isDirectory() && !stat.isSymbolicLink()) {
175
- records.push(["directory", relative, mode]);
176
- visit(fullPath);
177
- } else if (stat.isSymbolicLink()) {
178
- const target = fs.readlinkSync(fullPath);
179
- const resolved = path.resolve(path.dirname(fullPath), target);
180
- if (path.isAbsolute(target)
181
- || (resolved !== root && !resolved.startsWith(`${root}${path.sep}`))
182
- || !fs.existsSync(resolved)) {
183
- throw setupError("PM_STUDIO_BUNDLE_SYMLINK_INVALID", `PM Studio contains an unsafe symlink: ${relative}`);
184
- }
185
- symlinkCount += 1;
186
- records.push(["symlink", relative, mode, target]);
187
- } else if (stat.isFile()) {
188
- if (stat.nlink !== 1) {
189
- throw setupError("PM_STUDIO_BUNDLE_HARDLINK_INVALID", `PM Studio contains a hard-linked file: ${relative}`);
190
- }
191
- regularFileCount += 1;
192
- regularBytes += stat.size;
193
- records.push(["file", relative, mode, stat.size, hashRegularFile(fullPath, stat, readBuffer)]);
194
- } else {
195
- throw setupError("PM_STUDIO_BUNDLE_ENTRY_INVALID", `PM Studio contains an unsupported entry: ${relative}`);
196
- }
197
- }
198
- };
199
- visit(root);
200
-
201
- const xattrRecords = parseXattrHex(xattrOutput, root, ignoredXattrs);
202
- const hash = createHash("sha256");
203
- for (const record of [...records, ...xattrRecords]) hash.update(`${JSON.stringify(record)}\n`);
204
- return {
205
- scheme: BUNDLE_CONTENT_SCHEME,
206
- sha256: hash.digest("hex"),
207
- entryCount: records.length,
208
- regularFileCount,
209
- regularBytes,
210
- symlinkCount,
211
- xattrCount: xattrRecords.length,
212
- };
213
- }
214
-
215
65
  function defaultInspectBundleContent({ appPath, recipe, processRunner, readBuffer }) {
216
66
  const xattrs = runChecked(processRunner, "/usr/bin/xattr", ["-r", "-s", "-x", "-l", appPath],
217
67
  "Reading PM Studio extended attributes");
@@ -17,17 +17,33 @@ export const RUNTIME_DEFAULTS = Object.freeze({
17
17
  tokenLockStaleMs: 15 * 60 * 1000,
18
18
  });
19
19
 
20
+ // Node clamps larger timer delays to 1ms. Keep timer parsing separate from
21
+ // byte/count limits so valid large resource budgets remain configurable.
22
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
23
+
20
24
  export function parsePositiveInteger(value, fallback, max = Number.POSITIVE_INFINITY) {
21
25
  const parsed = Number.parseInt(value, 10);
22
26
  return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback;
23
27
  }
24
28
 
29
+ export function parseSafePositiveInteger(value, fallback) {
30
+ const text = typeof value === "number" ? String(value) : String(value ?? "").trim();
31
+ if (!/^\d+$/.test(text)) return fallback;
32
+ const parsed = Number(text);
33
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback;
34
+ return parsed;
35
+ }
36
+
37
+ export function parseTimerMs(value, fallback) {
38
+ return Math.min(parseSafePositiveInteger(value, fallback), MAX_TIMER_DELAY_MS);
39
+ }
40
+
25
41
  export function loadRuntimeConfig(env = process.env) {
26
42
  return Object.freeze({
27
- upstreamTimeoutMs: parsePositiveInteger(env.CCDX_UPSTREAM_TIMEOUT_MS, RUNTIME_DEFAULTS.upstreamTimeoutMs),
28
- streamHandshakeTimeoutMs: parsePositiveInteger(env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamHandshakeTimeoutMs),
29
- streamIdleTimeoutMs: parsePositiveInteger(env.CCDX_STREAM_IDLE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamIdleTimeoutMs),
30
- requestBodyTimeoutMs: parsePositiveInteger(env.CCDX_REQUEST_BODY_TIMEOUT_MS, RUNTIME_DEFAULTS.requestBodyTimeoutMs),
43
+ upstreamTimeoutMs: parseTimerMs(env.CCDX_UPSTREAM_TIMEOUT_MS, RUNTIME_DEFAULTS.upstreamTimeoutMs),
44
+ streamHandshakeTimeoutMs: parseTimerMs(env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamHandshakeTimeoutMs),
45
+ streamIdleTimeoutMs: parseTimerMs(env.CCDX_STREAM_IDLE_TIMEOUT_MS, RUNTIME_DEFAULTS.streamIdleTimeoutMs),
46
+ requestBodyTimeoutMs: parseTimerMs(env.CCDX_REQUEST_BODY_TIMEOUT_MS, RUNTIME_DEFAULTS.requestBodyTimeoutMs),
31
47
  maxSseBufferBytes: parsePositiveInteger(env.CCDX_MAX_SSE_BUFFER_BYTES, RUNTIME_DEFAULTS.maxSseBufferBytes),
32
48
  maxBodyBytes: parsePositiveInteger(env.CCDX_MAX_BODY_BYTES, RUNTIME_DEFAULTS.maxBodyBytes),
33
49
  maxDecodedBodyBytes: parsePositiveInteger(env.CCDX_MAX_DECODED_BODY_BYTES, RUNTIME_DEFAULTS.maxDecodedBodyBytes),
@@ -41,10 +57,10 @@ export function loadRuntimeConfig(env = process.env) {
41
57
  RUNTIME_DEFAULTS.maxUpstreamResponsesResponseBytes,
42
58
  ),
43
59
  maxQueuedRequests: parsePositiveInteger(env.CCDX_MAX_QUEUED_REQUESTS, RUNTIME_DEFAULTS.maxQueuedRequests),
44
- requestQueueTimeoutMs: parsePositiveInteger(env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, RUNTIME_DEFAULTS.requestQueueTimeoutMs),
60
+ requestQueueTimeoutMs: parseTimerMs(env.CCDX_REQUEST_QUEUE_TIMEOUT_MS, RUNTIME_DEFAULTS.requestQueueTimeoutMs),
45
61
  responseHistoryMaxBytes: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_BYTES, RUNTIME_DEFAULTS.responseHistoryMaxBytes),
46
62
  responseHistoryMaxEntries: parsePositiveInteger(env.CCDX_RESPONSE_HISTORY_MAX_ENTRIES, RUNTIME_DEFAULTS.responseHistoryMaxEntries),
47
- tokenLockTimeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
48
- tokenLockStaleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
63
+ tokenLockTimeoutMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
64
+ tokenLockStaleMs: parseSafePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
49
65
  });
50
66
  }
@@ -1,5 +1,6 @@
1
1
  import { Worker } from "node:worker_threads";
2
2
  import { anthropicTokenText } from "./anthropic.mjs";
3
+ import { debugLog } from "./log.mjs";
3
4
 
4
5
  export const DEFAULT_TOKEN_COUNTER_TIMEOUT_MS = 120 * 1000;
5
6
  export const DEFAULT_TOKEN_COUNTER_MAX_QUEUED = 16;
@@ -8,6 +9,23 @@ function serviceError(message, statusCode) {
8
9
  return Object.assign(new Error(message), { statusCode });
9
10
  }
10
11
 
12
+ const DIAGNOSTIC_PHASES = new Set(["create", "worker", "error", "exit", "postMessage"]);
13
+ const DIAGNOSTIC_ERROR_NAMES = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "WorkerExit"]);
14
+
15
+ export function tokenCounterWorkerDiagnostic(phase, error) {
16
+ const safePhase = DIAGNOSTIC_PHASES.has(phase) ? phase : "unknown";
17
+ let rawName;
18
+ let rawCode;
19
+ try { rawName = error?.name; } catch {}
20
+ try { rawCode = error?.code; } catch {}
21
+ const safeName = DIAGNOSTIC_ERROR_NAMES.has(rawName) ? rawName : "Error";
22
+ if (typeof rawCode === "number") rawCode = String(rawCode);
23
+ const safeCode = typeof rawCode === "string" && /^(?:\d+|[A-Z][A-Z0-9_]{0,63})$/.test(rawCode)
24
+ ? rawCode
25
+ : "unknown";
26
+ return `Token counter worker failure: phase=${safePhase} name=${safeName} code=${safeCode}`;
27
+ }
28
+
11
29
  function positiveInteger(value, fallback) {
12
30
  return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
13
31
  }
@@ -74,7 +92,8 @@ export function createTokenCounterService({
74
92
  if (!worker) {
75
93
  try {
76
94
  worker = workerFactory();
77
- } catch {
95
+ } catch (error) {
96
+ debugLog(tokenCounterWorkerDiagnostic("create", error));
78
97
  const job = queue.shift();
79
98
  if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
80
99
  queueMicrotask(pump);
@@ -85,6 +104,7 @@ export function createTokenCounterService({
85
104
  const job = active;
86
105
  active = null;
87
106
  if (message.error) {
107
+ debugLog(tokenCounterWorkerDiagnostic("worker", message.error));
88
108
  retireWorker();
89
109
  finish(job, job.reject, serviceError("Token counter worker failed", 500));
90
110
  } else {
@@ -92,14 +112,16 @@ export function createTokenCounterService({
92
112
  }
93
113
  pump();
94
114
  });
95
- worker.on("error", () => {
115
+ worker.on("error", (error) => {
116
+ debugLog(tokenCounterWorkerDiagnostic("error", error));
96
117
  const job = active;
97
118
  active = null;
98
119
  retireWorker();
99
120
  if (job) finish(job, job.reject, serviceError("Token counter worker failed", 500));
100
121
  pump();
101
122
  });
102
- worker.on("exit", () => {
123
+ worker.on("exit", (code) => {
124
+ if (active) debugLog(tokenCounterWorkerDiagnostic("exit", { name: "WorkerExit", code }));
103
125
  const job = active;
104
126
  active = null;
105
127
  worker = null;
@@ -114,7 +136,8 @@ export function createTokenCounterService({
114
136
  try {
115
137
  worker.postMessage({ id: job.id, text: job.text });
116
138
  job.text = null;
117
- } catch {
139
+ } catch (error) {
140
+ debugLog(tokenCounterWorkerDiagnostic("postMessage", error));
118
141
  active = null;
119
142
  retireWorker();
120
143
  finish(job, job.reject, serviceError("Token counter worker failed", 500));
@@ -4,7 +4,13 @@ import { countTokenText } from "./anthropic.mjs";
4
4
  parentPort.on("message", async ({ id, text }) => {
5
5
  try {
6
6
  parentPort.postMessage({ id, result: await countTokenText(text) });
7
- } catch {
8
- parentPort.postMessage({ id, error: true });
7
+ } catch (error) {
8
+ parentPort.postMessage({
9
+ id,
10
+ error: {
11
+ name: typeof error?.name === "string" ? error.name : "Error",
12
+ code: typeof error?.code === "string" ? error.code : "unknown",
13
+ },
14
+ });
9
15
  }
10
16
  });
package/src/usage.mjs CHANGED
@@ -4,11 +4,17 @@ import path from "node:path";
4
4
  import readline from "node:readline";
5
5
  import { cliOutputFormat, cliOutputWidth, formatResponsiveCliTable, terminalCell } from "./cli-table.mjs";
6
6
  import { parseByteLimit, rotateFileIfNeededSync, rotatedFilePath } from "./file-rotation.mjs";
7
+ import { withFileLock } from "./lock.mjs";
7
8
 
8
9
  const DEFAULT_USAGE_PATH = path.join(os.homedir(), ".local", "share", "codex-copilot-dx", "usage.jsonl");
9
10
  const DEFAULT_USAGE_MAX_BYTES = 32 * 1024 * 1024;
11
+ const USAGE_WRITE_LOCK_TIMEOUT_MS = 1_000;
12
+ const USAGE_WRITE_LOCK_STALE_MS = 1_000;
13
+ const USAGE_WRITE_LOCK_POLL_MS = 5;
14
+ const USAGE_FLUSH_TIMEOUT_MS = 1_500;
10
15
 
11
- let writeQueue = Promise.resolve();
16
+ const pendingWrites = [];
17
+ let writeDrain = null;
12
18
 
13
19
  export function usageLogPath() {
14
20
  return process.env.CCDX_USAGE_PATH || DEFAULT_USAGE_PATH;
@@ -119,19 +125,63 @@ export function buildAnthropicUsageRecord({ surface = "messages", mode, model, r
119
125
  });
120
126
  }
121
127
 
128
+ function takePendingWrites(filePath, maxBytes) {
129
+ const batch = [];
130
+ let retained = 0;
131
+ for (const entry of pendingWrites) {
132
+ if (entry.filePath === filePath && entry.maxBytes === maxBytes) batch.push(entry);
133
+ else pendingWrites[retained++] = entry;
134
+ }
135
+ pendingWrites.length = retained;
136
+ return batch;
137
+ }
138
+
139
+ async function writeUsageBatch(batch) {
140
+ const { filePath, maxBytes } = batch[0];
141
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
142
+ await withFileLock(`${path.resolve(filePath)}.lock`, async () => {
143
+ batch.push(...takePendingWrites(filePath, maxBytes));
144
+ for (const { line } of batch) {
145
+ rotateFileIfNeededSync(filePath, Buffer.byteLength(line), maxBytes);
146
+ await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
147
+ }
148
+ }, {
149
+ timeoutMs: USAGE_WRITE_LOCK_TIMEOUT_MS,
150
+ staleMs: USAGE_WRITE_LOCK_STALE_MS,
151
+ pollMs: USAGE_WRITE_LOCK_POLL_MS,
152
+ });
153
+ }
154
+
155
+ function scheduleUsageWriteDrain() {
156
+ if (writeDrain) return;
157
+ writeDrain = Promise.resolve().then(async () => {
158
+ while (pendingWrites.length > 0) {
159
+ const first = pendingWrites.shift();
160
+ const batch = [first, ...takePendingWrites(first.filePath, first.maxBytes)];
161
+ try {
162
+ await writeUsageBatch(batch);
163
+ } catch (error) {
164
+ batch.push(...takePendingWrites(first.filePath, first.maxBytes));
165
+ console.error(`codex-copilot-dx usage log write failed: ${error.message}`);
166
+ } finally {
167
+ for (const entry of batch) entry.resolve();
168
+ }
169
+ }
170
+ }).finally(() => {
171
+ writeDrain = null;
172
+ if (pendingWrites.length > 0) scheduleUsageWriteDrain();
173
+ });
174
+ }
175
+
122
176
  export function recordUsage(record) {
123
177
  if (!record || process.env.CCDX_DISABLE_USAGE === "1") return Promise.resolve();
124
178
  const filePath = usageLogPath();
125
179
  const line = `${JSON.stringify(record)}\n`;
126
- writeQueue = writeQueue
127
- .catch(() => {})
128
- .then(async () => {
129
- await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
130
- rotateFileIfNeededSync(filePath, Buffer.byteLength(line), usageLogMaxBytes());
131
- await fs.promises.appendFile(filePath, line, { encoding: "utf8", mode: 0o600 });
132
- })
133
- .catch((e) => console.error(`codex-copilot-dx usage log write failed: ${e.message}`));
134
- return writeQueue;
180
+ const completed = new Promise((resolve) => {
181
+ pendingWrites.push({ filePath, line, maxBytes: usageLogMaxBytes(), resolve });
182
+ });
183
+ scheduleUsageWriteDrain();
184
+ return completed;
135
185
  }
136
186
 
137
187
  export function recordResponsesUsage(args) {
@@ -143,11 +193,28 @@ export function recordAnthropicUsage(args) {
143
193
  }
144
194
 
145
195
  export async function flushUsageWritesForTests() {
146
- await writeQueue;
196
+ while (writeDrain || pendingWrites.length > 0) {
197
+ if (writeDrain) await writeDrain;
198
+ else scheduleUsageWriteDrain();
199
+ }
147
200
  }
148
201
 
149
- export async function flushUsageWrites() {
150
- await writeQueue;
202
+ export async function flushUsageWrites({
203
+ timeoutMs = USAGE_FLUSH_TIMEOUT_MS,
204
+ warn = console.error,
205
+ } = {}) {
206
+ const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0
207
+ ? Math.floor(timeoutMs)
208
+ : USAGE_FLUSH_TIMEOUT_MS;
209
+ let timer;
210
+ const completed = await Promise.race([
211
+ flushUsageWritesForTests().then(() => true),
212
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeout); }),
213
+ ]);
214
+ if (timer) clearTimeout(timer);
215
+ if (!completed) {
216
+ try { warn(`codex-copilot-dx usage log flush timed out after ${timeout}ms`); } catch {}
217
+ }
151
218
  }
152
219
 
153
220
  async function* iterateUsageRecords(filePath, { warn = console.error } = {}) {