@yansigit/opencodex 2.31.2 → 2.32.0
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/bin/ocx.mjs +99 -70
- package/gui/dist/assets/index-BG43zwVe.js +102 -0
- package/gui/dist/assets/index-CiSI-jrP.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +6 -8
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code-project-context.ts +377 -0
- package/src/adapters/command-code.ts +7 -4
- package/src/adapters/cursor/live-models.ts +8 -0
- package/src/adapters/cursor/live-transport.ts +21 -0
- package/src/adapters/cursor/native-exec-bridge.ts +141 -0
- package/src/adapters/cursor/native-exec-desktop.ts +16 -0
- package/src/adapters/cursor/tool-definitions.ts +1 -1
- package/src/adapters/google-http.ts +12 -2
- package/src/adapters/google-wire-compiler.ts +83 -2
- package/src/adapters/google.ts +62 -21
- package/src/adapters/kiro.ts +0 -3
- package/src/adapters/openai-responses.ts +3 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-web-search.ts +7 -2
- package/src/bridge.ts +21 -15
- package/src/cli/dispatch.ts +50 -2
- package/src/cli/doctor.ts +24 -11
- package/src/cli/help.ts +4 -3
- package/src/cli/index.ts +11 -4
- package/src/cli/models.ts +13 -3
- package/src/cli/observe.ts +20 -5
- package/src/cli/provider.ts +2 -1
- package/src/cli/registry.ts +7 -5
- package/src/cli/status.ts +2 -1
- package/src/cli/system-restart-client.ts +1 -1
- package/src/cli/usage-report.ts +134 -0
- package/src/codex/app-server-processes.ts +3 -1
- package/src/codex/catalog/aggregation.ts +13 -1
- package/src/codex/catalog/effort.ts +24 -9
- package/src/codex/catalog/model-metadata.ts +566 -0
- package/src/codex/catalog/parsing.ts +33 -0
- package/src/codex/catalog/provider-fetch.ts +123 -32
- package/src/codex/catalog/sync.ts +5 -4
- package/src/codex/desktop-app-restart.ts +342 -0
- package/src/codex/history-job.ts +32 -3
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +5 -5
- package/src/codex/history-provider.ts +825 -247
- package/src/codex/history-worker.ts +8 -5
- package/src/codex/inject.ts +49 -21
- package/src/codex/injected-marker.ts +1 -1
- package/src/codex/internal/history-writer.ts +4 -3
- package/src/codex/native-profile-startup.ts +157 -27
- package/src/codex/native-residue.ts +26 -33
- package/src/combos/failover.ts +27 -0
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +81 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +308 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config.ts +77 -812
- package/src/generated/compatibility-version.json +162 -94
- package/src/images/plan.ts +5 -4
- package/src/integrations/ownership-policy.ts +141 -0
- package/src/integrations/ownership.ts +10 -0
- package/src/integrations/state.ts +44 -5
- package/src/integrations/writer.ts +6 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/bounded-body.ts +14 -2
- package/src/lib/process-control.ts +2 -1
- package/src/lib/state-store-registrations.ts +2 -0
- package/src/lib/tool-argument-integers.ts +56 -5
- package/src/oauth/health.ts +1 -1
- package/src/oauth/index.ts +3 -0
- package/src/providers/registry.ts +1 -1
- package/src/reasoning-effort.ts +19 -2
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/custom-tool-compat.ts +132 -38
- package/src/responses/parser.ts +3 -2
- package/src/responses/reasoning-replay-cache.ts +81 -3
- package/src/routing/compatibility/behavior.ts +3 -0
- package/src/server/auth-cors.ts +9 -7
- package/src/server/index.ts +102 -21
- package/src/server/local-management-read-client.ts +1 -1
- package/src/server/local-provider-reload-client.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +1 -1
- package/src/server/management/config-routes.ts +4 -1
- package/src/server/management/context.ts +1 -1
- package/src/server/management/logs-usage-routes.ts +27 -6
- package/src/server/management/model-routes.ts +8 -4
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/provider-capability-config.ts +1 -1
- package/src/server/management/system-restart.ts +1 -1
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/proxy-liveness.ts +2 -1
- package/src/server/request-log-conversation.ts +30 -0
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/combo-stream-preflight.ts +171 -0
- package/src/server/responses/compact.ts +6 -21
- package/src/server/responses/core.ts +242 -101
- package/src/server/responses/fetch-helpers.ts +2 -97
- package/src/server/responses-custom-tool-repair.ts +41 -5
- package/src/server/responses-undeclared-tool-guard.ts +156 -15
- package/src/service.ts +8 -4
- package/src/types/provider.ts +7 -0
- package/src/types/request.ts +12 -1
- package/src/types/tools.ts +87 -11
- package/src/types.ts +1 -1
- package/src/update/index.ts +5 -4
- package/src/update/job.ts +3 -1
- package/src/update/transactional-install.mjs +8 -1
- package/src/usage/log.ts +16 -8
- package/src/usage/summary.ts +201 -8
- package/src/vision/describe.ts +18 -13
- package/src/web-search/executor.ts +10 -3
- package/src/web-search/gemini-executor.ts +35 -13
- package/src/web-search/index.ts +85 -1
- package/gui/dist/assets/index-BAMgarF9.js +0 -102
- package/gui/dist/assets/index-CGoDO3uO.css +0 -1
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { open, opendir } from "node:fs/promises";
|
|
3
|
+
import { join, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type CommandCodeProjectContext = {
|
|
6
|
+
memory: string;
|
|
7
|
+
taste: string | null;
|
|
8
|
+
skills: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const EMPTY_COMMAND_CODE_PROJECT_CONTEXT: CommandCodeProjectContext = {
|
|
12
|
+
memory: "",
|
|
13
|
+
taste: null,
|
|
14
|
+
skills: null,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MEMORY_CAP_BYTES = 32_768;
|
|
18
|
+
const TASTE_CAP_BYTES = 8_192;
|
|
19
|
+
const SKILLS_XML_CAP_BYTES = 32_768;
|
|
20
|
+
const MAX_SKILLS = 16;
|
|
21
|
+
// Upper bound on how many candidate skill directories one root may enumerate before
|
|
22
|
+
// stopping. Realistic projects have far fewer; this bounds pathological/hostile
|
|
23
|
+
// directories (millions of entries) to a finite scan while preserving alphabetical
|
|
24
|
+
// selection for any realistic case (≤ MAX_SKILL_DIRS_TO_SCAN valid dirs per root).
|
|
25
|
+
const MAX_SKILL_DIRS_TO_SCAN = 256;
|
|
26
|
+
const FILE_OP_TIMEOUT_MS = 2_000;
|
|
27
|
+
const PROJECT_CONTEXT_TTL_MS = 30_000;
|
|
28
|
+
const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128;
|
|
29
|
+
|
|
30
|
+
const TRUNCATION_MARKER = "\n<!-- truncated -->";
|
|
31
|
+
|
|
32
|
+
const SKILL_ROOTS = [
|
|
33
|
+
".commandcode/skills",
|
|
34
|
+
".agents/skills",
|
|
35
|
+
".pi/skills",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export const projectContextCache = new Map<string, { collectedAt: number; value: CommandCodeProjectContext }>();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Evict expired entries first, then the oldest live entry if at capacity.
|
|
42
|
+
* Called before inserting a new key so the cache never exceeds the cap.
|
|
43
|
+
*/
|
|
44
|
+
function pruneExpiredProjectContextCache(now: number): void {
|
|
45
|
+
for (const [key, entry] of projectContextCache) {
|
|
46
|
+
if (now - entry.collectedAt >= PROJECT_CONTEXT_TTL_MS) {
|
|
47
|
+
projectContextCache.delete(key);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function pruneProjectContextCache(now: number): void {
|
|
53
|
+
pruneExpiredProjectContextCache(now);
|
|
54
|
+
if (projectContextCache.size >= MAX_PROJECT_CONTEXT_CACHE_ENTRIES) {
|
|
55
|
+
let oldestKey: string | null = null;
|
|
56
|
+
let oldestAt = Infinity;
|
|
57
|
+
for (const [key, entry] of projectContextCache) {
|
|
58
|
+
if (entry.collectedAt < oldestAt) {
|
|
59
|
+
oldestAt = entry.collectedAt;
|
|
60
|
+
oldestKey = key;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (oldestKey !== null) projectContextCache.delete(oldestKey);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Fail-soft canonical path; returns null when the path does not exist or cannot be resolved. */
|
|
68
|
+
function canonicalPath(candidate: string): string | null {
|
|
69
|
+
if (!existsSync(candidate)) return null;
|
|
70
|
+
try {
|
|
71
|
+
return realpathSync.native(candidate);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizePathIdentity(path: string): string {
|
|
78
|
+
return process.platform === "win32" ? path.toLowerCase() : path;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function confinedCanonicalPath(filePath: string, cwdCanonical: string): string | null {
|
|
82
|
+
const fileCanonical = canonicalPath(filePath);
|
|
83
|
+
if (!fileCanonical) return null;
|
|
84
|
+
const fileId = normalizePathIdentity(fileCanonical);
|
|
85
|
+
const cwdId = normalizePathIdentity(cwdCanonical);
|
|
86
|
+
if (fileId === cwdId || fileId.startsWith(cwdId + sep)) return fileCanonical;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
91
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([
|
|
94
|
+
promise,
|
|
95
|
+
new Promise<never>((_, reject) => {
|
|
96
|
+
timer = setTimeout(() => reject(new Error("timeout")), ms);
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
} finally {
|
|
100
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function truncateUtf8(text: string, capBytes: number): string {
|
|
105
|
+
const buf = Buffer.from(text, "utf8");
|
|
106
|
+
if (buf.length <= capBytes) return text;
|
|
107
|
+
const markerBuf = Buffer.from(TRUNCATION_MARKER, "utf8");
|
|
108
|
+
const prefixCap = capBytes - markerBuf.length;
|
|
109
|
+
if (prefixCap <= 0) return TRUNCATION_MARKER.slice(0, capBytes);
|
|
110
|
+
let end = prefixCap;
|
|
111
|
+
while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--;
|
|
112
|
+
return buf.subarray(0, end).toString("utf8") + TRUNCATION_MARKER;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readUtf8File(path: string, capBytes: number): Promise<string | null> {
|
|
116
|
+
type FileHandle = Awaited<ReturnType<typeof open>>;
|
|
117
|
+
let fileHandle: FileHandle | undefined;
|
|
118
|
+
const closedHandles = new WeakSet<object>();
|
|
119
|
+
const opened = open(path, "r");
|
|
120
|
+
const closeBestEffort = (handle: FileHandle): Promise<void> => {
|
|
121
|
+
if (closedHandles.has(handle)) return Promise.resolve();
|
|
122
|
+
closedHandles.add(handle);
|
|
123
|
+
return Promise.resolve()
|
|
124
|
+
.then(() => handle.close())
|
|
125
|
+
.catch(() => {
|
|
126
|
+
/* closing a timed-out read is best-effort */
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const read = (async () => {
|
|
131
|
+
const handle = await opened;
|
|
132
|
+
fileHandle = handle;
|
|
133
|
+
try {
|
|
134
|
+
const data = Buffer.alloc(capBytes + 1);
|
|
135
|
+
const { bytesRead } = await handle.read(data, 0, data.length, 0);
|
|
136
|
+
return data.subarray(0, bytesRead).toString("utf8");
|
|
137
|
+
} finally {
|
|
138
|
+
await closeBestEffort(handle);
|
|
139
|
+
if (fileHandle === handle) fileHandle = undefined;
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
return await withTimeout(read, FILE_OP_TIMEOUT_MS);
|
|
145
|
+
} catch {
|
|
146
|
+
if (fileHandle) void closeBestEffort(fileHandle);
|
|
147
|
+
void opened.then(handle => closeBestEffort(handle), () => undefined);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function xmlEscape(value: string): string {
|
|
153
|
+
return value
|
|
154
|
+
.replace(/&/g, "&")
|
|
155
|
+
.replace(/</g, "<")
|
|
156
|
+
.replace(/>/g, ">")
|
|
157
|
+
.replace(/"/g, """);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseSkillFrontmatter(text: string): { name: string | null; body: string } {
|
|
161
|
+
const opening = text.startsWith("---\r\n") ? "---\r\n" : text.startsWith("---\n") ? "---\n" : null;
|
|
162
|
+
if (!opening) return { name: null, body: text };
|
|
163
|
+
const closing = text.slice(opening.length).match(/^---(?:\r?\n|$)/m);
|
|
164
|
+
if (!closing || closing.index === undefined) return { name: null, body: text };
|
|
165
|
+
const end = opening.length + closing.index;
|
|
166
|
+
const frontmatter = text.slice(opening.length, end).replace(/\r?\n$/, "");
|
|
167
|
+
let name: string | null = null;
|
|
168
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
169
|
+
const match = line.match(/^name:\s*(.+)$/);
|
|
170
|
+
if (match) {
|
|
171
|
+
const parsed = match[1]!.trim();
|
|
172
|
+
if (parsed.length > 0) name = parsed;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const bodyStart = end + closing[0].length;
|
|
177
|
+
const body = text.slice(bodyStart);
|
|
178
|
+
return { name, body };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function readMemory(cwd: string, cwdCanonical: string): Promise<string> {
|
|
182
|
+
const path = join(cwd, "AGENTS.md");
|
|
183
|
+
const canonical = confinedCanonicalPath(path, cwdCanonical);
|
|
184
|
+
if (!canonical) return "";
|
|
185
|
+
const text = await readUtf8File(canonical, MEMORY_CAP_BYTES);
|
|
186
|
+
if (text === null) return "";
|
|
187
|
+
return truncateUtf8(text, MEMORY_CAP_BYTES);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function readTaste(cwd: string, cwdCanonical: string): Promise<string | null> {
|
|
191
|
+
const path = join(cwd, ".commandcode", "taste", "taste.md");
|
|
192
|
+
const canonical = confinedCanonicalPath(path, cwdCanonical);
|
|
193
|
+
if (!canonical) return null;
|
|
194
|
+
if (!existsSync(canonical)) return null;
|
|
195
|
+
const text = await readUtf8File(canonical, TASTE_CAP_BYTES);
|
|
196
|
+
if (text === null) return null;
|
|
197
|
+
return truncateUtf8(text, TASTE_CAP_BYTES);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface SkillEntry {
|
|
201
|
+
name: string;
|
|
202
|
+
body: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number): Promise<string[]> {
|
|
206
|
+
if (scanBudget <= 0) return [];
|
|
207
|
+
const skillRootCanonical = confinedCanonicalPath(skillRoot, cwdCanonical);
|
|
208
|
+
if (!skillRootCanonical) return [];
|
|
209
|
+
let dir: Awaited<ReturnType<typeof opendir>> | undefined;
|
|
210
|
+
try {
|
|
211
|
+
return await withTimeout(
|
|
212
|
+
(async () => {
|
|
213
|
+
const openedDir = await opendir(skillRootCanonical);
|
|
214
|
+
dir = openedDir;
|
|
215
|
+
const names: string[] = [];
|
|
216
|
+
try {
|
|
217
|
+
for await (const entry of openedDir) {
|
|
218
|
+
if (entry.name.startsWith(".")) continue;
|
|
219
|
+
if (!entry.isDirectory()) continue;
|
|
220
|
+
const skillMd = join(skillRoot, entry.name, "SKILL.md");
|
|
221
|
+
const skillMdCanonical = confinedCanonicalPath(skillMd, cwdCanonical);
|
|
222
|
+
if (!skillMdCanonical) continue;
|
|
223
|
+
if (!existsSync(skillMdCanonical)) continue;
|
|
224
|
+
names.push(entry.name);
|
|
225
|
+
if (names.length >= scanBudget) break;
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
try {
|
|
229
|
+
await openedDir.close();
|
|
230
|
+
} catch {
|
|
231
|
+
/* closing a failed iterator is best-effort */
|
|
232
|
+
}
|
|
233
|
+
/* directory iteration is best-effort */
|
|
234
|
+
}
|
|
235
|
+
names.sort();
|
|
236
|
+
return names;
|
|
237
|
+
})(),
|
|
238
|
+
FILE_OP_TIMEOUT_MS,
|
|
239
|
+
);
|
|
240
|
+
} catch {
|
|
241
|
+
if (dir) {
|
|
242
|
+
try {
|
|
243
|
+
await dir.close();
|
|
244
|
+
} catch {
|
|
245
|
+
/* closing a timed-out iterator is best-effort */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string): Promise<SkillEntry | null> {
|
|
253
|
+
const path = join(skillRoot, dirName, "SKILL.md");
|
|
254
|
+
const canonical = confinedCanonicalPath(path, cwdCanonical);
|
|
255
|
+
if (!canonical) return null;
|
|
256
|
+
const text = await readUtf8File(canonical, SKILLS_XML_CAP_BYTES);
|
|
257
|
+
if (text === null) return null;
|
|
258
|
+
const { name, body } = parseSkillFrontmatter(text);
|
|
259
|
+
return { name: name ?? dirName, body };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildSkillsXml(skills: SkillEntry[]): string | null {
|
|
263
|
+
if (skills.length === 0) return null;
|
|
264
|
+
const lines = ["<skills>"];
|
|
265
|
+
let usedBytes = Buffer.byteLength(lines[0]! + "\n</skills>", "utf8");
|
|
266
|
+
|
|
267
|
+
for (const skill of skills) {
|
|
268
|
+
const open = ` <skill name="${xmlEscape(skill.name)}">`;
|
|
269
|
+
const close = "</skill>";
|
|
270
|
+
let body = skill.body;
|
|
271
|
+
let line = `${open}${xmlEscape(body)}${close}`;
|
|
272
|
+
let lineBytes = Buffer.byteLength(line + "\n", "utf8");
|
|
273
|
+
|
|
274
|
+
if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) {
|
|
275
|
+
const overhead = Buffer.byteLength(open + close + "\n", "utf8");
|
|
276
|
+
const bodyBudget = SKILLS_XML_CAP_BYTES - usedBytes - overhead;
|
|
277
|
+
if (bodyBudget <= 0) break;
|
|
278
|
+
const fittedBody = truncateUtf8BodyForXml(body, bodyBudget);
|
|
279
|
+
if (fittedBody === null) break;
|
|
280
|
+
const wasTruncated = fittedBody !== body;
|
|
281
|
+
body = fittedBody;
|
|
282
|
+
line = `${open}${xmlEscape(body)}${close}`;
|
|
283
|
+
lineBytes = Buffer.byteLength(line + "\n", "utf8");
|
|
284
|
+
if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) break;
|
|
285
|
+
lines.push(line);
|
|
286
|
+
usedBytes += lineBytes;
|
|
287
|
+
if (wasTruncated) break;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
lines.push(line);
|
|
292
|
+
usedBytes += lineBytes;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
lines.push("</skills>");
|
|
296
|
+
if (lines.length === 2) return null;
|
|
297
|
+
return lines.join("\n");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function truncateUtf8BodyForXml(body: string, capBytes: number): string | null {
|
|
301
|
+
const rawBuf = Buffer.from(body, "utf8");
|
|
302
|
+
if (Buffer.byteLength(xmlEscape(body), "utf8") <= capBytes) return body;
|
|
303
|
+
if (Buffer.byteLength(xmlEscape(TRUNCATION_MARKER), "utf8") > capBytes) return null;
|
|
304
|
+
|
|
305
|
+
// XML entities can expand a raw body by several bytes per character. Binary-search the
|
|
306
|
+
// largest UTF-8 prefix whose escaped form, including the marker, fits the actual wire cap.
|
|
307
|
+
let low = 0;
|
|
308
|
+
let high = rawBuf.length;
|
|
309
|
+
let best = TRUNCATION_MARKER;
|
|
310
|
+
while (low <= high) {
|
|
311
|
+
const mid = Math.floor((low + high) / 2);
|
|
312
|
+
let rawEnd = mid;
|
|
313
|
+
while (rawEnd > 0 && (rawBuf[rawEnd]! & 0xc0) === 0x80) rawEnd--;
|
|
314
|
+
const candidate = rawBuf.subarray(0, rawEnd).toString("utf8") + TRUNCATION_MARKER;
|
|
315
|
+
if (Buffer.byteLength(xmlEscape(candidate), "utf8") <= capBytes) {
|
|
316
|
+
best = candidate;
|
|
317
|
+
low = mid + 1;
|
|
318
|
+
} else {
|
|
319
|
+
high = mid - 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return best;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function readSkills(cwd: string, cwdCanonical: string): Promise<string | null> {
|
|
326
|
+
const seen = new Set<string>();
|
|
327
|
+
const collected: SkillEntry[] = [];
|
|
328
|
+
|
|
329
|
+
for (const rootRel of SKILL_ROOTS) {
|
|
330
|
+
const skillRoot = join(cwd, ...rootRel.split("/"));
|
|
331
|
+
const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN);
|
|
332
|
+
for (const dirName of dirs) {
|
|
333
|
+
if (collected.length >= MAX_SKILLS) break;
|
|
334
|
+
const skill = await readSkill(skillRoot, dirName, cwdCanonical);
|
|
335
|
+
if (!skill) continue;
|
|
336
|
+
if (seen.has(skill.name)) continue;
|
|
337
|
+
seen.add(skill.name);
|
|
338
|
+
collected.push(skill);
|
|
339
|
+
}
|
|
340
|
+
if (collected.length >= MAX_SKILLS) break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return buildSkillsXml(collected);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function collectProjectContext(cwd: string): Promise<CommandCodeProjectContext> {
|
|
347
|
+
const cwdCanonical = canonicalPath(cwd);
|
|
348
|
+
if (!cwdCanonical) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT };
|
|
349
|
+
|
|
350
|
+
const [memory, taste, skills] = await Promise.all([
|
|
351
|
+
readMemory(cwd, cwdCanonical),
|
|
352
|
+
readTaste(cwd, cwdCanonical),
|
|
353
|
+
readSkills(cwd, cwdCanonical),
|
|
354
|
+
]);
|
|
355
|
+
|
|
356
|
+
return { memory, taste, skills };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function loadCommandCodeProjectContext(cwd: string | undefined): Promise<CommandCodeProjectContext> {
|
|
360
|
+
if (!cwd) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT };
|
|
361
|
+
|
|
362
|
+
const hadCachedEntry = projectContextCache.has(cwd);
|
|
363
|
+
const cached = projectContextCache.get(cwd);
|
|
364
|
+
if (cached && Date.now() - cached.collectedAt < PROJECT_CONTEXT_TTL_MS) {
|
|
365
|
+
return cached.value;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const value = await collectProjectContext(cwd);
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
if (hadCachedEntry) {
|
|
371
|
+
pruneExpiredProjectContextCache(now);
|
|
372
|
+
} else {
|
|
373
|
+
pruneProjectContextCache(now);
|
|
374
|
+
}
|
|
375
|
+
projectContextCache.set(cwd, { collectedAt: now, value });
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
@@ -3,7 +3,7 @@ import { execFile as execFileCallback } from "node:child_process";
|
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { opendir } from "node:fs/promises";
|
|
5
5
|
import type { AdapterEvent, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool, OcxUsage } from "../types";
|
|
6
|
-
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName,
|
|
6
|
+
import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types";
|
|
7
7
|
import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base";
|
|
8
8
|
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
9
9
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
@@ -12,6 +12,7 @@ import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from
|
|
|
12
12
|
import { identifyRoutedModel } from "./identity";
|
|
13
13
|
import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
|
|
14
14
|
import { parseDataUrl } from "./image";
|
|
15
|
+
import { EMPTY_COMMAND_CODE_PROJECT_CONTEXT, loadCommandCodeProjectContext } from "./command-code-project-context";
|
|
15
16
|
|
|
16
17
|
// Retain the short ids emitted by the first local integration. New requests use the live catalog's
|
|
17
18
|
// provider-native IDs directly; this map is compatibility-only and is not a model fallback list.
|
|
@@ -156,8 +157,7 @@ function visibleTools(parsed: OcxParsedRequest): OcxTool[] {
|
|
|
156
157
|
if (choice === "none") return [];
|
|
157
158
|
const tools = parsed.context.tools ?? [];
|
|
158
159
|
if (isAllowedToolChoice(choice)) {
|
|
159
|
-
|
|
160
|
-
return tools.filter(tool => toolAllowedByChoice(tool, allowed, tools));
|
|
160
|
+
return tools.filter(toolChoiceToolPredicate(choice, tools));
|
|
161
161
|
}
|
|
162
162
|
if (choice && typeof choice !== "string") {
|
|
163
163
|
const selected = resolveToolChoiceWireName(tools, choice.name);
|
|
@@ -463,8 +463,11 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
|
|
|
463
463
|
...(choiceInstruction ? [choiceInstruction] : []),
|
|
464
464
|
].join("\n\n"), parsed.modelId);
|
|
465
465
|
const reasoningEffort = supportedCommandCodeEffort(provider, parsed.modelId, parsed.options.reasoning);
|
|
466
|
+
const projectContext = provider.projectContext === "on"
|
|
467
|
+
? await loadCommandCodeProjectContext(cwd)
|
|
468
|
+
: EMPTY_COMMAND_CODE_PROJECT_CONTEXT;
|
|
466
469
|
const body = {
|
|
467
|
-
config: await commandCodeConfig(cwd),
|
|
470
|
+
config: await commandCodeConfig(cwd), ...projectContext,
|
|
468
471
|
permissionMode: "standard", mode: "agent",
|
|
469
472
|
params: {
|
|
470
473
|
model: canonicalCommandCodeModelId(parsed.modelId),
|
|
@@ -231,10 +231,12 @@ async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions)
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
let status = 0;
|
|
234
|
+
let responseReceived = false;
|
|
234
235
|
const chunks: Buffer[] = [];
|
|
235
236
|
let receivedBytes = 0;
|
|
236
237
|
let bodyRejected = false;
|
|
237
238
|
req.on("response", headers => {
|
|
239
|
+
responseReceived = true;
|
|
238
240
|
status = Number(headers[":status"] ?? 0);
|
|
239
241
|
const contentLength = Number(headers["content-length"] ?? 0);
|
|
240
242
|
if (Number.isFinite(contentLength) && contentLength > CURSOR_MODEL_DISCOVERY_MAX_BYTES) {
|
|
@@ -257,6 +259,12 @@ async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions)
|
|
|
257
259
|
req.on("error", () => close({ ok: false, error: "transport", detail: "HTTP/2 request failed" }));
|
|
258
260
|
req.on("end", () => {
|
|
259
261
|
if (bodyRejected) return;
|
|
262
|
+
// A stream can end after an HTTP/2 session/reset without ever delivering response headers.
|
|
263
|
+
// Treat status 0 as a transient transport failure so the bounded discovery retry can recover;
|
|
264
|
+
// reporting it as `http/HTTP unknown` makes the pre-response failure non-retryable.
|
|
265
|
+
if (!responseReceived || status === 0) {
|
|
266
|
+
return close({ ok: false, error: "transport", detail: "HTTP/2 stream ended before response headers" });
|
|
267
|
+
}
|
|
260
268
|
if (status === 401 || status === 403) {
|
|
261
269
|
return close({ ok: false, error: "auth", detail: `HTTP ${status}` });
|
|
262
270
|
}
|
|
@@ -55,12 +55,18 @@ import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, i
|
|
|
55
55
|
import { mcpArgsFromToolCall } from "./protobuf-events";
|
|
56
56
|
import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions";
|
|
57
57
|
import {
|
|
58
|
+
cursorUnsafeNativeLocalExecEnabled,
|
|
58
59
|
handleCursorNativeExec,
|
|
59
60
|
handleCursorNativeKv,
|
|
60
61
|
releaseCursorBlobRequestScope,
|
|
61
62
|
type CursorBlobRequestScopeToken,
|
|
62
63
|
type CursorNativeExecContext,
|
|
63
64
|
} from "./native-exec";
|
|
65
|
+
import {
|
|
66
|
+
advertisedBareCodexShellBridgeName,
|
|
67
|
+
nativeExecBridgeToMcpExec,
|
|
68
|
+
planNativeExecBridge,
|
|
69
|
+
} from "./native-exec-bridge";
|
|
64
70
|
import { effectiveCursorNativeExecAllow } from "./exec-policy";
|
|
65
71
|
import { resolveMcpServers } from "./mcp-config";
|
|
66
72
|
import { CursorMcpManager } from "./mcp-manager";
|
|
@@ -1448,6 +1454,21 @@ class LiveCursorTransport implements CursorTransport {
|
|
|
1448
1454
|
return;
|
|
1449
1455
|
}
|
|
1450
1456
|
}
|
|
1457
|
+
const advertisedShellBridgeName = advertisedBareCodexShellBridgeName(state.clientToolNames ?? []);
|
|
1458
|
+
const nativePlan = planNativeExecBridge(execMsg, {
|
|
1459
|
+
nativeLocalExecEnabled: cursorUnsafeNativeLocalExecEnabled(this.execContext),
|
|
1460
|
+
advertisedShellBridgeName,
|
|
1461
|
+
});
|
|
1462
|
+
if (nativePlan.bridge) {
|
|
1463
|
+
const plan = planMcpArgsHandling(nativeExecBridgeToMcpExec(execMsg, nativePlan), state);
|
|
1464
|
+
if (plan.handledByResponsesBridge) {
|
|
1465
|
+
this.noteClientToolActivity();
|
|
1466
|
+
for (const event of plan.events) push(event);
|
|
1467
|
+
if (plan.cancelCursorRun) this.cancelCursorRun();
|
|
1468
|
+
else if (plan.finalizeWhenDrained) this.scheduleClientToolFinalize(state, push);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1451
1472
|
// Native exec/MCP is handled inside this transport and can mutate files/process state
|
|
1452
1473
|
// without emitting a Responses tool event. Mark the turn replay-unsafe before executing so
|
|
1453
1474
|
// an eventual invalid_argument cannot cause the adapter's fresh-conversation fallback to
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { create } from "@bufbuild/protobuf";
|
|
2
|
+
import {
|
|
3
|
+
ExecServerMessageSchema,
|
|
4
|
+
McpArgsSchema,
|
|
5
|
+
type ExecServerMessage,
|
|
6
|
+
} from "./gen/agent_pb";
|
|
7
|
+
import {
|
|
8
|
+
CODEX_SHELL_BRIDGE_TOOL_NAMES,
|
|
9
|
+
isCodexShellBridgeToolName,
|
|
10
|
+
normalizeCursorWireName,
|
|
11
|
+
OCX_RESPONSES_TOOL_PROVIDER,
|
|
12
|
+
} from "./tool-definitions";
|
|
13
|
+
|
|
14
|
+
export type NativeExecBridgePlan =
|
|
15
|
+
| { bridge: false }
|
|
16
|
+
| {
|
|
17
|
+
bridge: true;
|
|
18
|
+
toolName: string;
|
|
19
|
+
toolCallId: string;
|
|
20
|
+
args: { command: string; workdir?: string };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function posixSingleQuote(value: string): string {
|
|
24
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function advertisedBareCodexShellBridgeName(clientToolNames: Iterable<string>): string | undefined {
|
|
28
|
+
const advertised = new Set(
|
|
29
|
+
[...clientToolNames]
|
|
30
|
+
.map(normalizeCursorWireName)
|
|
31
|
+
.filter(isCodexShellBridgeToolName),
|
|
32
|
+
);
|
|
33
|
+
for (const bridgeName of CODEX_SHELL_BRIDGE_TOOL_NAMES) {
|
|
34
|
+
if (advertised.has(bridgeName)) return bridgeName;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function planNativeExecBridge(
|
|
40
|
+
execMsg: ExecServerMessage,
|
|
41
|
+
opts: { nativeLocalExecEnabled: boolean; advertisedShellBridgeName?: string },
|
|
42
|
+
): NativeExecBridgePlan {
|
|
43
|
+
if (opts.nativeLocalExecEnabled || !opts.advertisedShellBridgeName) return { bridge: false };
|
|
44
|
+
|
|
45
|
+
const execCase = execMsg.message.case;
|
|
46
|
+
if (!execCase) return { bridge: false };
|
|
47
|
+
|
|
48
|
+
let toolCallId = `exec_${execMsg.id}`;
|
|
49
|
+
let command = "";
|
|
50
|
+
let workdir: string | undefined;
|
|
51
|
+
|
|
52
|
+
switch (execCase) {
|
|
53
|
+
case "shellArgs":
|
|
54
|
+
case "shellStreamArgs": {
|
|
55
|
+
const args = execMsg.message.value;
|
|
56
|
+
command = args.command.trim();
|
|
57
|
+
if (args.workingDirectory.trim()) workdir = args.workingDirectory.trim();
|
|
58
|
+
if (args.toolCallId) toolCallId = args.toolCallId;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "backgroundShellSpawnArgs": {
|
|
62
|
+
const args = execMsg.message.value;
|
|
63
|
+
command = args.command.trim();
|
|
64
|
+
if (args.workingDirectory.trim()) workdir = args.workingDirectory.trim();
|
|
65
|
+
if (args.toolCallId) toolCallId = args.toolCallId;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "readArgs": {
|
|
69
|
+
const args = execMsg.message.value;
|
|
70
|
+
const path = args.path.trim();
|
|
71
|
+
if (!path) return { bridge: false };
|
|
72
|
+
command = `cat -- ${posixSingleQuote(path)}`;
|
|
73
|
+
if (args.toolCallId) toolCallId = args.toolCallId;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case "lsArgs": {
|
|
77
|
+
const args = execMsg.message.value;
|
|
78
|
+
const path = args.path.trim();
|
|
79
|
+
if (!path) return { bridge: false };
|
|
80
|
+
command = `ls -la -- ${posixSingleQuote(path)}`;
|
|
81
|
+
if (args.toolCallId) toolCallId = args.toolCallId;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "grepArgs": {
|
|
85
|
+
const args = execMsg.message.value;
|
|
86
|
+
const pattern = args.pattern.trim();
|
|
87
|
+
if (!pattern) return { bridge: false };
|
|
88
|
+
const path = args.path?.trim();
|
|
89
|
+
command = path
|
|
90
|
+
? `rg -n -- ${posixSingleQuote(pattern)} ${posixSingleQuote(path)}`
|
|
91
|
+
: `rg -n -- ${posixSingleQuote(pattern)}`;
|
|
92
|
+
if (args.toolCallId) toolCallId = args.toolCallId;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "fetchArgs": {
|
|
96
|
+
const args = execMsg.message.value;
|
|
97
|
+
const url = args.url.trim();
|
|
98
|
+
if (!url) return { bridge: false };
|
|
99
|
+
command = `curl -fsSL -- ${posixSingleQuote(url)}`;
|
|
100
|
+
if (args.toolCallId) toolCallId = args.toolCallId;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
return { bridge: false };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!command) return { bridge: false };
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
bridge: true,
|
|
111
|
+
toolName: opts.advertisedShellBridgeName,
|
|
112
|
+
toolCallId,
|
|
113
|
+
args: workdir ? { command, workdir } : { command },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function nativeExecBridgeToMcpExec(
|
|
118
|
+
execMsg: ExecServerMessage,
|
|
119
|
+
plan: Extract<NativeExecBridgePlan, { bridge: true }>,
|
|
120
|
+
): ExecServerMessage {
|
|
121
|
+
const args: Record<string, Uint8Array> = {
|
|
122
|
+
command: new TextEncoder().encode(JSON.stringify(plan.args.command)),
|
|
123
|
+
};
|
|
124
|
+
if (plan.args.workdir) {
|
|
125
|
+
args.workdir = new TextEncoder().encode(JSON.stringify(plan.args.workdir));
|
|
126
|
+
}
|
|
127
|
+
return create(ExecServerMessageSchema, {
|
|
128
|
+
id: execMsg.id,
|
|
129
|
+
execId: execMsg.execId,
|
|
130
|
+
message: {
|
|
131
|
+
case: "mcpArgs",
|
|
132
|
+
value: create(McpArgsSchema, {
|
|
133
|
+
name: plan.toolName,
|
|
134
|
+
toolName: plan.toolName,
|
|
135
|
+
toolCallId: plan.toolCallId,
|
|
136
|
+
providerIdentifier: OCX_RESPONSES_TOOL_PROVIDER,
|
|
137
|
+
args,
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -122,6 +122,10 @@ function recordScreenFailure(error: string): RecordScreenResult {
|
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
function isBrokenPipe(err: unknown): boolean {
|
|
126
|
+
return Boolean(err && typeof err === "object" && "code" in err && (err as NodeJS.ErrnoException).code === "EPIPE");
|
|
127
|
+
}
|
|
128
|
+
|
|
125
129
|
/**
|
|
126
130
|
* Spawn `command` via the platform shell (sh -c on POSIX, cmd.exe /d /s /c on win32 —
|
|
127
131
|
* the configured command is platform-native shell syntax; devlog
|
|
@@ -170,10 +174,22 @@ function runExternalJson(command: string, payload: unknown, config: DesktopExecu
|
|
|
170
174
|
}
|
|
171
175
|
});
|
|
172
176
|
|
|
177
|
+
// A child that prints and exits without reading stdin (echo, exit N) closes
|
|
178
|
+
// the pipe under the write. The async EPIPE is expected; wait for `close`
|
|
179
|
+
// and parse whatever stdout we got. An unhandled stdin error event would
|
|
180
|
+
// otherwise escape the Promise and fail the caller as an uncaught exception.
|
|
181
|
+
child.stdin.on("error", err => {
|
|
182
|
+
if (isBrokenPipe(err)) return;
|
|
183
|
+
if (settled) return;
|
|
184
|
+
settled = true;
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
reject(err);
|
|
187
|
+
});
|
|
173
188
|
try {
|
|
174
189
|
child.stdin.write(JSON.stringify(payload));
|
|
175
190
|
child.stdin.end();
|
|
176
191
|
} catch (err) {
|
|
192
|
+
if (isBrokenPipe(err)) return;
|
|
177
193
|
if (!settled) {
|
|
178
194
|
settled = true;
|
|
179
195
|
clearTimeout(timer);
|
|
@@ -651,7 +651,7 @@ export function buildCursorToolGuidanceSystemNote(
|
|
|
651
651
|
// Code mode: shell/edit/MCP live inside freeform `exec` as nested helpers. Without this the
|
|
652
652
|
// model probes for a top-level shell tool that is not there.
|
|
653
653
|
codeMode
|
|
654
|
-
? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.<name>(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description and the isolate global \`ALL_TOOLS\` (not \`tools.ALL_TOOLS\`) for helpers this turn provides; absence from the top-level catalog or from \`exec\`'s description is not absence. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\` or \`shell_command\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}.`
|
|
654
|
+
? `\`${CODEX_UNIFIED_EXEC_TOOL}\` is Codex code mode: its body is JavaScript evaluated in a V8 isolate, not a shell command and not Node. Shell, file edits, and MCP are nested helpers called INSIDE that body as \`await tools.<name>(...)\`, for example \`await tools.exec_command({cmd: \"ls\"})\`. Read the tool description and the isolate global \`ALL_TOOLS\` (not \`tools.ALL_TOOLS\`) for helpers this turn provides; absence from the top-level catalog or from \`exec\`'s description is not absence. Those nested helpers are not themselves top-level tools, so do not call \`exec_command\` or \`shell_command\` at the top level here${codeModeOtherTopLevelNames.length > 0 ? `; every other tool this turn lists, including ${quotedNames(codeModeOtherTopLevelNames)}, remains callable at the top level as usual` : ""}. Nested \`tools.apply_patch(input)\` is host-executed: the string must begin exactly with \`*** Begin Patch\` and end with \`*** End Patch\` (no trailing \`***\` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated \`*** Begin Patch ***\` envelope is rejected by Codex before the file is touched.`
|
|
655
655
|
: undefined,
|
|
656
656
|
codeMode
|
|
657
657
|
? "In code mode the isolate returns nothing on its own: call `text(...)` (or `notify(...)`) on any value you need to see, or the call completes with empty output. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers."
|