codiedev 0.9.21 → 0.9.23
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/pull.d.ts +1 -0
- package/dist/commands/pull.js +32 -6
- package/dist/hook.d.ts +5 -1
- package/dist/hook.js +94 -20
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +113 -1
- package/package.json +1 -1
package/dist/commands/pull.d.ts
CHANGED
package/dist/commands/pull.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.candidateKeys = candidateKeys;
|
|
36
37
|
exports.runPull = runPull;
|
|
37
38
|
const fs = __importStar(require("fs"));
|
|
38
39
|
const path = __importStar(require("path"));
|
|
@@ -69,21 +70,45 @@ function parseArgs(args) {
|
|
|
69
70
|
}
|
|
70
71
|
return { key, version, out };
|
|
71
72
|
}
|
|
73
|
+
// The backend stores skills under extension-bearing keys (e.g. "pr-push.md"),
|
|
74
|
+
// and lookup is exact-match, so a bare "pr-push" 404s. Make pull forgiving: try
|
|
75
|
+
// the key as given, then a ".md" fallback when it has no extension, so the
|
|
76
|
+
// intuitive bare name (and the hook's suggested key) both resolve.
|
|
77
|
+
function candidateKeys(key) {
|
|
78
|
+
if (/\.[a-z0-9]+$/i.test(key))
|
|
79
|
+
return [key];
|
|
80
|
+
return [key, `${key}.md`];
|
|
81
|
+
}
|
|
72
82
|
async function runPull(args) {
|
|
73
83
|
const { key, version, out } = parseArgs(args);
|
|
74
84
|
const config = (0, shared_1.requireConfig)();
|
|
85
|
+
const keys = candidateKeys(key);
|
|
75
86
|
const start = Date.now();
|
|
76
87
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
let res;
|
|
89
|
+
for (let i = 0; i < keys.length; i++) {
|
|
90
|
+
try {
|
|
91
|
+
res = await (0, shared_1.apiRequest)("GET", "/api/cli/pull", {
|
|
92
|
+
config,
|
|
93
|
+
query: { key: keys[i], version },
|
|
94
|
+
});
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
const e = err;
|
|
99
|
+
// A 404 on a non-final candidate is retryable (fall through to ".md");
|
|
100
|
+
// anything else — or the last candidate — propagates to the handler.
|
|
101
|
+
if (e.status === 404 && i < keys.length - 1)
|
|
102
|
+
continue;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const a = res.artifact;
|
|
81
107
|
(0, shared_1.recordAgentEvent)(config, {
|
|
82
108
|
tool: "codiedev_pull",
|
|
83
109
|
ok: true,
|
|
84
110
|
latencyMs: Date.now() - start,
|
|
85
111
|
});
|
|
86
|
-
const a = res.artifact;
|
|
87
112
|
if (out) {
|
|
88
113
|
const absolute = path.isAbsolute(out) ? out : path.resolve(process.cwd(), out);
|
|
89
114
|
fs.writeFileSync(absolute, a.markdown, "utf8");
|
|
@@ -108,7 +133,8 @@ async function runPull(args) {
|
|
|
108
133
|
error: e.message,
|
|
109
134
|
});
|
|
110
135
|
if (e.status === 404) {
|
|
111
|
-
|
|
136
|
+
const also = keys.length > 1 ? ` (also tried ${keys.slice(1).join(", ")})` : "";
|
|
137
|
+
console.error(`Not found: ${key}${version ? ` v${version}` : ""}${also}`);
|
|
112
138
|
}
|
|
113
139
|
else {
|
|
114
140
|
console.error(`Pull failed: ${e.message}`);
|
package/dist/hook.d.ts
CHANGED
package/dist/hook.js
CHANGED
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
};
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.buildDiscoveryContext = buildDiscoveryContext;
|
|
37
38
|
const fs = __importStar(require("fs"));
|
|
38
39
|
const os = __importStar(require("os"));
|
|
39
40
|
const path = __importStar(require("path"));
|
|
@@ -74,6 +75,26 @@ function getJson(url, bearerToken) {
|
|
|
74
75
|
req.end();
|
|
75
76
|
});
|
|
76
77
|
}
|
|
78
|
+
// Pure builder for the discovery additionalContext. Returns null when there are
|
|
79
|
+
// no matches. The suggested `codiedev pull` command carries the EXACT top key
|
|
80
|
+
// (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
|
|
81
|
+
// paraphrase the key (pr-push) and the extension-sensitive `pull` 404s.
|
|
82
|
+
function buildDiscoveryContext(skills) {
|
|
83
|
+
if (skills.length === 0)
|
|
84
|
+
return null;
|
|
85
|
+
// Don't trust the API's ordering — rank by score so the suggested pull key is
|
|
86
|
+
// always the highest-confidence match.
|
|
87
|
+
const top = [...skills].sort((a, b) => b.score - a.score).slice(0, 3);
|
|
88
|
+
const lines = top
|
|
89
|
+
.map((s) => `- ${s.key} — ${(s.score * 100).toFixed(0)}% match`)
|
|
90
|
+
.join("\n");
|
|
91
|
+
return ("Your team has a CodieDev skill that fits this task — prefer it over improvising:\n" +
|
|
92
|
+
lines +
|
|
93
|
+
"\nPull and follow it now: `codiedev pull " +
|
|
94
|
+
top[0].key +
|
|
95
|
+
"`. It encodes the team's vetted " +
|
|
96
|
+
"approach. Skip it only if it clearly doesn't apply.");
|
|
97
|
+
}
|
|
77
98
|
// UserPromptSubmit: query skill discovery for the prompt and, if a team skill
|
|
78
99
|
// clears the confidence threshold, inject it as additionalContext so the agent
|
|
79
100
|
// can offer to pull+run it. Fully silent on miss/error — never blocks input.
|
|
@@ -86,15 +107,8 @@ async function runDiscover(prompt) {
|
|
|
86
107
|
try {
|
|
87
108
|
const res = await getJson(`${config.backendUrl}/api/cli/skill-discovery?q=${encodeURIComponent(prompt)}`, config.token);
|
|
88
109
|
const skills = res?.skills ?? [];
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
.slice(0, 3)
|
|
92
|
-
.map((s) => `- ${s.key} — ${(s.score * 100).toFixed(0)}% match`)
|
|
93
|
-
.join("\n");
|
|
94
|
-
const additionalContext = "Your team has a CodieDev skill that fits this task — prefer it over improvising:\n" +
|
|
95
|
-
lines +
|
|
96
|
-
"\nPull and follow it now: `codiedev pull <key>`. It encodes the team's vetted " +
|
|
97
|
-
"approach. Skip it only if it clearly doesn't apply.";
|
|
110
|
+
const additionalContext = buildDiscoveryContext(skills);
|
|
111
|
+
if (additionalContext) {
|
|
98
112
|
process.stdout.write(JSON.stringify({
|
|
99
113
|
hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext },
|
|
100
114
|
}));
|
|
@@ -144,6 +158,20 @@ async function runStopFailure(hookInput) {
|
|
|
144
158
|
}
|
|
145
159
|
process.exit(0);
|
|
146
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
|
+
}
|
|
147
175
|
// SessionStart: the capture-coverage denominator. Record that a session opened
|
|
148
176
|
// so the backend can later compute captured ÷ started. Deliberately tiny — no
|
|
149
177
|
// transcript read, no git — so it never slows session startup. Best-effort +
|
|
@@ -158,7 +186,7 @@ async function runSessionStart(hookInput) {
|
|
|
158
186
|
sessionId: hookInput.session_id,
|
|
159
187
|
startedAt: Date.now(),
|
|
160
188
|
source: "claude-code",
|
|
161
|
-
}, config.token);
|
|
189
|
+
}, config.token, PING_TIMEOUT_MS);
|
|
162
190
|
}
|
|
163
191
|
catch {
|
|
164
192
|
/* best-effort — never block session startup */
|
|
@@ -166,7 +194,13 @@ async function runSessionStart(hookInput) {
|
|
|
166
194
|
}
|
|
167
195
|
process.exit(0);
|
|
168
196
|
}
|
|
169
|
-
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) {
|
|
170
204
|
return new Promise((resolve, reject) => {
|
|
171
205
|
const parsed = new URL(url);
|
|
172
206
|
const data = JSON.stringify(body);
|
|
@@ -198,10 +232,19 @@ function postJson(url, body, bearerToken) {
|
|
|
198
232
|
});
|
|
199
233
|
});
|
|
200
234
|
req.on("error", reject);
|
|
235
|
+
if (timeoutMs) {
|
|
236
|
+
req.setTimeout(timeoutMs, () => {
|
|
237
|
+
req.destroy(new Error(`timeout after ${timeoutMs}ms`));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
201
240
|
req.write(data);
|
|
202
241
|
req.end();
|
|
203
242
|
});
|
|
204
243
|
}
|
|
244
|
+
// Short leash for the tiny coverage pings (sessionStart / sessionSkip): they
|
|
245
|
+
// carry a few bytes and must never stall a hook. 3s is generous for a ping
|
|
246
|
+
// and a hard bound for the shutdown clock.
|
|
247
|
+
const PING_TIMEOUT_MS = 3000;
|
|
205
248
|
async function readStdin() {
|
|
206
249
|
return new Promise((resolve, reject) => {
|
|
207
250
|
let data = "";
|
|
@@ -329,14 +372,12 @@ async function main() {
|
|
|
329
372
|
await runSessionStart(hookInput);
|
|
330
373
|
return;
|
|
331
374
|
}
|
|
332
|
-
// Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
(0, utils_1.
|
|
338
|
-
(0, utils_1.ensureLimitHookInstalled)();
|
|
339
|
-
(0, utils_1.ensureSessionStartHookInstalled)();
|
|
375
|
+
// Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
|
|
376
|
+
// every codiedev hook — INCLUDING SessionEnd→capture, which previously had
|
|
377
|
+
// no self-heal and could stay stripped forever while SessionStart kept
|
|
378
|
+
// pinging (the Sonifi "starts arrive, captures never" loss) — re-wired on
|
|
379
|
+
// their next Stop/SessionEnd. One read + one write, only when missing.
|
|
380
|
+
(0, utils_1.ensureAllCodiedevHooksInstalled)();
|
|
340
381
|
const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
|
|
341
382
|
const workspaceRoot = workspace_roots && workspace_roots.length > 0
|
|
342
383
|
? workspace_roots[0]
|
|
@@ -393,9 +434,14 @@ async function main() {
|
|
|
393
434
|
// finished and the upload went through, but CC had already given up waiting.
|
|
394
435
|
const config = (0, utils_1.readConfig)();
|
|
395
436
|
if (!config) {
|
|
437
|
+
// No config = no token; nothing can be sent, not even a skip ping.
|
|
396
438
|
process.exit(0);
|
|
397
439
|
}
|
|
398
440
|
if (!transcript_path) {
|
|
441
|
+
// Nothing to capture — but say so, instead of leaving a start ping with
|
|
442
|
+
// no matching capture (silent coverage loss). One tiny POST; the rare
|
|
443
|
+
// no-transcript case has no other work on the shutdown clock.
|
|
444
|
+
await postSessionSkip(config, session_id, "no-transcript-path");
|
|
399
445
|
process.exit(0);
|
|
400
446
|
}
|
|
401
447
|
try {
|
|
@@ -434,8 +480,10 @@ async function runCaptureFinish(input) {
|
|
|
434
480
|
if (!config)
|
|
435
481
|
return;
|
|
436
482
|
const { session_id, transcript_path, cwd, workspace_roots } = input;
|
|
437
|
-
if (!transcript_path)
|
|
483
|
+
if (!transcript_path) {
|
|
484
|
+
await postSessionSkip(config, session_id, "no-transcript-path");
|
|
438
485
|
return;
|
|
486
|
+
}
|
|
439
487
|
// Log that this child has started processing — the first diagnostic proof
|
|
440
488
|
// that the detached capture-finish child actually ran (visible in doctor).
|
|
441
489
|
(0, captureLog_1.appendCaptureLog)({ stage: "capture-finish", sessionId: session_id, outcome: "start" });
|
|
@@ -461,6 +509,16 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
461
509
|
transcriptContent = fs.readFileSync(transcript_path, "utf8");
|
|
462
510
|
}
|
|
463
511
|
catch {
|
|
512
|
+
// Previously the one fully-silent gap in the detached child: no log, no
|
|
513
|
+
// server signal. Diagnose it — deleted/rotated transcript, permissions,
|
|
514
|
+
// unmounted path.
|
|
515
|
+
(0, captureLog_1.appendCaptureLog)({
|
|
516
|
+
stage: "capture-finish",
|
|
517
|
+
sessionId: session_id,
|
|
518
|
+
outcome: "skip",
|
|
519
|
+
detail: "transcript-unreadable",
|
|
520
|
+
});
|
|
521
|
+
await postSessionSkip(config, session_id, "transcript-unreadable");
|
|
464
522
|
return;
|
|
465
523
|
}
|
|
466
524
|
// Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
|
|
@@ -579,6 +637,14 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
579
637
|
// Retry any earlier capture jobs that failed to upload (transient network
|
|
580
638
|
// blips), excluding the one we just dispatched. Best-effort, detached.
|
|
581
639
|
// Skip the sweep on deferred path — the deferred child will handle uploading.
|
|
640
|
+
//
|
|
641
|
+
// Orphan pings are COLLECTED and awaited below: this child process calls
|
|
642
|
+
// process.exit(0) as soon as runCaptureFinish resolves, so a
|
|
643
|
+
// fire-and-forget POST here would be killed mid-flight — after the
|
|
644
|
+
// orphan file was already unlinked, i.e. the loss would stay invisible
|
|
645
|
+
// forever. The await is off the shutdown clock (detached child) and
|
|
646
|
+
// bounded by PING_TIMEOUT_MS.
|
|
647
|
+
const orphanPings = [];
|
|
582
648
|
(0, utils_1.sweepPendingCaptures)({
|
|
583
649
|
dir: os.tmpdir(),
|
|
584
650
|
readdirSync: fs.readdirSync,
|
|
@@ -589,7 +655,15 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
589
655
|
exclude: tmpFile,
|
|
590
656
|
openSync: fs.openSync,
|
|
591
657
|
closeSync: fs.closeSync,
|
|
658
|
+
// Orphaned finish-inputs (capture-finish child died before writing an
|
|
659
|
+
// upload job): unrecoverable payload, but report the loss so it lands
|
|
660
|
+
// as a skip-reason instead of a silent coverage hole.
|
|
661
|
+
unlinkSync: fs.unlinkSync,
|
|
662
|
+
reportOrphan: (sid) => {
|
|
663
|
+
orphanPings.push(postSessionSkip(config, sid, "finish-orphaned"));
|
|
664
|
+
},
|
|
592
665
|
});
|
|
666
|
+
await Promise.all(orphanPings);
|
|
593
667
|
}
|
|
594
668
|
else {
|
|
595
669
|
// 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;
|
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.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;
|
|
@@ -913,6 +914,61 @@ function ensureLimitHookInstalled() {
|
|
|
913
914
|
}
|
|
914
915
|
catch { /* best-effort */ }
|
|
915
916
|
}
|
|
917
|
+
// Self-heal ALL codiedev hooks in one read → one write. This replaces calling
|
|
918
|
+
// the four per-hook ensure* functions in sequence, and fixes two real losses
|
|
919
|
+
// seen in the field (Sonifi, 2026-07):
|
|
920
|
+
// 1. The SessionEnd → capture hook — the one that actually uploads sessions
|
|
921
|
+
// — had NO self-heal at all: it was written once at `connect` and never
|
|
922
|
+
// repaired. Anything that stripped it (IT-managed dotfiles baseline, the
|
|
923
|
+
// race below) left SessionStart pinging forever while captures silently
|
|
924
|
+
// stopped — the exact "start pings arrive, captures never" signature.
|
|
925
|
+
// 2. Each per-hook ensure* did its own read-modify-write of settings.json
|
|
926
|
+
// with no locking; two concurrent Claude Code sessions repairing
|
|
927
|
+
// different hooks could lose each other's writes. One read + one write
|
|
928
|
+
// shrinks that window and removes the intra-process version entirely.
|
|
929
|
+
// The per-hook ensure* functions below are kept for tests/back-compat but are
|
|
930
|
+
// no longer called from the hook entrypoint.
|
|
931
|
+
function ensureAllCodiedevHooksInstalled() {
|
|
932
|
+
try {
|
|
933
|
+
if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
|
|
934
|
+
return; // not a Claude Code user
|
|
935
|
+
const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
|
|
936
|
+
const hooks = (settings.hooks ?? {});
|
|
937
|
+
const wanted = [
|
|
938
|
+
["SessionEnd", "capture"],
|
|
939
|
+
["UserPromptSubmit", "discover"],
|
|
940
|
+
["Stop", "backup"],
|
|
941
|
+
["StopFailure", "limit", "rate_limit"],
|
|
942
|
+
["SessionStart", "session-start"],
|
|
943
|
+
];
|
|
944
|
+
const missing = wanted.filter(([eventName]) => !hasCodiedevHook(hooks, eventName));
|
|
945
|
+
if (missing.length === 0)
|
|
946
|
+
return; // steady state: no write, no churn
|
|
947
|
+
settings.hooks = hooks;
|
|
948
|
+
for (const [eventName, subcommand, matcher] of missing) {
|
|
949
|
+
upsertCodiedevHook(hooks, eventName, subcommand, matcher);
|
|
950
|
+
}
|
|
951
|
+
fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
// Best-effort — never block a session on self-heal.
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
function hasCodiedevHook(hooks, eventName) {
|
|
958
|
+
const entries = hooks[eventName] ?? [];
|
|
959
|
+
// A malformed (non-array) event value would make `.some` throw and — under
|
|
960
|
+
// ensureAll's single try/catch — abort EVERY heal, including the capture
|
|
961
|
+
// heal. Report "present" instead: leave the foreign/corrupt key alone and
|
|
962
|
+
// keep healing the other events (the per-hook fault isolation the old
|
|
963
|
+
// ensure* functions had).
|
|
964
|
+
if (!Array.isArray(entries))
|
|
965
|
+
return true;
|
|
966
|
+
return entries.some((hook) => {
|
|
967
|
+
const inner = hook.hooks;
|
|
968
|
+
return (Array.isArray(inner) &&
|
|
969
|
+
inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command)));
|
|
970
|
+
});
|
|
971
|
+
}
|
|
916
972
|
// Self-heal: ensure the SessionStart → session-start hook is present without a
|
|
917
973
|
// full `connect` re-run, mirroring ensureLimitHookInstalled. Called from the
|
|
918
974
|
// capture hook so an upgraded-but-not-reconnected user starts emitting the
|
|
@@ -1733,6 +1789,11 @@ function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
|
|
|
1733
1789
|
// A claimed job whose uploader hasn't finished after this long is presumed
|
|
1734
1790
|
// crashed and gets renamed back to its canonical name for a later retry.
|
|
1735
1791
|
exports.CLAIM_STALE_MS = 15 * 60 * 1000;
|
|
1792
|
+
// A finish-input file older than this is an orphan: its capture-finish child
|
|
1793
|
+
// normally consumes + unlinks it within seconds, so an hour-old file means the
|
|
1794
|
+
// child died (spawn failure, OOM/AV kill, machine sleep). Generous so we never
|
|
1795
|
+
// race a legitimately slow child.
|
|
1796
|
+
exports.FINISH_ORPHAN_AGE_MS = 60 * 60 * 1000;
|
|
1736
1797
|
// Retry kept-on-failure capture jobs: re-dispatch each pending
|
|
1737
1798
|
// `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
|
|
1738
1799
|
// gives up at the cap). Best-effort — never throws into the hook.
|
|
@@ -1770,6 +1831,37 @@ function sweepPendingCaptures(deps) {
|
|
|
1770
1831
|
// raced another sweeper or the uploader just finished — fine
|
|
1771
1832
|
}
|
|
1772
1833
|
}
|
|
1834
|
+
// Orphaned finish-inputs: the FIRST detached child (capture-finish) died
|
|
1835
|
+
// before it could build an upload job. That path has no retry queue — the
|
|
1836
|
+
// payload is gone — but the loss must not stay invisible: report the
|
|
1837
|
+
// sessionId (filename embeds it: codiedev-finish-<sid>-<pid>-<ts>.json) so
|
|
1838
|
+
// it lands as a skip-reason, then clean the file up.
|
|
1839
|
+
if (deps.unlinkSync) {
|
|
1840
|
+
for (const f of files) {
|
|
1841
|
+
const m = /^codiedev-finish-(.+)-\d+-\d+\.json$/.exec(f);
|
|
1842
|
+
if (!m)
|
|
1843
|
+
continue;
|
|
1844
|
+
const orphanPath = path.join(deps.dir, f);
|
|
1845
|
+
try {
|
|
1846
|
+
if (now - deps.statSync(orphanPath).mtimeMs < exports.FINISH_ORPHAN_AGE_MS)
|
|
1847
|
+
continue;
|
|
1848
|
+
deps.unlinkSync(orphanPath);
|
|
1849
|
+
// finishInputTmpFile falls back to the PID when the hook had no
|
|
1850
|
+
// session_id (codiedev-finish-<pid>-<pid>-<ts>.json). A pure-digit
|
|
1851
|
+
// capture is that fallback, not a session id — reporting it would
|
|
1852
|
+
// create a phantom session row server-side. Real session ids are
|
|
1853
|
+
// UUIDs and never all-digits.
|
|
1854
|
+
const sid = /^\d+$/.test(m[1]) ? undefined : m[1];
|
|
1855
|
+
try {
|
|
1856
|
+
deps.reportOrphan?.(sid);
|
|
1857
|
+
}
|
|
1858
|
+
catch { /* report is best-effort */ }
|
|
1859
|
+
}
|
|
1860
|
+
catch {
|
|
1861
|
+
// raced its owner or another sweeper — fine
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1773
1865
|
const jobs = files
|
|
1774
1866
|
.filter((f) => /^codiedev-capture-.*\.json$/.test(f))
|
|
1775
1867
|
.map((f) => path.join(deps.dir, f))
|
|
@@ -2046,10 +2138,30 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
2046
2138
|
}
|
|
2047
2139
|
catch { /* log is best-effort */ }
|
|
2048
2140
|
}
|
|
2141
|
+
// Unlink FIRST: if the skip ping below hangs (no timeout on injected
|
|
2142
|
+
// post impls), a still-present job file would be reclaimed by the
|
|
2143
|
+
// 15-min stale-claim sweep and re-enter this give-up path forever.
|
|
2049
2144
|
try {
|
|
2050
2145
|
deps.unlinkSync(jobFile);
|
|
2051
2146
|
}
|
|
2052
2147
|
catch { /* gone */ }
|
|
2148
|
+
// Tell the server this capture is permanently lost and why. The full
|
|
2149
|
+
// transcript POST may be what's blocked (proxy/DLP body inspection) —
|
|
2150
|
+
// this tiny ping often still passes, turning an invisible loss into a
|
|
2151
|
+
// per-reason bucket on the admin Capture Health panel.
|
|
2152
|
+
if (sessionId) {
|
|
2153
|
+
try {
|
|
2154
|
+
const skipUrl = job.url.replace(/\/api\/captureSession$/, "/api/cli/sessionSkip");
|
|
2155
|
+
if (skipUrl !== job.url) {
|
|
2156
|
+
await deps.post(skipUrl, {
|
|
2157
|
+
sessionId,
|
|
2158
|
+
reason: expired ? "upload-gave-up:ttl" : "upload-gave-up:attempts",
|
|
2159
|
+
at: now,
|
|
2160
|
+
}, job.token);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
catch { /* best-effort — the ping may be blocked too */ }
|
|
2164
|
+
}
|
|
2053
2165
|
return;
|
|
2054
2166
|
}
|
|
2055
2167
|
// Log the error before persisting the retry bump.
|
package/package.json
CHANGED