codex-copilot-dx 0.6.15 → 0.6.19

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.
@@ -0,0 +1,44 @@
1
+ import { terminalCell } from "./cli-table.mjs";
2
+ import { status } from "./status.mjs";
3
+
4
+ const MAX_MESSAGE_CHARACTERS = 1024;
5
+ const MAX_DETAIL_CHARACTERS = 512;
6
+ const MAX_DETAILS = 16;
7
+
8
+ function redactSensitiveValues(value) {
9
+ return value
10
+ .replace(/\b(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+/gi, "$1[REDACTED]")
11
+ .replace(/\bbearer\s+[^\s,;]+/gi, "Bearer [REDACTED]")
12
+ .replace(/\b((?:api[-_ ]?key|access[-_ ]?token|refresh[-_ ]?token|password|secret)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]")
13
+ .replace(/\b(?:github_pat_[A-Za-z0-9_]{8,}|gh[pousr]_[A-Za-z0-9_]{8,}|sk-[A-Za-z0-9_-]{8,})\b/g, "[REDACTED]");
14
+ }
15
+
16
+ function boundedTerminalText(value, fallback, maxCharacters) {
17
+ try {
18
+ return redactSensitiveValues(terminalCell(value, { fallback })).slice(0, maxCharacters);
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ export function formatCliError(error) {
25
+ const message = boundedTerminalText(error?.message, "Command failed", MAX_MESSAGE_CHARACTERS);
26
+ let details;
27
+ try {
28
+ details = error?.details;
29
+ } catch {
30
+ details = undefined;
31
+ }
32
+ const values = details === undefined
33
+ ? []
34
+ : Array.isArray(details) ? details : [details];
35
+ const visible = values.slice(0, MAX_DETAILS);
36
+ const lines = [status("err", message)];
37
+ for (const detail of visible) {
38
+ lines.push(status("info", boundedTerminalText(detail, "Detail unavailable", MAX_DETAIL_CHARACTERS)));
39
+ }
40
+ if (values.length > visible.length) {
41
+ lines.push(status("info", `${values.length - visible.length} additional detail(s) omitted`));
42
+ }
43
+ return lines.join("\n");
44
+ }
@@ -18,6 +18,7 @@ const HELP_TOPICS = new Set([
18
18
  "pms",
19
19
  "pms setup",
20
20
  "pms status",
21
+ "pms restore",
21
22
  "usage",
22
23
  "auto-review-model",
23
24
  "update",
@@ -154,9 +155,9 @@ export function parseCliArgs(args = []) {
154
155
  }
155
156
  if (PM_COMMANDS.has(command)) {
156
157
  const [action, ...pmsArgs] = rest;
157
- if (!action) throw new Error("Missing pms action: expected setup or status");
158
- if (!new Set(["setup", "status"]).has(action)) throw new Error(`Unknown pms action: ${action}`);
159
- if (action === "setup") {
158
+ if (!action) throw new Error("Missing pms action: expected setup, status, or restore");
159
+ if (!new Set(["setup", "status", "restore"]).has(action)) throw new Error(`Unknown pms action: ${action}`);
160
+ if (action === "setup" || action === "restore") {
160
161
  if (pmsArgs.length) unexpectedArgs(pmsArgs);
161
162
  return baseCommand("pms", { action });
162
163
  }
@@ -292,9 +293,10 @@ function topicHelp(name, topic) {
292
293
  doctor: `Usage:\n ${name} doctor [--online] [--compat] [--profile codex|claude|all]\n\nChecks local credentials, client configuration, PM Studio state, and the adapter. --online validates account entitlement; --compat sends minimal inference requests and consumes a small amount of Copilot usage.`,
293
294
  status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, cache, queue, and PM relay metrics from a running adapter.`,
294
295
  models: `Usage:\n ${name} models [--profile codex|claude] [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for one saved profile. Interactive terminals use a table by default.`,
295
- pms: `Usage:\n ${name} pms status [--format table|plain]\n ${name} pms setup\n ${name} pm-studio status [--format table|plain]\n ${name} pm-studio setup\n\nInspects or installs the fail-closed PM Studio split-origin patch. setup modifies the installed app only after exact or semantic compatibility and source checks pass.`,
296
+ pms: `Usage:\n ${name} pms status [--format table|plain]\n ${name} pms setup\n ${name} pms restore\n ${name} pm-studio status [--format table|plain]\n ${name} pm-studio setup\n ${name} pm-studio restore\n\nInspects, installs, or restores the fail-closed PM Studio split-origin patch. setup and restore modify the installed app only after source-bound compatibility and backup checks pass.`,
296
297
  "pms setup": `Usage:\n ${name} pms setup\n\nBacks up, patches, verifies, and ad-hoc signs an exact or structurally compatible PM Studio bundle. Unverifiable versions and drift are never modified.`,
297
298
  "pms status": `Usage:\n ${name} pms status [--format table|plain]\n\nRead-only inspection of the installed PM Studio version, patch integrity, Claude profile, and relay availability. Interactive terminals use a table by default.`,
299
+ "pms restore": `Usage:\n ${name} pms restore\n\nRestores the installed PM Studio bundle from its exact source-bound verified backup. The explicit command authorizes the change; no confirmation prompt is shown. Missing, ambiguous, version-mismatched, or drifted records are never restored.`,
298
300
  usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
299
301
  "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an enabled Responses model for Codex Auto-review.`,
300
302
  update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or GitHub main. With no source, an interactive terminal prompts for one.`,
@@ -316,6 +318,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
316
318
  ${name} models [--profile codex|claude] [--format table|plain]
317
319
  ${name} pms status [--format table|plain]
318
320
  ${name} pms setup
321
+ ${name} pms restore
319
322
  ${name} usage [--format table|plain]
320
323
  ${name} auto-review-model
321
324
  ${name} update [npm|github]
@@ -328,7 +331,7 @@ Commands:
328
331
  doctor Diagnose local config; optional live and inference checks
329
332
  status Show runtime routing, performance, queue, and cache health
330
333
  models Query a saved account's live Copilot model catalog
331
- pms, pm-studio Inspect or install the PM Studio patch
334
+ pms, pm-studio Inspect, install, or restore the PM Studio patch
332
335
  usage Summarize locally recorded token usage
333
336
  auto-review-model Select the Codex Auto-review Responses model
334
337
  update Update the global package from npm or GitHub
@@ -1,4 +1,5 @@
1
1
  import { createCopilotTokenSession } from "./copilot-token-session.mjs";
2
+ import { MAX_UPSTREAM_MODEL_CATALOG_BYTES, readBoundedResponseText } from "./http-transport.mjs";
2
3
  import { prepareResponsesPayload, summarizeReqBody } from "./image-optimization.mjs";
3
4
  import { debugLog } from "./log.mjs";
4
5
  import { enforceResponsesPayloadByteBudget } from "./responses-byte-budget.mjs";
@@ -117,7 +118,10 @@ export function createCopilotClientRuntime({
117
118
  headers,
118
119
  signal,
119
120
  }, { fetchImpl, ...retryOptions });
120
- const body = await response.text();
121
+ const body = await readBoundedResponseText(response, {
122
+ maxBytes: MAX_UPSTREAM_MODEL_CATALOG_BYTES,
123
+ label: "Copilot model catalog",
124
+ });
121
125
  if (response.ok) {
122
126
  try { cacheModelEndpoints(JSON.parse(body)); } catch {}
123
127
  }
@@ -4,7 +4,11 @@ import {
4
4
  isImageNamespaceCollisionError,
5
5
  sanitizeImageNamespaceCollisionRequest,
6
6
  } from "./copilot-responses-policy.mjs";
7
- import { httpError } from "./http-transport.mjs";
7
+ import {
8
+ httpError,
9
+ MAX_UPSTREAM_ERROR_BODY_BYTES,
10
+ readBoundedResponseText,
11
+ } from "./http-transport.mjs";
8
12
  import {
9
13
  finalizeEncryptedHistoryRebase,
10
14
  sanitizeEncryptedReasoningRequest,
@@ -48,7 +52,10 @@ export async function openCopilotResponse(reqContext, upstream = copilotResponse
48
52
  return { resp, reqContext };
49
53
  }
50
54
 
51
- const errorText = await resp.text();
55
+ const errorText = await readBoundedResponseText(resp, {
56
+ maxBytes: MAX_UPSTREAM_ERROR_BODY_BYTES,
57
+ label: "Copilot Responses error body",
58
+ });
52
59
  options.assertPrepareActive?.();
53
60
  let retryPolicy = null;
54
61
  let retryContext = null;
package/src/copilot.mjs CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { status } from "./status.mjs";
3
3
  import { debugLog } from "./log.mjs";
4
4
  import { createCopilotClientRuntime } from "./copilot-client.mjs";
5
+ import { discardBoundedResponseBody } from "./http-transport.mjs";
5
6
 
6
7
  export {
7
8
  optimizeImageDataUrl,
@@ -156,7 +157,7 @@ export async function fetchCopilotUpstream(
156
157
  const resp = await fetchImpl(url, init);
157
158
  debugLog(`upstream ${method} ${target} status=${resp.status} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
158
159
  if (attempt < retryCount && isRetryableUpstreamStatus(resp.status, method)) {
159
- await resp.arrayBuffer?.().catch(() => {});
160
+ await discardBoundedResponseBody(resp);
160
161
  const delay = upstreamRetryDelay(attempt, baseDelay);
161
162
  console.warn(status("warn", `upstream ${target} returned ${resp.status}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
162
163
  await sleep(delay, { signal });
@@ -17,12 +17,150 @@ const MAX_INFLIGHT_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxInflightBodyBytes;
17
17
  const MAX_QUEUED_REQUESTS = HTTP_RUNTIME_CONFIG.maxQueuedRequests;
18
18
  const REQUEST_QUEUE_TIMEOUT_MS = HTTP_RUNTIME_CONFIG.requestQueueTimeoutMs;
19
19
 
20
+ export const MAX_UPSTREAM_ERROR_BODY_BYTES = 1024 * 1024;
21
+ export const MAX_UPSTREAM_MODEL_CATALOG_BYTES = 8 * 1024 * 1024;
22
+ export const MAX_UPSTREAM_RETRY_DRAIN_BYTES = 64 * 1024;
23
+ export const MAX_UPSTREAM_CHAT_SUCCESS_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxUpstreamChatResponseBytes;
24
+ export const MAX_UPSTREAM_RESPONSES_SUCCESS_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxUpstreamResponsesResponseBytes;
25
+
20
26
  export function httpError(message, statusCode) {
21
27
  const err = new Error(message);
22
28
  err.statusCode = statusCode;
23
29
  return err;
24
30
  }
25
31
 
32
+ function upstreamBodyTooLarge(label, maxBytes) {
33
+ const error = httpError(`${label} exceeds ${maxBytes} bytes`, 502);
34
+ error.code = "ccdx_upstream_response_too_large";
35
+ error.jsonBody = {
36
+ error: {
37
+ message: error.message,
38
+ type: "upstream_error",
39
+ code: error.code,
40
+ },
41
+ };
42
+ return error;
43
+ }
44
+
45
+ function unreadableUpstreamBody(label) {
46
+ const error = httpError(`${label} is not a readable web stream`, 502);
47
+ error.code = "ccdx_upstream_response_unreadable";
48
+ error.jsonBody = {
49
+ error: {
50
+ message: error.message,
51
+ type: "upstream_error",
52
+ code: error.code,
53
+ },
54
+ };
55
+ return error;
56
+ }
57
+
58
+ async function cancelResponseBody(response) {
59
+ try { await response?.body?.cancel?.(); } catch {}
60
+ }
61
+
62
+ function responseAbortError(signal) {
63
+ if (signal?.reason instanceof Error) return signal.reason;
64
+ const error = new Error("The operation was aborted");
65
+ error.name = "AbortError";
66
+ return error;
67
+ }
68
+
69
+ function readResponseChunk(reader, signal) {
70
+ if (!signal) return reader.read();
71
+ if (signal.aborted) return Promise.reject(responseAbortError(signal));
72
+ return new Promise((resolve, reject) => {
73
+ let settled = false;
74
+ const finish = (callback, value) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ signal.removeEventListener("abort", onAbort);
78
+ callback(value);
79
+ };
80
+ const onAbort = () => {
81
+ const error = responseAbortError(signal);
82
+ try { Promise.resolve(reader.cancel(error)).catch(() => {}); } catch {}
83
+ finish(reject, error);
84
+ };
85
+ signal.addEventListener("abort", onAbort, { once: true });
86
+ reader.read().then(
87
+ (value) => finish(resolve, value),
88
+ (error) => finish(reject, error),
89
+ );
90
+ });
91
+ }
92
+
93
+ function responseContentLength(response) {
94
+ const raw = response?.headers?.get?.("content-length")
95
+ ?? response?.headers?.["content-length"]
96
+ ?? response?.headers?.["Content-Length"];
97
+ const parsed = Number.parseInt(raw || "", 10);
98
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
99
+ }
100
+
101
+ export async function readBoundedResponseBuffer(response, {
102
+ maxBytes,
103
+ label = "Upstream response body",
104
+ signal,
105
+ } = {}) {
106
+ const limit = Number.isFinite(maxBytes) && maxBytes >= 0 ? Math.floor(maxBytes) : 0;
107
+ if (signal?.aborted) {
108
+ cancelResponseBody(response);
109
+ throw responseAbortError(signal);
110
+ }
111
+ if (responseContentLength(response) > limit) {
112
+ await cancelResponseBody(response);
113
+ throw upstreamBodyTooLarge(label, limit);
114
+ }
115
+ if (!response?.body) {
116
+ if (typeof response?.text === "function") {
117
+ const buffer = Buffer.from(await response.text());
118
+ if (buffer.length > limit) throw upstreamBodyTooLarge(label, limit);
119
+ return buffer;
120
+ }
121
+ if ((responseContentLength(response) || 0) > 0) throw unreadableUpstreamBody(label);
122
+ return Buffer.alloc(0);
123
+ }
124
+ const reader = response?.body?.getReader?.();
125
+ if (!reader) throw unreadableUpstreamBody(label);
126
+ const chunks = [];
127
+ let total = 0;
128
+ try {
129
+ while (true) {
130
+ const { done, value } = await readResponseChunk(reader, signal);
131
+ if (done) break;
132
+ const chunk = Buffer.from(value);
133
+ total += chunk.length;
134
+ if (total > limit) {
135
+ await reader.cancel().catch(() => {});
136
+ throw upstreamBodyTooLarge(label, limit);
137
+ }
138
+ chunks.push(chunk);
139
+ }
140
+ return Buffer.concat(chunks, total);
141
+ } finally {
142
+ try {
143
+ reader.releaseLock();
144
+ } catch (error) {
145
+ if (!signal?.aborted) throw error;
146
+ }
147
+ }
148
+ }
149
+
150
+ export async function readBoundedResponseText(response, options) {
151
+ return new TextDecoder().decode(await readBoundedResponseBuffer(response, options));
152
+ }
153
+
154
+ export async function discardBoundedResponseBody(response, maxBytes = MAX_UPSTREAM_RETRY_DRAIN_BYTES) {
155
+ try {
156
+ await readBoundedResponseBuffer(response, { maxBytes, label: "Upstream retry response body" });
157
+ return true;
158
+ } catch {
159
+ await cancelResponseBody(response);
160
+ return false;
161
+ }
162
+ }
163
+
26
164
  function payloadTooLarge(kind, maxBytes) {
27
165
  return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
28
166
  }
package/src/lock.mjs CHANGED
@@ -32,23 +32,191 @@ function processIsAlive(pid) {
32
32
  }
33
33
  }
34
34
 
35
+ function readStaleLockSnapshot(lockPath, io) {
36
+ const before = io.lstatSync(lockPath);
37
+ if (!before.isFile() || before.isSymbolicLink()) return { safe: false };
38
+
39
+ const noFollow = io.constants?.O_NOFOLLOW || 0;
40
+ let fd;
41
+ try {
42
+ fd = io.openSync(lockPath, io.constants.O_RDONLY | noFollow);
43
+ const opened = io.fstatSync(fd);
44
+ if (!opened.isFile()
45
+ || opened.dev !== before.dev
46
+ || opened.ino !== before.ino
47
+ || opened.size !== before.size) {
48
+ return { safe: false };
49
+ }
50
+ const text = io.readFileSync(fd, "utf8");
51
+ const after = io.fstatSync(fd);
52
+ if (after.dev !== opened.dev
53
+ || after.ino !== opened.ino
54
+ || after.size !== opened.size
55
+ || after.mtimeMs !== opened.mtimeMs
56
+ || after.ctimeMs !== opened.ctimeMs) {
57
+ return { safe: false };
58
+ }
59
+ let record = {};
60
+ try {
61
+ record = JSON.parse(text);
62
+ } catch {}
63
+ return {
64
+ safe: true,
65
+ dev: after.dev,
66
+ ino: after.ino,
67
+ mtimeMs: after.mtimeMs,
68
+ size: after.size,
69
+ text,
70
+ pid: Number(record?.pid),
71
+ owner: typeof record?.owner === "string" ? record.owner : "",
72
+ };
73
+ } finally {
74
+ if (fd !== undefined) io.closeSync(fd);
75
+ }
76
+ }
77
+
35
78
  function sameLock(left, right) {
36
79
  return left.dev === right.dev
37
80
  && left.ino === right.ino
38
81
  && left.mtimeMs === right.mtimeMs
39
82
  && left.size === right.size
40
- && left.owner === right.owner;
83
+ && left.owner === right.owner
84
+ && left.text === right.text;
41
85
  }
42
86
 
43
- function maybeRemoveStaleLock(lockPath, staleMs, nowMs) {
44
- if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
87
+ function lockPathExists(lockPath, io) {
88
+ try {
89
+ io.lstatSync(lockPath);
90
+ return true;
91
+ } catch (error) {
92
+ if (error?.code === "ENOENT") return false;
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ function writeLockRecord(lockPath, record, io) {
98
+ const fd = io.openSync(lockPath, "wx", 0o600);
99
+ try {
100
+ io.writeFileSync(fd, JSON.stringify(record));
101
+ } catch (error) {
102
+ io.closeSync(fd);
103
+ try { io.unlinkSync(lockPath); } catch {}
104
+ throw error;
105
+ }
106
+ return fd;
107
+ }
108
+
109
+ function removeOwnedLock(lockPath, owner, io) {
110
+ try {
111
+ const snapshot = readStaleLockSnapshot(lockPath, io);
112
+ if (snapshot.safe && snapshot.owner === owner) io.unlinkSync(lockPath);
113
+ } catch (error) {
114
+ if (error?.code !== "ENOENT") throw error;
115
+ }
116
+ }
117
+
118
+ function reclaimGuardPath(lockPath) {
119
+ return `${lockPath}.reclaim`;
120
+ }
121
+
122
+ function acquireReclaimGuard(lockPath, io) {
123
+ const guardPath = reclaimGuardPath(lockPath);
124
+ const owner = randomUUID();
125
+ let fd;
126
+ try {
127
+ fd = writeLockRecord(guardPath, {
128
+ pid: process.pid,
129
+ owner,
130
+ created_at: new Date().toISOString(),
131
+ }, io);
132
+ io.closeSync(fd);
133
+ return { guardPath, owner };
134
+ } catch (error) {
135
+ if (fd !== undefined) try { io.closeSync(fd); } catch {}
136
+ if (error?.code === "EEXIST") return null;
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ function maybeRemoveAbandonedGuard(guardPath, staleMs, nowMs, io, processAlive) {
142
+ try {
143
+ const snapshot = readStaleLockSnapshot(guardPath, io);
144
+ if (!snapshot.safe || nowMs - snapshot.mtimeMs <= staleMs || processAlive(snapshot.pid)) return false;
145
+ const quarantinePath = `${guardPath}.stale-${randomUUID()}`;
146
+ io.renameSync(guardPath, quarantinePath);
147
+ const moved = readStaleLockSnapshot(quarantinePath, io);
148
+ if (!moved.safe || !sameLock(snapshot, moved)) {
149
+ restoreQuarantinedLock(guardPath, quarantinePath, io);
150
+ return false;
151
+ }
152
+ try {
153
+ io.unlinkSync(quarantinePath);
154
+ return true;
155
+ } catch {
156
+ return recoverQuarantineUnlinkFailure(guardPath, quarantinePath, snapshot, io);
157
+ }
158
+ } catch (error) {
159
+ return error?.code === "ENOENT";
160
+ }
161
+ }
162
+
163
+ function restoreQuarantinedLock(lockPath, quarantinePath, io) {
164
+ try {
165
+ io.linkSync(quarantinePath, lockPath);
166
+ } catch {
167
+ return;
168
+ }
45
169
  try {
46
- const first = readLockSnapshot(lockPath);
47
- if (nowMs - first.mtimeMs <= staleMs || processIsAlive(first.pid)) return false;
48
- const current = readLockSnapshot(lockPath);
49
- if (!sameLock(first, current)) return false;
50
- fs.unlinkSync(lockPath);
170
+ io.unlinkSync(quarantinePath);
171
+ } catch {}
172
+ }
173
+
174
+ function recoverQuarantineUnlinkFailure(lockPath, quarantinePath, snapshot, io) {
175
+ restoreQuarantinedLock(lockPath, quarantinePath, io);
176
+ try {
177
+ const restored = readStaleLockSnapshot(lockPath, io);
178
+ if (!restored.safe || !sameLock(snapshot, restored)) return false;
179
+ io.unlinkSync(lockPath);
51
180
  return true;
181
+ } catch (error) {
182
+ return error?.code === "ENOENT";
183
+ }
184
+ }
185
+
186
+ function maybeRemoveStaleLock(lockPath, staleMs, nowMs, io, processAlive) {
187
+ if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
188
+ try {
189
+ const first = readStaleLockSnapshot(lockPath, io);
190
+ if (!first.safe || nowMs - first.mtimeMs <= staleMs || processAlive(first.pid)) return false;
191
+ const guard = acquireReclaimGuard(lockPath, io);
192
+ if (!guard) return false;
193
+ try {
194
+ const current = readStaleLockSnapshot(lockPath, io);
195
+ if (!sameLock(first, current)
196
+ || nowMs - current.mtimeMs <= staleMs
197
+ || processAlive(current.pid)) {
198
+ return false;
199
+ }
200
+ const quarantinePath = `${lockPath}.stale-${randomUUID()}`;
201
+ try {
202
+ io.renameSync(lockPath, quarantinePath);
203
+ } catch (e) {
204
+ return e?.code === "ENOENT";
205
+ }
206
+ const moved = readStaleLockSnapshot(quarantinePath, io);
207
+ if (!moved.safe || !sameLock(current, moved)) {
208
+ restoreQuarantinedLock(lockPath, quarantinePath, io);
209
+ return false;
210
+ }
211
+ try {
212
+ io.unlinkSync(quarantinePath);
213
+ return true;
214
+ } catch {
215
+ return recoverQuarantineUnlinkFailure(lockPath, quarantinePath, current, io);
216
+ }
217
+ } finally {
218
+ removeOwnedLock(guard.guardPath, guard.owner, io);
219
+ }
52
220
  } catch (e) {
53
221
  if (e?.code === "ENOENT") return true;
54
222
  return false;
@@ -60,28 +228,42 @@ export async function withFileLock(lockPath, fn, {
60
228
  staleMs = 15 * 60 * 1000,
61
229
  pollMs = 50,
62
230
  now = Date.now,
231
+ fsImpl = fs,
232
+ processAlive = processIsAlive,
63
233
  } = {}) {
64
234
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
65
235
  const started = now();
66
236
  const owner = randomUUID();
67
237
  let fd = null;
238
+ const guardPath = reclaimGuardPath(lockPath);
68
239
 
69
240
  while (fd === null) {
241
+ const beforeAcquire = now();
242
+ if (lockPathExists(guardPath, fsImpl)) {
243
+ maybeRemoveAbandonedGuard(guardPath, staleMs, beforeAcquire, fsImpl, processAlive);
244
+ if (lockPathExists(guardPath, fsImpl)) {
245
+ if (beforeAcquire - started >= timeoutMs) throw new Error(`Timed out waiting for lock: ${lockPath}`);
246
+ await sleep(Math.min(pollMs, Math.max(1, timeoutMs - (beforeAcquire - started))));
247
+ continue;
248
+ }
249
+ }
70
250
  try {
71
- const candidateFd = fs.openSync(lockPath, "wx", 0o600);
72
- try {
73
- fs.writeFileSync(candidateFd, JSON.stringify({ pid: process.pid, owner, created_at: new Date().toISOString() }));
74
- fd = candidateFd;
75
- } catch (e) {
76
- fs.closeSync(candidateFd);
77
- try { fs.unlinkSync(lockPath); } catch {}
78
- throw e;
251
+ fd = writeLockRecord(lockPath, {
252
+ pid: process.pid,
253
+ owner,
254
+ created_at: new Date().toISOString(),
255
+ }, fs);
256
+ if (lockPathExists(guardPath, fsImpl)) {
257
+ fs.closeSync(fd);
258
+ fd = null;
259
+ removeOwnedLock(lockPath, owner, fs);
260
+ continue;
79
261
  }
80
262
  break;
81
263
  } catch (e) {
82
264
  if (e?.code !== "EEXIST") throw e;
83
265
  const nowMs = now();
84
- if (maybeRemoveStaleLock(lockPath, staleMs, nowMs)) continue;
266
+ if (maybeRemoveStaleLock(lockPath, staleMs, nowMs, fsImpl, processAlive)) continue;
85
267
  if (nowMs - started >= timeoutMs) {
86
268
  throw new Error(`Timed out waiting for lock: ${lockPath}`);
87
269
  }
package/src/models.mjs CHANGED
@@ -117,6 +117,23 @@ export function modelIsResponsesOnly(model) {
117
117
  return hasResponses && !endpoints.includes("/chat/completions");
118
118
  }
119
119
 
120
+ export function resolveCopilotPriorityTierModel(model, serviceTier, models) {
121
+ const baseModel = String(model || "").trim();
122
+ if (baseModel !== "gpt-5.6-sol" || serviceTier !== "priority") return null;
123
+ const fastModelId = "gpt-5.6-sol-fast";
124
+ const fastModel = copilotModelData(models)
125
+ .find((entry) => String(entry?.id || "").trim() === fastModelId);
126
+ const vendor = String(fastModel?.vendor || fastModel?.owned_by || "").trim().toLowerCase();
127
+ const policy = String(fastModel?.policy?.state || "").trim().toLowerCase();
128
+ if (vendor !== "openai"
129
+ || policy !== "enabled"
130
+ || fastModel?.model_picker_enabled !== true
131
+ || !modelIsResponsesOnly(fastModel)) {
132
+ return null;
133
+ }
134
+ return fastModelId;
135
+ }
136
+
120
137
  function copilotModelData(models) {
121
138
  const data = Array.isArray(models) ? models : models?.data;
122
139
  return Array.isArray(data) ? data : [];