codiedev 0.9.22 → 0.9.24
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/hook.js +157 -12
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +149 -2
- package/package.json +1 -1
package/dist/hook.js
CHANGED
|
@@ -158,6 +158,20 @@ async function runStopFailure(hookInput) {
|
|
|
158
158
|
}
|
|
159
159
|
process.exit(0);
|
|
160
160
|
}
|
|
161
|
+
// Skip-reason ping: this session is ending WITHOUT a capture, and here's why.
|
|
162
|
+
// Turns silent coverage loss into per-reason buckets on the admin Capture
|
|
163
|
+
// Health panel (patches the session's aiSessionStarts row server-side). Tiny
|
|
164
|
+
// like the sessionStart ping. Best-effort, never throws.
|
|
165
|
+
async function postSessionSkip(config, sessionId, reason) {
|
|
166
|
+
if (!sessionId)
|
|
167
|
+
return;
|
|
168
|
+
try {
|
|
169
|
+
await postJson(`${config.backendUrl}/api/cli/sessionSkip`, { sessionId, reason, at: Date.now() }, config.token, PING_TIMEOUT_MS);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* best-effort */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
161
175
|
// SessionStart: the capture-coverage denominator. Record that a session opened
|
|
162
176
|
// so the backend can later compute captured ÷ started. Deliberately tiny — no
|
|
163
177
|
// transcript read, no git — so it never slows session startup. Best-effort +
|
|
@@ -172,7 +186,7 @@ async function runSessionStart(hookInput) {
|
|
|
172
186
|
sessionId: hookInput.session_id,
|
|
173
187
|
startedAt: Date.now(),
|
|
174
188
|
source: "claude-code",
|
|
175
|
-
}, config.token);
|
|
189
|
+
}, config.token, PING_TIMEOUT_MS);
|
|
176
190
|
}
|
|
177
191
|
catch {
|
|
178
192
|
/* best-effort — never block session startup */
|
|
@@ -180,7 +194,13 @@ async function runSessionStart(hookInput) {
|
|
|
180
194
|
}
|
|
181
195
|
process.exit(0);
|
|
182
196
|
}
|
|
183
|
-
function postJson(url, body, bearerToken
|
|
197
|
+
function postJson(url, body, bearerToken,
|
|
198
|
+
// Socket timeout. Pass a SMALL value for tiny pings that run on (or near)
|
|
199
|
+
// Claude Code's shutdown clock — a black-holed connection (proxy dropping
|
|
200
|
+
// packets without RST) otherwise hangs the hook until CC kills it. Leave
|
|
201
|
+
// unset for transcript uploads, which legitimately take a while on slow
|
|
202
|
+
// corporate links and already run in detached children with retry.
|
|
203
|
+
timeoutMs) {
|
|
184
204
|
return new Promise((resolve, reject) => {
|
|
185
205
|
const parsed = new URL(url);
|
|
186
206
|
const data = JSON.stringify(body);
|
|
@@ -212,10 +232,97 @@ function postJson(url, body, bearerToken) {
|
|
|
212
232
|
});
|
|
213
233
|
});
|
|
214
234
|
req.on("error", reject);
|
|
235
|
+
if (timeoutMs) {
|
|
236
|
+
req.setTimeout(timeoutMs, () => {
|
|
237
|
+
req.destroy(new Error(`timeout after ${timeoutMs}ms`));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
215
240
|
req.write(data);
|
|
216
241
|
req.end();
|
|
217
242
|
});
|
|
218
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
|
+
}
|
|
322
|
+
// Short leash for the tiny coverage pings (sessionStart / sessionSkip): they
|
|
323
|
+
// carry a few bytes and must never stall a hook. 3s is generous for a ping
|
|
324
|
+
// and a hard bound for the shutdown clock.
|
|
325
|
+
const PING_TIMEOUT_MS = 3000;
|
|
219
326
|
async function readStdin() {
|
|
220
327
|
return new Promise((resolve, reject) => {
|
|
221
328
|
let data = "";
|
|
@@ -241,6 +348,12 @@ async function main() {
|
|
|
241
348
|
function makeUploadDeps() {
|
|
242
349
|
return {
|
|
243
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,
|
|
244
357
|
readFileSync: fs.readFileSync,
|
|
245
358
|
unlinkSync: fs.unlinkSync,
|
|
246
359
|
// Persist a bumped attempt count on failure so a later sweep retries
|
|
@@ -343,14 +456,12 @@ async function main() {
|
|
|
343
456
|
await runSessionStart(hookInput);
|
|
344
457
|
return;
|
|
345
458
|
}
|
|
346
|
-
// Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
(0, utils_1.
|
|
352
|
-
(0, utils_1.ensureLimitHookInstalled)();
|
|
353
|
-
(0, utils_1.ensureSessionStartHookInstalled)();
|
|
459
|
+
// Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
|
|
460
|
+
// every codiedev hook — INCLUDING SessionEnd→capture, which previously had
|
|
461
|
+
// no self-heal and could stay stripped forever while SessionStart kept
|
|
462
|
+
// pinging (the Sonifi "starts arrive, captures never" loss) — re-wired on
|
|
463
|
+
// their next Stop/SessionEnd. One read + one write, only when missing.
|
|
464
|
+
(0, utils_1.ensureAllCodiedevHooksInstalled)();
|
|
354
465
|
const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
|
|
355
466
|
const workspaceRoot = workspace_roots && workspace_roots.length > 0
|
|
356
467
|
? workspace_roots[0]
|
|
@@ -407,9 +518,14 @@ async function main() {
|
|
|
407
518
|
// finished and the upload went through, but CC had already given up waiting.
|
|
408
519
|
const config = (0, utils_1.readConfig)();
|
|
409
520
|
if (!config) {
|
|
521
|
+
// No config = no token; nothing can be sent, not even a skip ping.
|
|
410
522
|
process.exit(0);
|
|
411
523
|
}
|
|
412
524
|
if (!transcript_path) {
|
|
525
|
+
// Nothing to capture — but say so, instead of leaving a start ping with
|
|
526
|
+
// no matching capture (silent coverage loss). One tiny POST; the rare
|
|
527
|
+
// no-transcript case has no other work on the shutdown clock.
|
|
528
|
+
await postSessionSkip(config, session_id, "no-transcript-path");
|
|
413
529
|
process.exit(0);
|
|
414
530
|
}
|
|
415
531
|
try {
|
|
@@ -448,8 +564,10 @@ async function runCaptureFinish(input) {
|
|
|
448
564
|
if (!config)
|
|
449
565
|
return;
|
|
450
566
|
const { session_id, transcript_path, cwd, workspace_roots } = input;
|
|
451
|
-
if (!transcript_path)
|
|
567
|
+
if (!transcript_path) {
|
|
568
|
+
await postSessionSkip(config, session_id, "no-transcript-path");
|
|
452
569
|
return;
|
|
570
|
+
}
|
|
453
571
|
// Log that this child has started processing — the first diagnostic proof
|
|
454
572
|
// that the detached capture-finish child actually ran (visible in doctor).
|
|
455
573
|
(0, captureLog_1.appendCaptureLog)({ stage: "capture-finish", sessionId: session_id, outcome: "start" });
|
|
@@ -474,7 +592,18 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
474
592
|
try {
|
|
475
593
|
transcriptContent = fs.readFileSync(transcript_path, "utf8");
|
|
476
594
|
}
|
|
477
|
-
catch {
|
|
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);
|
|
600
|
+
(0, captureLog_1.appendCaptureLog)({
|
|
601
|
+
stage: "capture-finish",
|
|
602
|
+
sessionId: session_id,
|
|
603
|
+
outcome: "skip",
|
|
604
|
+
detail: reason,
|
|
605
|
+
});
|
|
606
|
+
await postSessionSkip(config, session_id, reason);
|
|
478
607
|
return;
|
|
479
608
|
}
|
|
480
609
|
// Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
|
|
@@ -593,6 +722,14 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
593
722
|
// Retry any earlier capture jobs that failed to upload (transient network
|
|
594
723
|
// blips), excluding the one we just dispatched. Best-effort, detached.
|
|
595
724
|
// Skip the sweep on deferred path — the deferred child will handle uploading.
|
|
725
|
+
//
|
|
726
|
+
// Orphan pings are COLLECTED and awaited below: this child process calls
|
|
727
|
+
// process.exit(0) as soon as runCaptureFinish resolves, so a
|
|
728
|
+
// fire-and-forget POST here would be killed mid-flight — after the
|
|
729
|
+
// orphan file was already unlinked, i.e. the loss would stay invisible
|
|
730
|
+
// forever. The await is off the shutdown clock (detached child) and
|
|
731
|
+
// bounded by PING_TIMEOUT_MS.
|
|
732
|
+
const orphanPings = [];
|
|
596
733
|
(0, utils_1.sweepPendingCaptures)({
|
|
597
734
|
dir: os.tmpdir(),
|
|
598
735
|
readdirSync: fs.readdirSync,
|
|
@@ -603,7 +740,15 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
603
740
|
exclude: tmpFile,
|
|
604
741
|
openSync: fs.openSync,
|
|
605
742
|
closeSync: fs.closeSync,
|
|
743
|
+
// Orphaned finish-inputs (capture-finish child died before writing an
|
|
744
|
+
// upload job): unrecoverable payload, but report the loss so it lands
|
|
745
|
+
// as a skip-reason instead of a silent coverage hole.
|
|
746
|
+
unlinkSync: fs.unlinkSync,
|
|
747
|
+
reportOrphan: (sid) => {
|
|
748
|
+
orphanPings.push(postSessionSkip(config, sid, "finish-orphaned"));
|
|
749
|
+
},
|
|
606
750
|
});
|
|
751
|
+
await Promise.all(orphanPings);
|
|
607
752
|
}
|
|
608
753
|
else {
|
|
609
754
|
// Deferred: write the job file (newer turns overwrite = supersede), then
|
package/dist/utils.d.ts
CHANGED
|
@@ -81,6 +81,7 @@ export declare function installHook(): void;
|
|
|
81
81
|
export declare function ensureDiscoveryHookInstalled(): void;
|
|
82
82
|
export declare function ensureBackupHookInstalled(): void;
|
|
83
83
|
export declare function ensureLimitHookInstalled(): void;
|
|
84
|
+
export declare function ensureAllCodiedevHooksInstalled(): void;
|
|
84
85
|
export declare function ensureSessionStartHookInstalled(): void;
|
|
85
86
|
export declare function installClaudeCodeInstructions(): void;
|
|
86
87
|
export declare function installCodexInstructions(): void;
|
|
@@ -206,8 +207,11 @@ interface SweepDeps {
|
|
|
206
207
|
openSync?: typeof fs.openSync;
|
|
207
208
|
closeSync?: typeof fs.closeSync;
|
|
208
209
|
pruneState?: () => void;
|
|
210
|
+
unlinkSync?: (p: string) => void;
|
|
211
|
+
reportOrphan?: (sessionId: string | undefined) => void;
|
|
209
212
|
}
|
|
210
213
|
export declare const CLAIM_STALE_MS: number;
|
|
214
|
+
export declare const FINISH_ORPHAN_AGE_MS: number;
|
|
211
215
|
export declare function sweepPendingCaptures(deps: SweepDeps): void;
|
|
212
216
|
export declare const CAPTURE_THROTTLE_MS: number;
|
|
213
217
|
export declare const SWEEP_MIN_AGE_MS: number;
|
|
@@ -244,8 +248,11 @@ export declare function pruneCaptureStateDir(deps?: {
|
|
|
244
248
|
};
|
|
245
249
|
unlinkSync: (p: string) => void;
|
|
246
250
|
}, dir?: string, now?: number): void;
|
|
251
|
+
export declare const DIRECT_UPLOAD_THRESHOLD_CHARS = 2000000;
|
|
247
252
|
interface UploadDeps {
|
|
248
253
|
post: (url: string, body: Record<string, unknown>, token: string) => Promise<void>;
|
|
254
|
+
requestJson?: (url: string, body: Record<string, unknown>, token: string) => Promise<any>;
|
|
255
|
+
uploadBytes?: (url: string, bytes: Buffer, contentType: string) => Promise<any>;
|
|
249
256
|
readFileSync: typeof fs.readFileSync;
|
|
250
257
|
unlinkSync: typeof fs.unlinkSync;
|
|
251
258
|
writeFileSync?: typeof fs.writeFileSync;
|
|
@@ -282,4 +289,5 @@ interface DeferredUploadDeps extends UploadDeps {
|
|
|
282
289
|
sleep?: (ms: number) => Promise<void>;
|
|
283
290
|
}
|
|
284
291
|
export declare function runDeferredUpload(jobFile: string, delayMs: number, deps: DeferredUploadDeps): Promise<void>;
|
|
292
|
+
export declare function transcriptReadSkipReason(err: unknown): string;
|
|
285
293
|
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.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;
|
|
@@ -61,6 +61,7 @@ exports.installHook = installHook;
|
|
|
61
61
|
exports.ensureDiscoveryHookInstalled = ensureDiscoveryHookInstalled;
|
|
62
62
|
exports.ensureBackupHookInstalled = ensureBackupHookInstalled;
|
|
63
63
|
exports.ensureLimitHookInstalled = ensureLimitHookInstalled;
|
|
64
|
+
exports.ensureAllCodiedevHooksInstalled = ensureAllCodiedevHooksInstalled;
|
|
64
65
|
exports.ensureSessionStartHookInstalled = ensureSessionStartHookInstalled;
|
|
65
66
|
exports.installClaudeCodeInstructions = installClaudeCodeInstructions;
|
|
66
67
|
exports.installCodexInstructions = installCodexInstructions;
|
|
@@ -98,6 +99,7 @@ exports.writeLastCapture = writeLastCapture;
|
|
|
98
99
|
exports.readLastCapture = readLastCapture;
|
|
99
100
|
exports.runCaptureUpload = runCaptureUpload;
|
|
100
101
|
exports.runDeferredUpload = runDeferredUpload;
|
|
102
|
+
exports.transcriptReadSkipReason = transcriptReadSkipReason;
|
|
101
103
|
const fs = __importStar(require("fs"));
|
|
102
104
|
const path = __importStar(require("path"));
|
|
103
105
|
const os = __importStar(require("os"));
|
|
@@ -913,6 +915,61 @@ function ensureLimitHookInstalled() {
|
|
|
913
915
|
}
|
|
914
916
|
catch { /* best-effort */ }
|
|
915
917
|
}
|
|
918
|
+
// Self-heal ALL codiedev hooks in one read → one write. This replaces calling
|
|
919
|
+
// the four per-hook ensure* functions in sequence, and fixes two real losses
|
|
920
|
+
// seen in the field (Sonifi, 2026-07):
|
|
921
|
+
// 1. The SessionEnd → capture hook — the one that actually uploads sessions
|
|
922
|
+
// — had NO self-heal at all: it was written once at `connect` and never
|
|
923
|
+
// repaired. Anything that stripped it (IT-managed dotfiles baseline, the
|
|
924
|
+
// race below) left SessionStart pinging forever while captures silently
|
|
925
|
+
// stopped — the exact "start pings arrive, captures never" signature.
|
|
926
|
+
// 2. Each per-hook ensure* did its own read-modify-write of settings.json
|
|
927
|
+
// with no locking; two concurrent Claude Code sessions repairing
|
|
928
|
+
// different hooks could lose each other's writes. One read + one write
|
|
929
|
+
// shrinks that window and removes the intra-process version entirely.
|
|
930
|
+
// The per-hook ensure* functions below are kept for tests/back-compat but are
|
|
931
|
+
// no longer called from the hook entrypoint.
|
|
932
|
+
function ensureAllCodiedevHooksInstalled() {
|
|
933
|
+
try {
|
|
934
|
+
if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
|
|
935
|
+
return; // not a Claude Code user
|
|
936
|
+
const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
|
|
937
|
+
const hooks = (settings.hooks ?? {});
|
|
938
|
+
const wanted = [
|
|
939
|
+
["SessionEnd", "capture"],
|
|
940
|
+
["UserPromptSubmit", "discover"],
|
|
941
|
+
["Stop", "backup"],
|
|
942
|
+
["StopFailure", "limit", "rate_limit"],
|
|
943
|
+
["SessionStart", "session-start"],
|
|
944
|
+
];
|
|
945
|
+
const missing = wanted.filter(([eventName]) => !hasCodiedevHook(hooks, eventName));
|
|
946
|
+
if (missing.length === 0)
|
|
947
|
+
return; // steady state: no write, no churn
|
|
948
|
+
settings.hooks = hooks;
|
|
949
|
+
for (const [eventName, subcommand, matcher] of missing) {
|
|
950
|
+
upsertCodiedevHook(hooks, eventName, subcommand, matcher);
|
|
951
|
+
}
|
|
952
|
+
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
// Best-effort — never block a session on self-heal.
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function hasCodiedevHook(hooks, eventName) {
|
|
959
|
+
const entries = hooks[eventName] ?? [];
|
|
960
|
+
// A malformed (non-array) event value would make `.some` throw and — under
|
|
961
|
+
// ensureAll's single try/catch — abort EVERY heal, including the capture
|
|
962
|
+
// heal. Report "present" instead: leave the foreign/corrupt key alone and
|
|
963
|
+
// keep healing the other events (the per-hook fault isolation the old
|
|
964
|
+
// ensure* functions had).
|
|
965
|
+
if (!Array.isArray(entries))
|
|
966
|
+
return true;
|
|
967
|
+
return entries.some((hook) => {
|
|
968
|
+
const inner = hook.hooks;
|
|
969
|
+
return (Array.isArray(inner) &&
|
|
970
|
+
inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command)));
|
|
971
|
+
});
|
|
972
|
+
}
|
|
916
973
|
// Self-heal: ensure the SessionStart → session-start hook is present without a
|
|
917
974
|
// full `connect` re-run, mirroring ensureLimitHookInstalled. Called from the
|
|
918
975
|
// capture hook so an upgraded-but-not-reconnected user starts emitting the
|
|
@@ -1733,6 +1790,11 @@ function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
|
|
|
1733
1790
|
// A claimed job whose uploader hasn't finished after this long is presumed
|
|
1734
1791
|
// crashed and gets renamed back to its canonical name for a later retry.
|
|
1735
1792
|
exports.CLAIM_STALE_MS = 15 * 60 * 1000;
|
|
1793
|
+
// A finish-input file older than this is an orphan: its capture-finish child
|
|
1794
|
+
// normally consumes + unlinks it within seconds, so an hour-old file means the
|
|
1795
|
+
// child died (spawn failure, OOM/AV kill, machine sleep). Generous so we never
|
|
1796
|
+
// race a legitimately slow child.
|
|
1797
|
+
exports.FINISH_ORPHAN_AGE_MS = 60 * 60 * 1000;
|
|
1736
1798
|
// Retry kept-on-failure capture jobs: re-dispatch each pending
|
|
1737
1799
|
// `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
|
|
1738
1800
|
// gives up at the cap). Best-effort — never throws into the hook.
|
|
@@ -1770,6 +1832,37 @@ function sweepPendingCaptures(deps) {
|
|
|
1770
1832
|
// raced another sweeper or the uploader just finished — fine
|
|
1771
1833
|
}
|
|
1772
1834
|
}
|
|
1835
|
+
// Orphaned finish-inputs: the FIRST detached child (capture-finish) died
|
|
1836
|
+
// before it could build an upload job. That path has no retry queue — the
|
|
1837
|
+
// payload is gone — but the loss must not stay invisible: report the
|
|
1838
|
+
// sessionId (filename embeds it: codiedev-finish-<sid>-<pid>-<ts>.json) so
|
|
1839
|
+
// it lands as a skip-reason, then clean the file up.
|
|
1840
|
+
if (deps.unlinkSync) {
|
|
1841
|
+
for (const f of files) {
|
|
1842
|
+
const m = /^codiedev-finish-(.+)-\d+-\d+\.json$/.exec(f);
|
|
1843
|
+
if (!m)
|
|
1844
|
+
continue;
|
|
1845
|
+
const orphanPath = path.join(deps.dir, f);
|
|
1846
|
+
try {
|
|
1847
|
+
if (now - deps.statSync(orphanPath).mtimeMs < exports.FINISH_ORPHAN_AGE_MS)
|
|
1848
|
+
continue;
|
|
1849
|
+
deps.unlinkSync(orphanPath);
|
|
1850
|
+
// finishInputTmpFile falls back to the PID when the hook had no
|
|
1851
|
+
// session_id (codiedev-finish-<pid>-<pid>-<ts>.json). A pure-digit
|
|
1852
|
+
// capture is that fallback, not a session id — reporting it would
|
|
1853
|
+
// create a phantom session row server-side. Real session ids are
|
|
1854
|
+
// UUIDs and never all-digits.
|
|
1855
|
+
const sid = /^\d+$/.test(m[1]) ? undefined : m[1];
|
|
1856
|
+
try {
|
|
1857
|
+
deps.reportOrphan?.(sid);
|
|
1858
|
+
}
|
|
1859
|
+
catch { /* report is best-effort */ }
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
// raced its owner or another sweeper — fine
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1773
1866
|
const jobs = files
|
|
1774
1867
|
.filter((f) => /^codiedev-capture-.*\.json$/.test(f))
|
|
1775
1868
|
.map((f) => path.join(deps.dir, f))
|
|
@@ -1936,6 +2029,13 @@ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now
|
|
|
1936
2029
|
// dir missing/unreadable — nothing to prune
|
|
1937
2030
|
}
|
|
1938
2031
|
}
|
|
2032
|
+
// Base64 length above which the transcript goes straight to storage instead
|
|
2033
|
+
// of inline in the captureSession JSON body. The backend's HTTP-action
|
|
2034
|
+
// isolate has a 64MB cap and inline base64 amplifies ~8x in memory (request
|
|
2035
|
+
// text + parsed JSON + decode output all resident) — ~7MB transcripts OOM it
|
|
2036
|
+
// (observed live 2026-07-02). 2M base64 chars ≈ 1.5MB raw: far below the
|
|
2037
|
+
// cliff, while per-turn snapshots (typically KBs) skip the extra roundtrips.
|
|
2038
|
+
exports.DIRECT_UPLOAD_THRESHOLD_CHARS = 2_000_000;
|
|
1939
2039
|
// "Last session captured" breadcrumb. Written next to config.json on a
|
|
1940
2040
|
// successful capture upload; read by `codiedev doctor` so a user can confirm
|
|
1941
2041
|
// their most recent session actually landed (the single hardest thing to
|
|
@@ -1994,7 +2094,22 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
1994
2094
|
}
|
|
1995
2095
|
const sessionId = job.body?.sessionId;
|
|
1996
2096
|
try {
|
|
1997
|
-
|
|
2097
|
+
// Big transcript + capable deps → direct-to-storage: fetch a one-time
|
|
2098
|
+
// upload URL, PUT the raw bytes, send captureSession the storageId. Any
|
|
2099
|
+
// step failing throws into the retry path below, same as a failed POST.
|
|
2100
|
+
let body = job.body;
|
|
2101
|
+
const transcript = body?.transcript;
|
|
2102
|
+
if (transcript &&
|
|
2103
|
+
transcript.length > exports.DIRECT_UPLOAD_THRESHOLD_CHARS &&
|
|
2104
|
+
deps.requestJson &&
|
|
2105
|
+
deps.uploadBytes) {
|
|
2106
|
+
const uploadUrlEndpoint = job.url.replace(/\/api\/captureSession$/, "/api/captureUploadUrl");
|
|
2107
|
+
const { uploadUrl } = await deps.requestJson(uploadUrlEndpoint, {}, job.token);
|
|
2108
|
+
const { storageId } = await deps.uploadBytes(uploadUrl, Buffer.from(transcript, "base64"), "application/json");
|
|
2109
|
+
body = { ...body, transcriptStorageId: storageId };
|
|
2110
|
+
delete body.transcript;
|
|
2111
|
+
}
|
|
2112
|
+
await deps.post(job.url, body, job.token);
|
|
1998
2113
|
// success — drop a "last session captured" breadcrumb (before removing the
|
|
1999
2114
|
// job) so doctor can confirm the most recent session actually landed.
|
|
2000
2115
|
if (deps.recordSuccess) {
|
|
@@ -2046,10 +2161,30 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
2046
2161
|
}
|
|
2047
2162
|
catch { /* log is best-effort */ }
|
|
2048
2163
|
}
|
|
2164
|
+
// Unlink FIRST: if the skip ping below hangs (no timeout on injected
|
|
2165
|
+
// post impls), a still-present job file would be reclaimed by the
|
|
2166
|
+
// 15-min stale-claim sweep and re-enter this give-up path forever.
|
|
2049
2167
|
try {
|
|
2050
2168
|
deps.unlinkSync(jobFile);
|
|
2051
2169
|
}
|
|
2052
2170
|
catch { /* gone */ }
|
|
2171
|
+
// Tell the server this capture is permanently lost and why. The full
|
|
2172
|
+
// transcript POST may be what's blocked (proxy/DLP body inspection) —
|
|
2173
|
+
// this tiny ping often still passes, turning an invisible loss into a
|
|
2174
|
+
// per-reason bucket on the admin Capture Health panel.
|
|
2175
|
+
if (sessionId) {
|
|
2176
|
+
try {
|
|
2177
|
+
const skipUrl = job.url.replace(/\/api\/captureSession$/, "/api/cli/sessionSkip");
|
|
2178
|
+
if (skipUrl !== job.url) {
|
|
2179
|
+
await deps.post(skipUrl, {
|
|
2180
|
+
sessionId,
|
|
2181
|
+
reason: expired ? "upload-gave-up:ttl" : "upload-gave-up:attempts",
|
|
2182
|
+
at: now,
|
|
2183
|
+
}, job.token);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
catch { /* best-effort — the ping may be blocked too */ }
|
|
2187
|
+
}
|
|
2053
2188
|
return;
|
|
2054
2189
|
}
|
|
2055
2190
|
// Log the error before persisting the retry bump.
|
|
@@ -2113,3 +2248,15 @@ async function runDeferredUpload(jobFile, delayMs, deps) {
|
|
|
2113
2248
|
}
|
|
2114
2249
|
await runCaptureUpload(claimed, deps);
|
|
2115
2250
|
}
|
|
2251
|
+
// Classify a transcript read failure at capture time. ENOENT = the transcript
|
|
2252
|
+
// never existed: a desktop-app phantom helper session (SessionStart/End hooks
|
|
2253
|
+
// fire, no conversation ever happens), not lost work — the backend excludes
|
|
2254
|
+
// "no-transcript" from coverage. Anything else means a real transcript was
|
|
2255
|
+
// there but the read failed: a genuine loss, tagged with the errno so it stays
|
|
2256
|
+
// diagnosable from the server side.
|
|
2257
|
+
function transcriptReadSkipReason(err) {
|
|
2258
|
+
const code = err?.code;
|
|
2259
|
+
if (code === "ENOENT")
|
|
2260
|
+
return "no-transcript";
|
|
2261
|
+
return `transcript-unreadable:${code || "unknown"}`;
|
|
2262
|
+
}
|
package/package.json
CHANGED