@bitkyc08/opencodex 2.69.0 → 2.70.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/gui/dist/assets/App-_MrbTrBe.js +51 -0
- package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
- package/gui/dist/assets/index-DKrO2eF3.js +86 -0
- package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-model-contract.ts +119 -0
- package/src/adapters/anthropic.ts +14 -83
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code-project-context.ts +542 -0
- package/src/adapters/command-code-tool-text.ts +99 -67
- package/src/adapters/command-code.ts +5 -1
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
- package/src/adapters/devin/cloud-direct/chat.ts +78 -48
- package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
- package/src/adapters/devin/context-overflow.ts +75 -0
- package/src/adapters/devin/live-models.ts +174 -13
- package/src/adapters/devin/reasoning-signature.ts +100 -0
- package/src/adapters/devin.ts +310 -117
- package/src/adapters/kiro/payload.ts +10 -10
- package/src/adapters/kiro-images.ts +58 -7
- package/src/adapters/openai-chat/passthrough.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -1
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/bridge/response-json.ts +15 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/intercept/model-bindings.ts +1 -0
- package/src/cli/account-extended.ts +31 -1
- package/src/cli/account.ts +3 -0
- package/src/cli/capabilities.ts +13 -0
- package/src/cli/client-start-fence.ts +36 -0
- package/src/cli/dispatch.ts +3 -2
- package/src/cli/index.ts +98 -117
- package/src/cli/provider-runtime.ts +23 -2
- package/src/cli/provider.ts +3 -0
- package/src/cli/resolve.ts +18 -0
- package/src/cli/start-owner-fence.ts +30 -0
- package/src/cli/stop-approval.ts +13 -1
- package/src/cli/stop-restore.ts +73 -0
- package/src/cli/tray-proxy.ts +221 -18
- package/src/cli/version-skew.ts +25 -23
- package/src/client/runtime.ts +8 -4
- package/src/codex/auth-api/account-list.ts +11 -5
- package/src/codex/auth-api/login-flow.ts +5 -3
- package/src/codex/auth-api/main-account-probe.ts +186 -110
- package/src/codex/auth-api/pool-mode-gate.ts +61 -7
- package/src/codex/auth-api/pool-quota-probe.ts +142 -90
- package/src/codex/auth-api/reset-credit-service.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +1 -0
- package/src/codex/catalog/model-hints.ts +3 -3
- package/src/codex/catalog/provider-models.ts +2 -0
- package/src/codex/low-quota-events.ts +28 -0
- package/src/codex/low-quota-observer.ts +23 -0
- package/src/codex/low-quota-protection.ts +272 -0
- package/src/codex/quota-query-backoff.ts +148 -0
- package/src/codex/quota.ts +7 -2
- package/src/config/diagnostics.ts +10 -1
- package/src/config/load-degrade.ts +17 -0
- package/src/config/mutation-lock.ts +3 -4
- package/src/config/process-state.ts +1 -1
- package/src/config/provider-validation.ts +22 -0
- package/src/config/schema/blocked-model-redirects.ts +17 -0
- package/src/config/schema/config-schema.ts +4 -1
- package/src/config/schema/leaf-validators.ts +33 -2
- package/src/config/serving-runtimes.ts +412 -0
- package/src/generated/compatibility-version.json +211 -115
- package/src/generated/model-metadata.ts +3 -3
- package/src/images/loop.ts +3 -0
- package/src/lib/errors.ts +2 -1
- package/src/lib/handled-signal-exit.ts +23 -0
- package/src/lib/request-failure-model.ts +1 -0
- package/src/lib/self-launch-argv.ts +9 -0
- package/src/lib/shadow-call.ts +5 -2
- package/src/lib/strict-semver.ts +26 -0
- package/src/lib/transient-service-path.ts +16 -0
- package/src/lib/windows-service-wrappers.ts +10 -6
- package/src/lib/winsw.ts +3 -1
- package/src/oauth/anthropic-model-routes.ts +105 -0
- package/src/oauth/anthropic-routing.ts +85 -48
- package/src/oauth/devin.ts +135 -5
- package/src/oauth/index.ts +20 -3
- package/src/oauth/kiro-terminal-failover.ts +23 -9
- package/src/oauth/pool-settings-capability.ts +10 -2
- package/src/oauth/store.ts +1 -1
- package/src/protocols/encoders/adapter-events.ts +3 -1
- package/src/providers/github-copilot-context.ts +27 -0
- package/src/providers/kiro-models.ts +4 -0
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/quota/account-cache.ts +8 -3
- package/src/providers/quota/devin.ts +286 -0
- package/src/providers/quota/vendor-probes-oauth.ts +6 -2
- package/src/providers/quota.ts +1 -1
- package/src/providers/registry/entries-core.ts +1 -1
- package/src/providers/registry/model-seeds.ts +5 -2
- package/src/responses/compaction-images.ts +27 -0
- package/src/router.ts +104 -16
- package/src/routing/analytics.ts +1 -0
- package/src/server/auth-cors.ts +9 -1
- package/src/server/background-lifecycle.ts +21 -10
- package/src/server/chat-completions.ts +2 -1
- package/src/server/chat-native.ts +1 -0
- package/src/server/index.ts +4 -8
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/management/context.ts +3 -0
- package/src/server/management/low-quota-routes.ts +14 -0
- package/src/server/management/oauth-account-routes.ts +21 -1
- package/src/server/management/provider-overwrite-carry.ts +79 -6
- package/src/server/management/provider-routes.ts +41 -28
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management-api.ts +7 -0
- package/src/server/responses/adapter-continuation.ts +3 -0
- package/src/server/responses/adapter-delivery.ts +3 -0
- package/src/server/responses/adapter-dispatch.ts +20 -4
- package/src/server/responses/antigravity-validation-refusal.ts +20 -0
- package/src/server/responses/core-auth.ts +1 -0
- package/src/server/responses/core-codex-account.ts +1 -0
- package/src/server/responses/core-normalize.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +8 -1
- package/src/server/responses/request-sidecar-auth.ts +2 -0
- package/src/server/responses/request-transport.ts +27 -10
- package/src/server/responses/response-effects.ts +4 -1
- package/src/server/responses/run-turn-execution.ts +126 -42
- package/src/server/responses/sidecar-execution.ts +4 -1
- package/src/service/diagnostics.ts +3 -1
- package/src/service/guarded-manager-target.ts +39 -12
- package/src/service/launchd.ts +14 -6
- package/src/service/repair.ts +6 -1
- package/src/service/service-child-ownership.ts +168 -0
- package/src/service/state.ts +22 -4
- package/src/service/systemd.ts +2 -2
- package/src/service/windows-guarded-manager.ts +530 -0
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service.ts +2 -0
- package/src/types/config.ts +26 -0
- package/src/types/provider.ts +12 -0
- package/src/types/request.ts +2 -0
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +13 -0
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/vision/anthropic-describe.ts +2 -1
- package/src/web-search/anthropic-executor.ts +2 -1
- package/src/web-search/loop.ts +1 -0
- package/gui/dist/assets/App-DDbgOTh6.js +0 -51
- package/gui/dist/assets/index-CuaBJkG4.js +0 -86
- package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, opendir, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, 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 SKILL_FILE_CAP_BYTES = 8_192;
|
|
21
|
+
const SKILLS_READ_CAP_BYTES = 32_768;
|
|
22
|
+
const MAX_SKILLS = 16;
|
|
23
|
+
// Every visited entry, including hidden files and invalid directories, consumes this
|
|
24
|
+
// per-root scan budget. Selection remains separately capped by MAX_SKILLS.
|
|
25
|
+
const MAX_SKILL_DIRS_TO_SCAN = 256;
|
|
26
|
+
const COMMAND_CODE_FILE_OP_TIMEOUT_MS = 2_000;
|
|
27
|
+
let fileOpTimeoutForTests: number | undefined;
|
|
28
|
+
let beforeOpenForTests: ((path: string) => void | Promise<void>) | undefined;
|
|
29
|
+
|
|
30
|
+
/** Test seam for deterministic path replacement between confinement and open. */
|
|
31
|
+
export function setCommandCodeBeforeOpenForTests(hook: typeof beforeOpenForTests): void {
|
|
32
|
+
beforeOpenForTests = hook;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setCommandCodeFileOpTimeoutForTests(timeoutMs: number | undefined): void {
|
|
36
|
+
fileOpTimeoutForTests = timeoutMs;
|
|
37
|
+
}
|
|
38
|
+
const PROJECT_CONTEXT_TTL_MS = 30_000;
|
|
39
|
+
const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128;
|
|
40
|
+
const MAX_CONCURRENT_PROJECT_CONTEXT_SCANS = 8;
|
|
41
|
+
const MAX_PENDING_PROJECT_CONTEXT_FILE_OPS = 64;
|
|
42
|
+
class ProjectContextTimeoutError extends Error {}
|
|
43
|
+
class ProjectContextAdmissionError extends Error {}
|
|
44
|
+
const projectContextInFlight = new Map<string, Promise<CommandCodeProjectContext>>();
|
|
45
|
+
type ScanScope = { cwd: string; pendingOps: number; finished: boolean; degraded: boolean };
|
|
46
|
+
const outstandingProjectContextScans = new Map<string, ScanScope>();
|
|
47
|
+
const pendingProjectContextFileOps = new Set<Promise<unknown>>();
|
|
48
|
+
|
|
49
|
+
/** Test-only observation of scan admission and abandoned filesystem work. */
|
|
50
|
+
export function commandCodeProjectContextWorkCountsForTests(): { inFlight: number; outstandingScans: number; pendingFileOps: number } {
|
|
51
|
+
return {
|
|
52
|
+
inFlight: projectContextInFlight.size,
|
|
53
|
+
outstandingScans: outstandingProjectContextScans.size,
|
|
54
|
+
pendingFileOps: pendingProjectContextFileOps.size,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TRUNCATION_MARKER = "\n<!-- truncated -->";
|
|
59
|
+
|
|
60
|
+
const SKILL_ROOTS = [
|
|
61
|
+
".commandcode/skills",
|
|
62
|
+
".agents/skills",
|
|
63
|
+
".pi/skills",
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
export const projectContextCache = new Map<string, { collectedAt: number; value: CommandCodeProjectContext }>();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Evict expired entries first, then the oldest live entry if at capacity.
|
|
70
|
+
* Called before inserting a new key so the cache never exceeds the cap.
|
|
71
|
+
*/
|
|
72
|
+
function pruneExpiredProjectContextCache(now: number): void {
|
|
73
|
+
for (const [key, entry] of projectContextCache) {
|
|
74
|
+
if (now - entry.collectedAt >= PROJECT_CONTEXT_TTL_MS) {
|
|
75
|
+
projectContextCache.delete(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function pruneProjectContextCache(now: number): void {
|
|
81
|
+
pruneExpiredProjectContextCache(now);
|
|
82
|
+
if (projectContextCache.size >= MAX_PROJECT_CONTEXT_CACHE_ENTRIES) {
|
|
83
|
+
let oldestKey: string | null = null;
|
|
84
|
+
let oldestAt = Infinity;
|
|
85
|
+
for (const [key, entry] of projectContextCache) {
|
|
86
|
+
if (entry.collectedAt < oldestAt) {
|
|
87
|
+
oldestAt = entry.collectedAt;
|
|
88
|
+
oldestKey = key;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (oldestKey !== null) projectContextCache.delete(oldestKey);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A timed-out scan retains its cwd slot until every operation it dispatched settles. */
|
|
96
|
+
function releaseScanIfSettled(scope: ScanScope): void {
|
|
97
|
+
if (scope.finished && scope.pendingOps === 0 && outstandingProjectContextScans.get(scope.cwd) === scope) {
|
|
98
|
+
outstandingProjectContextScans.delete(scope.cwd);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function trackedFileOperation<T>(operation: () => Promise<T>, scope: ScanScope, enforceLimit = true): Promise<T> {
|
|
103
|
+
if (enforceLimit && pendingProjectContextFileOps.size >= MAX_PENDING_PROJECT_CONTEXT_FILE_OPS) {
|
|
104
|
+
throw new ProjectContextAdmissionError("project context filesystem admission limit");
|
|
105
|
+
}
|
|
106
|
+
const pending = operation();
|
|
107
|
+
scope.pendingOps++;
|
|
108
|
+
pendingProjectContextFileOps.add(pending);
|
|
109
|
+
const settle = (): void => {
|
|
110
|
+
pendingProjectContextFileOps.delete(pending);
|
|
111
|
+
scope.pendingOps--;
|
|
112
|
+
releaseScanIfSettled(scope);
|
|
113
|
+
};
|
|
114
|
+
void pending.then(settle, settle);
|
|
115
|
+
return pending;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Keep filesystem metadata work off the request thread and inside one load deadline. */
|
|
119
|
+
async function withinDeadline<T>(operation: () => Promise<T>, deadline: number, scope: ScanScope): Promise<T> {
|
|
120
|
+
const remaining = deadline - Date.now();
|
|
121
|
+
if (remaining <= 0) {
|
|
122
|
+
scope.degraded = true;
|
|
123
|
+
throw new ProjectContextTimeoutError("timeout");
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return await withTimeout(trackedFileOperation(operation, scope), remaining);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof ProjectContextTimeoutError || error instanceof ProjectContextAdmissionError) scope.degraded = true;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Fail-soft canonical path; no synchronous filesystem calls run on the request thread. */
|
|
134
|
+
async function canonicalPath(candidate: string, deadline: number, scope: ScanScope): Promise<string | null> {
|
|
135
|
+
try {
|
|
136
|
+
await withinDeadline(() => lstat(candidate), deadline, scope);
|
|
137
|
+
return await withinDeadline(() => realpath(candidate), deadline, scope);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizePathIdentity(path: string): string {
|
|
144
|
+
return process.platform === "win32" ? path.toLowerCase() : path;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Relative paths also work when cwd is a filesystem root; other volumes remain outside. */
|
|
148
|
+
export function isContainedCanonicalPath(cwdCanonical: string, fileCanonical: string): boolean {
|
|
149
|
+
const rel = relative(normalizePathIdentity(cwdCanonical), normalizePathIdentity(fileCanonical));
|
|
150
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function confinedCanonicalPath(
|
|
154
|
+
filePath: string, cwdCanonical: string, deadline: number, kind: "file" | "directory", scope: ScanScope,
|
|
155
|
+
): Promise<string | null> {
|
|
156
|
+
const canonical = await canonicalPath(filePath, deadline, scope);
|
|
157
|
+
if (!canonical || !isContainedCanonicalPath(cwdCanonical, canonical)) return null;
|
|
158
|
+
try {
|
|
159
|
+
const info = await withinDeadline(() => stat(canonical), deadline, scope);
|
|
160
|
+
return (kind === "file" ? info.isFile() : info.isDirectory()) ? canonical : null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
167
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
168
|
+
try {
|
|
169
|
+
return await Promise.race([
|
|
170
|
+
promise,
|
|
171
|
+
new Promise<never>((_, reject) => {
|
|
172
|
+
timer = setTimeout(() => reject(new ProjectContextTimeoutError("timeout")), ms);
|
|
173
|
+
}),
|
|
174
|
+
]);
|
|
175
|
+
} finally {
|
|
176
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function truncateUtf8(text: string, capBytes: number): string {
|
|
181
|
+
const buf = Buffer.from(text, "utf8");
|
|
182
|
+
if (buf.length <= capBytes) return text;
|
|
183
|
+
const markerBuf = Buffer.from(TRUNCATION_MARKER, "utf8");
|
|
184
|
+
const prefixCap = capBytes - markerBuf.length;
|
|
185
|
+
if (prefixCap <= 0) return TRUNCATION_MARKER.slice(0, capBytes);
|
|
186
|
+
let end = prefixCap;
|
|
187
|
+
while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--;
|
|
188
|
+
return buf.subarray(0, end).toString("utf8") + TRUNCATION_MARKER;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Match the opened inode to a still-canonical path inside cwd before publishing bytes. */
|
|
192
|
+
async function openedFileIsConfined(
|
|
193
|
+
handle: Awaited<ReturnType<typeof open>>, path: string, cwdCanonical: string, deadline: number, scope: ScanScope,
|
|
194
|
+
): Promise<boolean> {
|
|
195
|
+
const opened = await withinDeadline(() => handle.stat(), deadline, scope);
|
|
196
|
+
if (!opened.isFile()) return false;
|
|
197
|
+
const current = await withinDeadline(() => lstat(path), deadline, scope);
|
|
198
|
+
if (!current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) return false;
|
|
199
|
+
const resolved = await withinDeadline(() => realpath(path), deadline, scope);
|
|
200
|
+
// The input path was canonical before open. A changed intermediate symlink changes this
|
|
201
|
+
// result even though O_NOFOLLOW protects only the final component on macOS and Linux.
|
|
202
|
+
if (!isContainedCanonicalPath(cwdCanonical, resolved)
|
|
203
|
+
|| normalizePathIdentity(resolved) !== normalizePathIdentity(path)) return false;
|
|
204
|
+
const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline, scope);
|
|
205
|
+
return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function readUtf8File(path: string, capBytes: number, deadline: number, cwdCanonical: string, scope: ScanScope): Promise<string | null> {
|
|
209
|
+
type FileHandle = Awaited<ReturnType<typeof open>>;
|
|
210
|
+
let fileHandle: FileHandle | undefined;
|
|
211
|
+
const closedHandles = new WeakSet<object>();
|
|
212
|
+
if (beforeOpenForTests) {
|
|
213
|
+
try {
|
|
214
|
+
await withinDeadline(() => Promise.resolve(beforeOpenForTests!(path)), deadline, scope);
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const remaining = deadline - Date.now();
|
|
220
|
+
if (remaining <= 0) {
|
|
221
|
+
scope.degraded = true;
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
// O_NONBLOCK prevents a race that swaps a checked regular file for a FIFO.
|
|
225
|
+
// Windows lacks these POSIX open guards; post-open path/identity checks remain best-effort there.
|
|
226
|
+
const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW;
|
|
227
|
+
let opened: ReturnType<typeof open>;
|
|
228
|
+
try {
|
|
229
|
+
opened = trackedFileOperation(() => open(path, flags), scope);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof ProjectContextAdmissionError) scope.degraded = true;
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const closeBestEffort = (handle: FileHandle): Promise<void> => {
|
|
235
|
+
if (closedHandles.has(handle)) return Promise.resolve();
|
|
236
|
+
closedHandles.add(handle);
|
|
237
|
+
return Promise.resolve()
|
|
238
|
+
.then(() => trackedFileOperation(() => handle.close(), scope, false))
|
|
239
|
+
.catch(() => {
|
|
240
|
+
/* closing a timed-out read is best-effort */
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
let read: Promise<string | null>;
|
|
245
|
+
try {
|
|
246
|
+
read = trackedFileOperation(async () => {
|
|
247
|
+
const handle = await opened;
|
|
248
|
+
fileHandle = handle;
|
|
249
|
+
try {
|
|
250
|
+
if (Date.now() >= deadline || !await openedFileIsConfined(handle, path, cwdCanonical, deadline, scope)) return null;
|
|
251
|
+
const data = Buffer.alloc(capBytes + 1);
|
|
252
|
+
const { bytesRead } = await handle.read(data, 0, data.length, 0);
|
|
253
|
+
// Do not return bytes if an intermediate directory changed while the read was pending.
|
|
254
|
+
if (!await openedFileIsConfined(handle, path, cwdCanonical, deadline, scope)) return null;
|
|
255
|
+
return data.subarray(0, bytesRead).toString("utf8");
|
|
256
|
+
} finally {
|
|
257
|
+
await closeBestEffort(handle);
|
|
258
|
+
if (fileHandle === handle) fileHandle = undefined;
|
|
259
|
+
}
|
|
260
|
+
}, scope);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (error instanceof ProjectContextAdmissionError) scope.degraded = true;
|
|
263
|
+
void opened.then(handle => closeBestEffort(handle), () => undefined);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
return await withTimeout(read, remaining);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (error instanceof ProjectContextTimeoutError || error instanceof ProjectContextAdmissionError) scope.degraded = true;
|
|
271
|
+
if (fileHandle) void closeBestEffort(fileHandle);
|
|
272
|
+
void opened.then(handle => closeBestEffort(handle), () => undefined);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function xmlEscape(value: string): string {
|
|
278
|
+
return value
|
|
279
|
+
.replace(/&/g, "&")
|
|
280
|
+
.replace(/</g, "<")
|
|
281
|
+
.replace(/>/g, ">")
|
|
282
|
+
.replace(/"/g, """);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseSkillFrontmatter(text: string): { name: string | null; body: string } {
|
|
286
|
+
const opening = text.startsWith("---\r\n") ? "---\r\n" : text.startsWith("---\n") ? "---\n" : null;
|
|
287
|
+
if (!opening) return { name: null, body: text };
|
|
288
|
+
const closing = text.slice(opening.length).match(/^---(?:\r?\n|$)/m);
|
|
289
|
+
if (!closing || closing.index === undefined) return { name: null, body: text };
|
|
290
|
+
const end = opening.length + closing.index;
|
|
291
|
+
const frontmatter = text.slice(opening.length, end).replace(/\r?\n$/, "");
|
|
292
|
+
let name: string | null = null;
|
|
293
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
294
|
+
const match = line.match(/^name:\s*(.+)$/);
|
|
295
|
+
if (match) {
|
|
296
|
+
const parsed = match[1]!.trim();
|
|
297
|
+
if (parsed.length > 0) name = parsed;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const bodyStart = end + closing[0].length;
|
|
302
|
+
const body = text.slice(bodyStart);
|
|
303
|
+
return { name, body };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function readMemory(cwd: string, cwdCanonical: string, deadline: number, scope: ScanScope): Promise<string> {
|
|
307
|
+
const path = join(cwd, "AGENTS.md");
|
|
308
|
+
const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file", scope);
|
|
309
|
+
if (!canonical) return "";
|
|
310
|
+
const text = await readUtf8File(canonical, MEMORY_CAP_BYTES, deadline, cwdCanonical, scope);
|
|
311
|
+
if (text === null) return "";
|
|
312
|
+
return truncateUtf8(text, MEMORY_CAP_BYTES);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function readTaste(cwd: string, cwdCanonical: string, deadline: number, scope: ScanScope): Promise<string | null> {
|
|
316
|
+
const path = join(cwd, ".commandcode", "taste", "taste.md");
|
|
317
|
+
const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file", scope);
|
|
318
|
+
if (!canonical) return null;
|
|
319
|
+
const text = await readUtf8File(canonical, TASTE_CAP_BYTES, deadline, cwdCanonical, scope);
|
|
320
|
+
if (text === null) return null;
|
|
321
|
+
return truncateUtf8(text, TASTE_CAP_BYTES);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface SkillEntry {
|
|
325
|
+
name: string;
|
|
326
|
+
body: string;
|
|
327
|
+
bytesRead: number;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function closeSkillDirectoryBestEffort(dir: Awaited<ReturnType<typeof opendir>>, scope: ScanScope): void {
|
|
331
|
+
try {
|
|
332
|
+
void trackedFileOperation(() => dir.close(), scope, false).catch(() => undefined);
|
|
333
|
+
} catch {
|
|
334
|
+
/* a failed cleanup must not reject the request */
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number, deadline: number, scope: ScanScope): Promise<string[]> {
|
|
339
|
+
if (scanBudget <= 0) return [];
|
|
340
|
+
const skillRootCanonical = await confinedCanonicalPath(skillRoot, cwdCanonical, deadline, "directory", scope);
|
|
341
|
+
if (!skillRootCanonical) return [];
|
|
342
|
+
let dir: Awaited<ReturnType<typeof opendir>> | undefined;
|
|
343
|
+
try {
|
|
344
|
+
return await withinDeadline(
|
|
345
|
+
async () => {
|
|
346
|
+
const openedDir = await opendir(skillRootCanonical);
|
|
347
|
+
dir = openedDir;
|
|
348
|
+
if (Date.now() >= deadline) {
|
|
349
|
+
closeSkillDirectoryBestEffort(openedDir, scope);
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
const names: string[] = [];
|
|
353
|
+
let visitedEntries = 0;
|
|
354
|
+
try {
|
|
355
|
+
for await (const entry of openedDir) {
|
|
356
|
+
if (Date.now() >= deadline) break;
|
|
357
|
+
visitedEntries++;
|
|
358
|
+
const atLimit = visitedEntries >= scanBudget;
|
|
359
|
+
if (!entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink())) {
|
|
360
|
+
const skillMd = join(skillRoot, entry.name, "SKILL.md");
|
|
361
|
+
const skillMdCanonical = await confinedCanonicalPath(skillMd, cwdCanonical, deadline, "file", scope);
|
|
362
|
+
if (skillMdCanonical) {
|
|
363
|
+
names.push(entry.name);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (atLimit) break;
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
try {
|
|
370
|
+
closeSkillDirectoryBestEffort(openedDir, scope);
|
|
371
|
+
} catch {
|
|
372
|
+
/* closing a failed iterator is best-effort */
|
|
373
|
+
}
|
|
374
|
+
/* directory iteration is best-effort */
|
|
375
|
+
}
|
|
376
|
+
names.sort();
|
|
377
|
+
return names;
|
|
378
|
+
},
|
|
379
|
+
deadline,
|
|
380
|
+
scope,
|
|
381
|
+
);
|
|
382
|
+
} catch {
|
|
383
|
+
if (dir) {
|
|
384
|
+
try {
|
|
385
|
+
closeSkillDirectoryBestEffort(dir, scope);
|
|
386
|
+
} catch {
|
|
387
|
+
/* closing a timed-out iterator is best-effort */
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string, capBytes: number, deadline: number, scope: ScanScope): Promise<SkillEntry | null> {
|
|
395
|
+
const path = join(skillRoot, dirName, "SKILL.md");
|
|
396
|
+
const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file", scope);
|
|
397
|
+
if (!canonical) return null;
|
|
398
|
+
const text = await readUtf8File(canonical, capBytes, deadline, cwdCanonical, scope);
|
|
399
|
+
if (text === null) return null;
|
|
400
|
+
const { name, body } = parseSkillFrontmatter(truncateUtf8(text, capBytes));
|
|
401
|
+
return { name: name ?? dirName, body, bytesRead: Buffer.byteLength(text, "utf8") };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function buildSkillsXml(skills: SkillEntry[]): string | null {
|
|
405
|
+
if (skills.length === 0) return null;
|
|
406
|
+
const lines = ["<skills>"];
|
|
407
|
+
let usedBytes = Buffer.byteLength(lines[0]! + "\n</skills>", "utf8");
|
|
408
|
+
|
|
409
|
+
for (const skill of skills) {
|
|
410
|
+
const open = ` <skill name="${xmlEscape(skill.name)}">`;
|
|
411
|
+
const close = "</skill>";
|
|
412
|
+
let body = skill.body;
|
|
413
|
+
let line = `${open}${xmlEscape(body)}${close}`;
|
|
414
|
+
let lineBytes = Buffer.byteLength(line + "\n", "utf8");
|
|
415
|
+
|
|
416
|
+
if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) {
|
|
417
|
+
const overhead = Buffer.byteLength(open + close + "\n", "utf8");
|
|
418
|
+
const bodyBudget = SKILLS_XML_CAP_BYTES - usedBytes - overhead;
|
|
419
|
+
if (bodyBudget <= 0) break;
|
|
420
|
+
const fittedBody = truncateUtf8BodyForXml(body, bodyBudget);
|
|
421
|
+
if (fittedBody === null) break;
|
|
422
|
+
const wasTruncated = fittedBody !== body;
|
|
423
|
+
body = fittedBody;
|
|
424
|
+
line = `${open}${xmlEscape(body)}${close}`;
|
|
425
|
+
lineBytes = Buffer.byteLength(line + "\n", "utf8");
|
|
426
|
+
if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) break;
|
|
427
|
+
lines.push(line);
|
|
428
|
+
usedBytes += lineBytes;
|
|
429
|
+
if (wasTruncated) break;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
lines.push(line);
|
|
434
|
+
usedBytes += lineBytes;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
lines.push("</skills>");
|
|
438
|
+
if (lines.length === 2) return null;
|
|
439
|
+
return lines.join("\n");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function truncateUtf8BodyForXml(body: string, capBytes: number): string | null {
|
|
443
|
+
const rawBuf = Buffer.from(body, "utf8");
|
|
444
|
+
if (Buffer.byteLength(xmlEscape(body), "utf8") <= capBytes) return body;
|
|
445
|
+
if (Buffer.byteLength(xmlEscape(TRUNCATION_MARKER), "utf8") > capBytes) return null;
|
|
446
|
+
|
|
447
|
+
// XML entities can expand a raw body by several bytes per character. Binary-search the
|
|
448
|
+
// largest UTF-8 prefix whose escaped form, including the marker, fits the actual wire cap.
|
|
449
|
+
let low = 0;
|
|
450
|
+
let high = rawBuf.length;
|
|
451
|
+
let best = TRUNCATION_MARKER;
|
|
452
|
+
while (low <= high) {
|
|
453
|
+
const mid = Math.floor((low + high) / 2);
|
|
454
|
+
let rawEnd = mid;
|
|
455
|
+
while (rawEnd > 0 && (rawBuf[rawEnd]! & 0xc0) === 0x80) rawEnd--;
|
|
456
|
+
const candidate = rawBuf.subarray(0, rawEnd).toString("utf8") + TRUNCATION_MARKER;
|
|
457
|
+
if (Buffer.byteLength(xmlEscape(candidate), "utf8") <= capBytes) {
|
|
458
|
+
best = candidate;
|
|
459
|
+
low = mid + 1;
|
|
460
|
+
} else {
|
|
461
|
+
high = mid - 1;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return best;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function readSkills(cwd: string, cwdCanonical: string, deadline: number, scope: ScanScope): Promise<string | null> {
|
|
468
|
+
const seen = new Set<string>();
|
|
469
|
+
const collected: SkillEntry[] = [];
|
|
470
|
+
let remainingBytes = SKILLS_READ_CAP_BYTES;
|
|
471
|
+
|
|
472
|
+
for (const rootRel of SKILL_ROOTS) {
|
|
473
|
+
const remainingScanMs = deadline - Date.now();
|
|
474
|
+
if (remainingScanMs <= 0) { scope.degraded = true; break; }
|
|
475
|
+
const skillRoot = join(cwd, ...rootRel.split("/"));
|
|
476
|
+
const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN, deadline, scope);
|
|
477
|
+
for (const dirName of dirs) {
|
|
478
|
+
if (collected.length >= MAX_SKILLS || remainingBytes <= 1) break;
|
|
479
|
+
const remainingReadMs = deadline - Date.now();
|
|
480
|
+
if (remainingReadMs <= 0) { scope.degraded = true; break; }
|
|
481
|
+
const skill = await readSkill(skillRoot, dirName, cwdCanonical, Math.min(SKILL_FILE_CAP_BYTES, remainingBytes - 1), deadline, scope);
|
|
482
|
+
if (!skill) continue;
|
|
483
|
+
remainingBytes -= skill.bytesRead;
|
|
484
|
+
if (seen.has(skill.name)) continue;
|
|
485
|
+
seen.add(skill.name);
|
|
486
|
+
collected.push(skill);
|
|
487
|
+
}
|
|
488
|
+
if (collected.length >= MAX_SKILLS || remainingBytes <= 1) break;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return buildSkillsXml(collected);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function collectProjectContext(cwd: string, timeoutMs: number, scope: ScanScope): Promise<CommandCodeProjectContext> {
|
|
495
|
+
const deadline = Date.now() + timeoutMs;
|
|
496
|
+
const cwdCanonical = await canonicalPath(cwd, deadline, scope);
|
|
497
|
+
if (!cwdCanonical) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT };
|
|
498
|
+
|
|
499
|
+
const [memory, taste, skills] = await Promise.all([
|
|
500
|
+
readMemory(cwd, cwdCanonical, deadline, scope),
|
|
501
|
+
readTaste(cwd, cwdCanonical, deadline, scope),
|
|
502
|
+
readSkills(cwd, cwdCanonical, deadline, scope),
|
|
503
|
+
]);
|
|
504
|
+
|
|
505
|
+
return { memory, taste, skills };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export async function loadCommandCodeProjectContext(cwd: string | undefined): Promise<CommandCodeProjectContext> {
|
|
509
|
+
if (!cwd) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT };
|
|
510
|
+
|
|
511
|
+
const cached = projectContextCache.get(cwd);
|
|
512
|
+
if (cached && Date.now() - cached.collectedAt < PROJECT_CONTEXT_TTL_MS) return cached.value;
|
|
513
|
+
const existing = projectContextInFlight.get(cwd);
|
|
514
|
+
if (existing) return existing;
|
|
515
|
+
// A stuck filesystem operation stays counted after its caller's timeout. Once the cap is
|
|
516
|
+
// reached, fail soft without dispatching another scan or consuming another I/O worker.
|
|
517
|
+
if (outstandingProjectContextScans.has(cwd)
|
|
518
|
+
|| outstandingProjectContextScans.size >= MAX_CONCURRENT_PROJECT_CONTEXT_SCANS
|
|
519
|
+
|| pendingProjectContextFileOps.size >= MAX_PENDING_PROJECT_CONTEXT_FILE_OPS) {
|
|
520
|
+
return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT };
|
|
521
|
+
}
|
|
522
|
+
const scope: ScanScope = { cwd, pendingOps: 0, finished: false, degraded: false };
|
|
523
|
+
outstandingProjectContextScans.set(cwd, scope);
|
|
524
|
+
const scan = (async () => {
|
|
525
|
+
const value = await collectProjectContext(cwd, fileOpTimeoutForTests ?? COMMAND_CODE_FILE_OP_TIMEOUT_MS, scope);
|
|
526
|
+
if (!scope.degraded) {
|
|
527
|
+
const now = Date.now();
|
|
528
|
+
pruneExpiredProjectContextCache(now);
|
|
529
|
+
if (!projectContextCache.has(cwd)) pruneProjectContextCache(now);
|
|
530
|
+
projectContextCache.set(cwd, { collectedAt: now, value });
|
|
531
|
+
}
|
|
532
|
+
return value;
|
|
533
|
+
})();
|
|
534
|
+
projectContextInFlight.set(cwd, scan);
|
|
535
|
+
try {
|
|
536
|
+
return await scan;
|
|
537
|
+
} finally {
|
|
538
|
+
if (projectContextInFlight.get(cwd) === scan) projectContextInFlight.delete(cwd);
|
|
539
|
+
scope.finished = true;
|
|
540
|
+
releaseScanIfSettled(scope);
|
|
541
|
+
}
|
|
542
|
+
}
|