codiedev 0.9.23 → 0.9.25
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/dist/commands/doctor.js +56 -5
- package/dist/hook.js +91 -6
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +146 -5
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -165,6 +165,9 @@ async function runDoctor(_args) {
|
|
|
165
165
|
catch {
|
|
166
166
|
// malformed backendUrl in config — keep the generic placeholder
|
|
167
167
|
}
|
|
168
|
+
// Live tracked-repo list fetched from validateToken below. Null until (and
|
|
169
|
+
// unless) the backend answers; the checks fall back to `config.repos`.
|
|
170
|
+
let liveRepos = null;
|
|
168
171
|
try {
|
|
169
172
|
const res = await fetch(`${config.backendUrl}/api/cli/validateToken`, {
|
|
170
173
|
method: "POST",
|
|
@@ -180,6 +183,21 @@ async function runDoctor(_args) {
|
|
|
180
183
|
status: "pass",
|
|
181
184
|
detail: backendHost,
|
|
182
185
|
});
|
|
186
|
+
// validateToken returns the company's CURRENT tracked repos. Capture
|
|
187
|
+
// them: the repo-resolution checks below must reflect what the SERVER
|
|
188
|
+
// will resolve at capture time (repoResolver.ts resolves against the
|
|
189
|
+
// live list), not the possibly-stale `config.repos` frozen at the last
|
|
190
|
+
// `connect`. A repo linked in the portal after that connect otherwise
|
|
191
|
+
// reports a false "isn't a tracked repo — link it in the portal" here
|
|
192
|
+
// even though sessions/MRs for it attribute fine server-side.
|
|
193
|
+
try {
|
|
194
|
+
const data = (await res.json());
|
|
195
|
+
if (Array.isArray(data.repos))
|
|
196
|
+
liveRepos = data.repos;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
liveRepos = null; // malformed body → fall back to the cached list
|
|
200
|
+
}
|
|
183
201
|
}
|
|
184
202
|
else {
|
|
185
203
|
checks.push({
|
|
@@ -219,13 +237,46 @@ async function runDoctor(_args) {
|
|
|
219
237
|
detail: err.message,
|
|
220
238
|
});
|
|
221
239
|
}
|
|
222
|
-
// 4. Tracked repos
|
|
223
|
-
|
|
240
|
+
// 4. Tracked repos. Prefer the live list from validateToken over the cache:
|
|
241
|
+
// a repo linked after the last `connect` is tracked server-side but absent
|
|
242
|
+
// from `config.repos`, and resolving against the stale cache is exactly what
|
|
243
|
+
// produced the false "isn't a tracked repo — link it in the portal" warning.
|
|
244
|
+
// Treat an EMPTY live list as "no usable live data", NOT an authoritative
|
|
245
|
+
// zero: a transient permission glitch / backend hiccup can return `repos: []`,
|
|
246
|
+
// and both driving the checks from it AND healing the cache to empty would be
|
|
247
|
+
// wrong — the write would durably break every offline doctor run (and the
|
|
248
|
+
// capture hook's providedRepoId) until the next successful connect. So when
|
|
249
|
+
// live is empty, fall back to the cache and skip the write, exactly like
|
|
250
|
+
// backend-unreachable. A genuinely repo-less company also has an empty cache,
|
|
251
|
+
// so it still reports "0 repos" correctly.
|
|
252
|
+
const trackedRepos = liveRepos !== null && liveRepos.length > 0 ? liveRepos : (config.repos ?? []);
|
|
253
|
+
// Detect drift by CONTENT, not count: a same-size swap (one repo removed +
|
|
254
|
+
// another added between connects) or a repo whose path/URL changed while its
|
|
255
|
+
// id stayed stable (GitLab move/rename — exactly the SONIFI migration case)
|
|
256
|
+
// both leave the count unchanged. A length-only check would skip the heal and
|
|
257
|
+
// leave the capture hook's cache — and its advisory providedRepoId — stale.
|
|
258
|
+
const canonRepos = (rs) => JSON.stringify([...rs].map((r) => [r.repoId, r.url, r.fullName]).sort());
|
|
259
|
+
const cacheRefreshed = liveRepos !== null &&
|
|
260
|
+
liveRepos.length > 0 &&
|
|
261
|
+
canonRepos(liveRepos) !== canonRepos(config.repos ?? []);
|
|
262
|
+
// Heal the on-disk cache so the capture hook (which reads `config.repos` for
|
|
263
|
+
// the advisory providedRepoId) stops trailing the portal too. Best-effort:
|
|
264
|
+
// doctor must never fail because the config couldn't be rewritten.
|
|
265
|
+
if (cacheRefreshed && liveRepos) {
|
|
266
|
+
try {
|
|
267
|
+
(0, utils_1.writeConfig)({ ...config, repos: liveRepos, lastSynced: new Date().toISOString() });
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// read-only home / permission issue — the in-memory list still drives
|
|
271
|
+
// the checks below; the user just re-syncs on the next `connect`.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const repoCount = trackedRepos.length;
|
|
224
275
|
checks.push({
|
|
225
276
|
name: "Tracked repos",
|
|
226
277
|
status: repoCount > 0 ? "pass" : "warn",
|
|
227
278
|
detail: repoCount > 0
|
|
228
|
-
? `${repoCount} repo${repoCount === 1 ? "" : "s"}`
|
|
279
|
+
? `${repoCount} repo${repoCount === 1 ? "" : "s"}${cacheRefreshed ? " (refreshed from portal)" : ""}`
|
|
229
280
|
: "0 — link repos in the portal so sessions get captured",
|
|
230
281
|
});
|
|
231
282
|
// 4b. Capture readiness — the part that actually decides whether a session
|
|
@@ -234,7 +285,7 @@ async function runDoctor(_args) {
|
|
|
234
285
|
// last one?" — the blind spot behind lean-container / submodule / no-remote
|
|
235
286
|
// zeros.
|
|
236
287
|
const cwd = process.cwd();
|
|
237
|
-
const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd,
|
|
288
|
+
const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd, trackedRepos, {
|
|
238
289
|
exec: (cmd, dir) => (0, child_process_1.execSync)(cmd, { cwd: dir, stdio: ["pipe", "pipe", "pipe"] }).toString(),
|
|
239
290
|
getRemoteUrl: utils_1.getGitRemoteUrl,
|
|
240
291
|
matchRepo: utils_1.matchRepo,
|
|
@@ -247,7 +298,7 @@ async function runDoctor(_args) {
|
|
|
247
298
|
// instead of flatly warning "not a work tree / no remote" — the exact output
|
|
248
299
|
// that made working workspace-root setups read as broken.
|
|
249
300
|
const workspaceScan = readiness.gitOnPath && !readiness.insideWorkTree
|
|
250
|
-
? (0, captureReadiness_1.assessWorkspaceRepos)(cwd,
|
|
301
|
+
? (0, captureReadiness_1.assessWorkspaceRepos)(cwd, trackedRepos, {
|
|
251
302
|
listChildDirs: (dir) => fs
|
|
252
303
|
.readdirSync(dir, { withFileTypes: true })
|
|
253
304
|
.filter((e) => e.isDirectory())
|
package/dist/hook.js
CHANGED
|
@@ -241,6 +241,84 @@ timeoutMs) {
|
|
|
241
241
|
req.end();
|
|
242
242
|
});
|
|
243
243
|
}
|
|
244
|
+
// postJson variant that returns the parsed response body — the upload-URL
|
|
245
|
+
// handshake needs the { uploadUrl } the server hands back. No timeout: runs
|
|
246
|
+
// only in detached uploader children, never on the shutdown clock.
|
|
247
|
+
function postJsonForResponse(url, body, bearerToken) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
const parsed = new URL(url);
|
|
250
|
+
const data = JSON.stringify(body);
|
|
251
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
252
|
+
const req = lib.request({
|
|
253
|
+
hostname: parsed.hostname,
|
|
254
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
255
|
+
path: parsed.pathname + parsed.search,
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: {
|
|
258
|
+
"Content-Type": "application/json",
|
|
259
|
+
"Content-Length": Buffer.byteLength(data),
|
|
260
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
261
|
+
"X-Codiedev-Cli-Version": version_1.CLI_VERSION,
|
|
262
|
+
},
|
|
263
|
+
}, (res) => {
|
|
264
|
+
let responseData = "";
|
|
265
|
+
res.on("data", (chunk) => (responseData += chunk));
|
|
266
|
+
res.on("end", () => {
|
|
267
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
268
|
+
try {
|
|
269
|
+
resolve(JSON.parse(responseData));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
reject(new Error(`HTTP ${res.statusCode}: unparseable response`));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
reject(new Error(`HTTP ${res.statusCode}: ${responseData}`));
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
req.on("error", reject);
|
|
281
|
+
req.write(data);
|
|
282
|
+
req.end();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// Raw-bytes POST to a Convex storage upload URL (no auth header — the URL
|
|
286
|
+
// itself is the one-time credential). Returns the parsed { storageId }.
|
|
287
|
+
function postBytes(url, bytes, contentType) {
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
const parsed = new URL(url);
|
|
290
|
+
const lib = parsed.protocol === "https:" ? https : http;
|
|
291
|
+
const req = lib.request({
|
|
292
|
+
hostname: parsed.hostname,
|
|
293
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
294
|
+
path: parsed.pathname + parsed.search,
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: {
|
|
297
|
+
"Content-Type": contentType,
|
|
298
|
+
"Content-Length": bytes.length,
|
|
299
|
+
},
|
|
300
|
+
}, (res) => {
|
|
301
|
+
let responseData = "";
|
|
302
|
+
res.on("data", (chunk) => (responseData += chunk));
|
|
303
|
+
res.on("end", () => {
|
|
304
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
305
|
+
try {
|
|
306
|
+
resolve(JSON.parse(responseData));
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
reject(new Error(`HTTP ${res.statusCode}: unparseable response`));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
reject(new Error(`HTTP ${res.statusCode}: ${responseData}`));
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
req.on("error", reject);
|
|
318
|
+
req.write(bytes);
|
|
319
|
+
req.end();
|
|
320
|
+
});
|
|
321
|
+
}
|
|
244
322
|
// Short leash for the tiny coverage pings (sessionStart / sessionSkip): they
|
|
245
323
|
// carry a few bytes and must never stall a hook. 3s is generous for a ping
|
|
246
324
|
// and a hard bound for the shutdown clock.
|
|
@@ -270,6 +348,12 @@ async function main() {
|
|
|
270
348
|
function makeUploadDeps() {
|
|
271
349
|
return {
|
|
272
350
|
post: postJson,
|
|
351
|
+
// Direct-to-storage flow for big transcripts (see utils.ts
|
|
352
|
+
// DIRECT_UPLOAD_THRESHOLD_CHARS): fetch a one-time upload URL, then
|
|
353
|
+
// POST the raw bytes to it. Keeps ~7MB+ transcripts out of the
|
|
354
|
+
// backend's 64MB captureSession isolate.
|
|
355
|
+
requestJson: postJsonForResponse,
|
|
356
|
+
uploadBytes: postBytes,
|
|
273
357
|
readFileSync: fs.readFileSync,
|
|
274
358
|
unlinkSync: fs.unlinkSync,
|
|
275
359
|
// Persist a bumped attempt count on failure so a later sweep retries
|
|
@@ -508,17 +592,18 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
508
592
|
try {
|
|
509
593
|
transcriptContent = fs.readFileSync(transcript_path, "utf8");
|
|
510
594
|
}
|
|
511
|
-
catch {
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
595
|
+
catch (err) {
|
|
596
|
+
// ENOENT → "no-transcript" (phantom desktop-app helper session, excluded
|
|
597
|
+
// from coverage server-side); anything else → "transcript-unreadable:<code>"
|
|
598
|
+
// (real transcript, read failed — a genuine, diagnosable loss).
|
|
599
|
+
const reason = (0, utils_1.transcriptReadSkipReason)(err);
|
|
515
600
|
(0, captureLog_1.appendCaptureLog)({
|
|
516
601
|
stage: "capture-finish",
|
|
517
602
|
sessionId: session_id,
|
|
518
603
|
outcome: "skip",
|
|
519
|
-
detail:
|
|
604
|
+
detail: reason,
|
|
520
605
|
});
|
|
521
|
-
await postSessionSkip(config, session_id,
|
|
606
|
+
await postSessionSkip(config, session_id, reason);
|
|
522
607
|
return;
|
|
523
608
|
}
|
|
524
609
|
// Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
|
package/dist/utils.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface CapturePayloadInput {
|
|
|
67
67
|
}
|
|
68
68
|
export declare function buildCapturePayload(input: CapturePayloadInput): Record<string, unknown>;
|
|
69
69
|
export declare function hashToken(token: string): string;
|
|
70
|
+
export declare function extractHookNodePath(cmd: string | undefined): string | undefined;
|
|
71
|
+
export declare function resetStableNodeCacheForTests(): void;
|
|
70
72
|
export declare function claudeCodeInstalled(): boolean;
|
|
71
73
|
export declare function codexInstalled(): boolean;
|
|
72
74
|
export declare function cursorInstalled(): boolean;
|
|
@@ -248,8 +250,11 @@ export declare function pruneCaptureStateDir(deps?: {
|
|
|
248
250
|
};
|
|
249
251
|
unlinkSync: (p: string) => void;
|
|
250
252
|
}, dir?: string, now?: number): void;
|
|
253
|
+
export declare const DIRECT_UPLOAD_THRESHOLD_CHARS = 2000000;
|
|
251
254
|
interface UploadDeps {
|
|
252
255
|
post: (url: string, body: Record<string, unknown>, token: string) => Promise<void>;
|
|
256
|
+
requestJson?: (url: string, body: Record<string, unknown>, token: string) => Promise<any>;
|
|
257
|
+
uploadBytes?: (url: string, bytes: Buffer, contentType: string) => Promise<any>;
|
|
253
258
|
readFileSync: typeof fs.readFileSync;
|
|
254
259
|
unlinkSync: typeof fs.unlinkSync;
|
|
255
260
|
writeFileSync?: typeof fs.writeFileSync;
|
|
@@ -286,4 +291,5 @@ interface DeferredUploadDeps extends UploadDeps {
|
|
|
286
291
|
sleep?: (ms: number) => Promise<void>;
|
|
287
292
|
}
|
|
288
293
|
export declare function runDeferredUpload(jobFile: string, delayMs: number, deps: DeferredUploadDeps): Promise<void>;
|
|
294
|
+
export declare function transcriptReadSkipReason(err: unknown): string;
|
|
289
295
|
export {};
|
package/dist/utils.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.FINISH_ORPHAN_AGE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
|
|
36
|
+
exports.DIRECT_UPLOAD_THRESHOLD_CHARS = exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.FINISH_ORPHAN_AGE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
|
|
37
37
|
exports.readConfig = readConfig;
|
|
38
38
|
exports.writeConfig = writeConfig;
|
|
39
39
|
exports.parseGitRemoteFromConfig = parseGitRemoteFromConfig;
|
|
@@ -52,6 +52,8 @@ exports.getGitBranch = getGitBranch;
|
|
|
52
52
|
exports.getGitHeadSha = getGitHeadSha;
|
|
53
53
|
exports.buildCapturePayload = buildCapturePayload;
|
|
54
54
|
exports.hashToken = hashToken;
|
|
55
|
+
exports.extractHookNodePath = extractHookNodePath;
|
|
56
|
+
exports.resetStableNodeCacheForTests = resetStableNodeCacheForTests;
|
|
55
57
|
exports.claudeCodeInstalled = claudeCodeInstalled;
|
|
56
58
|
exports.codexInstalled = codexInstalled;
|
|
57
59
|
exports.cursorInstalled = cursorInstalled;
|
|
@@ -99,6 +101,7 @@ exports.writeLastCapture = writeLastCapture;
|
|
|
99
101
|
exports.readLastCapture = readLastCapture;
|
|
100
102
|
exports.runCaptureUpload = runCaptureUpload;
|
|
101
103
|
exports.runDeferredUpload = runDeferredUpload;
|
|
104
|
+
exports.transcriptReadSkipReason = transcriptReadSkipReason;
|
|
102
105
|
const fs = __importStar(require("fs"));
|
|
103
106
|
const path = __importStar(require("path"));
|
|
104
107
|
const os = __importStar(require("os"));
|
|
@@ -732,7 +735,94 @@ const VSCODE_USER_MCP_PATH = path.join(VSCODE_USER_DIR, "mcp.json");
|
|
|
732
735
|
function shellQuote(s) {
|
|
733
736
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
734
737
|
}
|
|
735
|
-
|
|
738
|
+
// Pull the `<node>` prefix out of a codiedev hook command we previously wrote.
|
|
739
|
+
// Commands take the form `'<node>' '<hookScript>' <subcommand>` (each path
|
|
740
|
+
// single-quoted by shellQuote). Returns undefined for the legacy
|
|
741
|
+
// `npx codiedev-hook <subcommand>` form (no explicit node path) or if the
|
|
742
|
+
// command doesn't start with a quoted token. Exported for unit testing.
|
|
743
|
+
function extractHookNodePath(cmd) {
|
|
744
|
+
if (!cmd)
|
|
745
|
+
return undefined;
|
|
746
|
+
// Match the first single-quoted token, honoring shellQuote's `'\''` escape.
|
|
747
|
+
const m = cmd.match(/^'((?:[^']|'\\'')*)'/);
|
|
748
|
+
if (!m)
|
|
749
|
+
return undefined;
|
|
750
|
+
return m[1].replace(/'\\''/g, "'");
|
|
751
|
+
}
|
|
752
|
+
// `process.execPath` is the *resolved* path to the running node binary, so under
|
|
753
|
+
// Homebrew it's the version-specific Cellar path (e.g.
|
|
754
|
+
// /opt/homebrew/Cellar/node/26.4.0/bin/node). Baking that into the hook means a
|
|
755
|
+
// later `brew upgrade node` moves the Cellar dir and the hook path 404s. Prefer
|
|
756
|
+
// a stable alias for the *same* binary:
|
|
757
|
+
// 1. A node path from a hook we previously wrote that still resolves — usually
|
|
758
|
+
// a symlink the user deliberately pinned (e.g. /opt/homebrew/bin/node). A
|
|
759
|
+
// re-`connect`/self-heal then honors it instead of clobbering it.
|
|
760
|
+
// 2. Otherwise, `node` as found on PATH when it points at the binary we're
|
|
761
|
+
// running (the Homebrew symlink, an nvm `current` alias, etc.).
|
|
762
|
+
// 3. Last resort: the resolved `process.execPath`. Always valid right now, and
|
|
763
|
+
// still an absolute path a GUI-launched agent can execute without PATH.
|
|
764
|
+
function realpathOrSelf(p) {
|
|
765
|
+
try {
|
|
766
|
+
return fs.realpathSync(p);
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
return p;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// A stable `node` alias on PATH (Homebrew symlink, nvm `current`, …) that points
|
|
773
|
+
// at the very binary we're running, or undefined if none. `installHook` writes
|
|
774
|
+
// five hooks per run, so memoize: `command -v node` is a synchronous subprocess
|
|
775
|
+
// spawn and its result can't change within a process. `null` = "looked, none".
|
|
776
|
+
let cachedStableNodeOnPath;
|
|
777
|
+
// Test-only: the memo lives for the process lifetime, so suites that stage
|
|
778
|
+
// their own PATH/symlink fixtures must clear it between tests.
|
|
779
|
+
function resetStableNodeCacheForTests() {
|
|
780
|
+
cachedStableNodeOnPath = undefined;
|
|
781
|
+
}
|
|
782
|
+
function stableNodeOnPath() {
|
|
783
|
+
if (cachedStableNodeOnPath !== undefined) {
|
|
784
|
+
return cachedStableNodeOnPath ?? undefined;
|
|
785
|
+
}
|
|
786
|
+
const execReal = realpathOrSelf(process.execPath);
|
|
787
|
+
try {
|
|
788
|
+
const onPath = (0, child_process_1.execSync)("command -v node", {
|
|
789
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
790
|
+
})
|
|
791
|
+
.toString()
|
|
792
|
+
.trim();
|
|
793
|
+
if (onPath && onPath !== process.execPath && realpathOrSelf(onPath) === execReal) {
|
|
794
|
+
cachedStableNodeOnPath = onPath;
|
|
795
|
+
return onPath;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
// No node on PATH (e.g. GUI-launched agent) — fall through.
|
|
800
|
+
}
|
|
801
|
+
cachedStableNodeOnPath = null;
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
function resolveHookNodePath(preferredNodePath) {
|
|
805
|
+
const execReal = realpathOrSelf(process.execPath);
|
|
806
|
+
// 1. Honor a still-working node path from the existing hook — but only when
|
|
807
|
+
// it's a *different* string than the resolved realpath. If the existing
|
|
808
|
+
// path already IS `process.execPath` it's the version-specific realpath we
|
|
809
|
+
// want to upgrade away from (that's the Homebrew bug), so fall through to
|
|
810
|
+
// hunt for a stable alias in step 2.
|
|
811
|
+
if (preferredNodePath &&
|
|
812
|
+
preferredNodePath !== process.execPath &&
|
|
813
|
+
fs.existsSync(preferredNodePath) &&
|
|
814
|
+
realpathOrSelf(preferredNodePath) === execReal) {
|
|
815
|
+
return preferredNodePath;
|
|
816
|
+
}
|
|
817
|
+
// 2. Prefer a stable `node` on PATH over the version-specific realpath.
|
|
818
|
+
const onPath = stableNodeOnPath();
|
|
819
|
+
if (onPath)
|
|
820
|
+
return onPath;
|
|
821
|
+
// 3. Last resort: the resolved absolute path. Always valid right now, and
|
|
822
|
+
// still an absolute path a GUI-launched agent can execute without PATH.
|
|
823
|
+
return process.execPath;
|
|
824
|
+
}
|
|
825
|
+
function resolveHookCommand(subcommand, preferredNodePath) {
|
|
736
826
|
try {
|
|
737
827
|
const cliEntry = process.argv[1];
|
|
738
828
|
if (cliEntry) {
|
|
@@ -740,7 +830,8 @@ function resolveHookCommand(subcommand) {
|
|
|
740
830
|
const distDir = path.dirname(realCli);
|
|
741
831
|
const hookScript = path.join(distDir, "hook.js");
|
|
742
832
|
if (fs.existsSync(hookScript)) {
|
|
743
|
-
|
|
833
|
+
const nodePath = resolveHookNodePath(preferredNodePath);
|
|
834
|
+
return `${shellQuote(nodePath)} ${shellQuote(hookScript)} ${subcommand}`;
|
|
744
835
|
}
|
|
745
836
|
}
|
|
746
837
|
}
|
|
@@ -797,6 +888,22 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
|
|
|
797
888
|
if (!hooks[eventName])
|
|
798
889
|
hooks[eventName] = [];
|
|
799
890
|
const existing = hooks[eventName];
|
|
891
|
+
// Before stripping the old codiedev entry, remember the node path it used so a
|
|
892
|
+
// re-run/upgrade can honor a stable symlink the user pinned instead of
|
|
893
|
+
// overwriting it with the version-specific `process.execPath` (see
|
|
894
|
+
// resolveHookNodePath). Done in an explicit scan rather than as a side effect
|
|
895
|
+
// of the filter predicate below, which stays a pure predicate.
|
|
896
|
+
let preservedNodePath;
|
|
897
|
+
for (const hook of existing) {
|
|
898
|
+
const inner = hook.hooks;
|
|
899
|
+
if (!Array.isArray(inner))
|
|
900
|
+
continue;
|
|
901
|
+
const codiedevInner = inner.find((h) => (0, detection_1.isCodiedevHookCommand)(h.command));
|
|
902
|
+
if (codiedevInner) {
|
|
903
|
+
preservedNodePath = extractHookNodePath(codiedevInner.command);
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
800
907
|
const filtered = existing.filter((hook) => {
|
|
801
908
|
const inner = hook.hooks;
|
|
802
909
|
if (Array.isArray(inner)) {
|
|
@@ -806,7 +913,7 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
|
|
|
806
913
|
});
|
|
807
914
|
filtered.push({
|
|
808
915
|
matcher,
|
|
809
|
-
hooks: [{ type: "command", command: resolveHookCommand(subcommand) }],
|
|
916
|
+
hooks: [{ type: "command", command: resolveHookCommand(subcommand, preservedNodePath) }],
|
|
810
917
|
});
|
|
811
918
|
hooks[eventName] = filtered;
|
|
812
919
|
}
|
|
@@ -2028,6 +2135,13 @@ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now
|
|
|
2028
2135
|
// dir missing/unreadable — nothing to prune
|
|
2029
2136
|
}
|
|
2030
2137
|
}
|
|
2138
|
+
// Base64 length above which the transcript goes straight to storage instead
|
|
2139
|
+
// of inline in the captureSession JSON body. The backend's HTTP-action
|
|
2140
|
+
// isolate has a 64MB cap and inline base64 amplifies ~8x in memory (request
|
|
2141
|
+
// text + parsed JSON + decode output all resident) — ~7MB transcripts OOM it
|
|
2142
|
+
// (observed live 2026-07-02). 2M base64 chars ≈ 1.5MB raw: far below the
|
|
2143
|
+
// cliff, while per-turn snapshots (typically KBs) skip the extra roundtrips.
|
|
2144
|
+
exports.DIRECT_UPLOAD_THRESHOLD_CHARS = 2_000_000;
|
|
2031
2145
|
// "Last session captured" breadcrumb. Written next to config.json on a
|
|
2032
2146
|
// successful capture upload; read by `codiedev doctor` so a user can confirm
|
|
2033
2147
|
// their most recent session actually landed (the single hardest thing to
|
|
@@ -2086,7 +2200,22 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
2086
2200
|
}
|
|
2087
2201
|
const sessionId = job.body?.sessionId;
|
|
2088
2202
|
try {
|
|
2089
|
-
|
|
2203
|
+
// Big transcript + capable deps → direct-to-storage: fetch a one-time
|
|
2204
|
+
// upload URL, PUT the raw bytes, send captureSession the storageId. Any
|
|
2205
|
+
// step failing throws into the retry path below, same as a failed POST.
|
|
2206
|
+
let body = job.body;
|
|
2207
|
+
const transcript = body?.transcript;
|
|
2208
|
+
if (transcript &&
|
|
2209
|
+
transcript.length > exports.DIRECT_UPLOAD_THRESHOLD_CHARS &&
|
|
2210
|
+
deps.requestJson &&
|
|
2211
|
+
deps.uploadBytes) {
|
|
2212
|
+
const uploadUrlEndpoint = job.url.replace(/\/api\/captureSession$/, "/api/captureUploadUrl");
|
|
2213
|
+
const { uploadUrl } = await deps.requestJson(uploadUrlEndpoint, {}, job.token);
|
|
2214
|
+
const { storageId } = await deps.uploadBytes(uploadUrl, Buffer.from(transcript, "base64"), "application/json");
|
|
2215
|
+
body = { ...body, transcriptStorageId: storageId };
|
|
2216
|
+
delete body.transcript;
|
|
2217
|
+
}
|
|
2218
|
+
await deps.post(job.url, body, job.token);
|
|
2090
2219
|
// success — drop a "last session captured" breadcrumb (before removing the
|
|
2091
2220
|
// job) so doctor can confirm the most recent session actually landed.
|
|
2092
2221
|
if (deps.recordSuccess) {
|
|
@@ -2225,3 +2354,15 @@ async function runDeferredUpload(jobFile, delayMs, deps) {
|
|
|
2225
2354
|
}
|
|
2226
2355
|
await runCaptureUpload(claimed, deps);
|
|
2227
2356
|
}
|
|
2357
|
+
// Classify a transcript read failure at capture time. ENOENT = the transcript
|
|
2358
|
+
// never existed: a desktop-app phantom helper session (SessionStart/End hooks
|
|
2359
|
+
// fire, no conversation ever happens), not lost work — the backend excludes
|
|
2360
|
+
// "no-transcript" from coverage. Anything else means a real transcript was
|
|
2361
|
+
// there but the read failed: a genuine loss, tagged with the errno so it stays
|
|
2362
|
+
// diagnosable from the server side.
|
|
2363
|
+
function transcriptReadSkipReason(err) {
|
|
2364
|
+
const code = err?.code;
|
|
2365
|
+
if (code === "ENOENT")
|
|
2366
|
+
return "no-transcript";
|
|
2367
|
+
return `transcript-unreadable:${code || "unknown"}`;
|
|
2368
|
+
}
|
package/package.json
CHANGED