codiedev 0.9.13 → 0.9.14
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 +86 -10
- package/dist/repoResolver.js +18 -10
- package/dist/utils.d.ts +10 -1
- package/dist/utils.js +122 -13
- package/package.json +1 -1
package/dist/hook.js
CHANGED
|
@@ -186,6 +186,34 @@ async function main() {
|
|
|
186
186
|
await runBackupUpload(process.argv[3]);
|
|
187
187
|
process.exit(0);
|
|
188
188
|
}
|
|
189
|
+
// Detached capture-finish child (spawned by the SessionEnd capture path
|
|
190
|
+
// below). The job carries only the raw hook inputs; this child does ALL the
|
|
191
|
+
// heavy work — read the transcript, parse it, resolve git branch/SHAs (the
|
|
192
|
+
// per-SHA `git rev-parse` calls that were overrunning CC's shutdown window
|
|
193
|
+
// on Windows), build the payload, and dispatch the upload. Running here means
|
|
194
|
+
// none of it is on the SessionEnd shutdown clock, so it can never be
|
|
195
|
+
// "Hook cancelled". No stdin needed — handle it before readStdin().
|
|
196
|
+
if (process.argv[2] === "capture-finish") {
|
|
197
|
+
const jobFile = process.argv[3];
|
|
198
|
+
if (jobFile) {
|
|
199
|
+
try {
|
|
200
|
+
const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
|
201
|
+
await runCaptureFinish(job);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// best-effort — never throw out of the detached child
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
try {
|
|
208
|
+
fs.unlinkSync(jobFile);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
/* ignore */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
189
217
|
let hookInput = {};
|
|
190
218
|
try {
|
|
191
219
|
const raw = await readStdin();
|
|
@@ -201,10 +229,10 @@ async function main() {
|
|
|
201
229
|
await runDiscover(hookInput.prompt);
|
|
202
230
|
return;
|
|
203
231
|
}
|
|
204
|
-
const mode = resolveMode(process.argv[2]);
|
|
205
232
|
// Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets the
|
|
206
233
|
// discovery + auto-backup hooks wired on their next SessionEnd, without
|
|
207
|
-
// re-connecting.
|
|
234
|
+
// re-connecting. Cheap (settings.json read, writes only when missing), so it
|
|
235
|
+
// stays inline; the costly transcript/git work is what moves to the child.
|
|
208
236
|
(0, utils_1.ensureDiscoveryHookInstalled)();
|
|
209
237
|
(0, utils_1.ensureBackupHookInstalled)();
|
|
210
238
|
const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
|
|
@@ -219,6 +247,15 @@ async function main() {
|
|
|
219
247
|
runBackup(workspaceRoot, process.argv[1]);
|
|
220
248
|
process.exit(0);
|
|
221
249
|
}
|
|
250
|
+
// Capture path (SessionEnd). Do the ABSOLUTE MINIMUM inline so this hook
|
|
251
|
+
// exits 0 within Claude Code's SessionEnd shutdown window: a cheap config /
|
|
252
|
+
// transcript guard, then hand ALL the heavy work (transcript read + parse +
|
|
253
|
+
// per-SHA `git rev-parse` + upload) to a detached `capture-finish` child.
|
|
254
|
+
// Detaching only the upload (the old #240 fix) left the synchronous parse and
|
|
255
|
+
// git work on the shutdown clock, which still overran on Windows — where each
|
|
256
|
+
// subprocess spawn costs ~50-150ms — producing "Hook cancelled" even though
|
|
257
|
+
// the data still landed. JS can't be interrupted mid-sync-chain, so the work
|
|
258
|
+
// finished and the upload went through, but CC had already given up waiting.
|
|
222
259
|
const config = (0, utils_1.readConfig)();
|
|
223
260
|
if (!config) {
|
|
224
261
|
process.exit(0);
|
|
@@ -226,12 +263,45 @@ async function main() {
|
|
|
226
263
|
if (!transcript_path) {
|
|
227
264
|
process.exit(0);
|
|
228
265
|
}
|
|
266
|
+
try {
|
|
267
|
+
const jobFile = path.join(os.tmpdir(), `codiedev-capture-job-${session_id ?? process.pid}.json`);
|
|
268
|
+
(0, utils_1.dispatchCaptureFinish)({
|
|
269
|
+
mode: process.argv[2] ?? "capture",
|
|
270
|
+
session_id,
|
|
271
|
+
transcript_path,
|
|
272
|
+
cwd,
|
|
273
|
+
workspace_roots,
|
|
274
|
+
}, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile });
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
|
|
278
|
+
}
|
|
279
|
+
process.exit(0);
|
|
280
|
+
}
|
|
281
|
+
// Runs inside the detached `capture-finish` child spawned by the SessionEnd
|
|
282
|
+
// hook above. This is everything that used to run inline in main(): read the
|
|
283
|
+
// transcript, resolve the repo, parse stats/usage/steering, resolve the git
|
|
284
|
+
// branch + authored SHAs (the per-SHA `git rev-parse` calls that overran the
|
|
285
|
+
// shutdown window on Windows), build the payload, and fire the upload from a
|
|
286
|
+
// further-detached uploader. Running here means none of it is on the SessionEnd
|
|
287
|
+
// shutdown clock, so it can never be "Hook cancelled". Best-effort throughout.
|
|
288
|
+
async function runCaptureFinish(input) {
|
|
289
|
+
const config = (0, utils_1.readConfig)();
|
|
290
|
+
if (!config)
|
|
291
|
+
return;
|
|
292
|
+
const { session_id, transcript_path, cwd, workspace_roots } = input;
|
|
293
|
+
if (!transcript_path)
|
|
294
|
+
return;
|
|
295
|
+
const mode = resolveMode(input.mode);
|
|
296
|
+
const workspaceRoot = workspace_roots && workspace_roots.length > 0
|
|
297
|
+
? workspace_roots[0]
|
|
298
|
+
: cwd || process.cwd();
|
|
229
299
|
let transcriptContent;
|
|
230
300
|
try {
|
|
231
301
|
transcriptContent = fs.readFileSync(transcript_path, "utf8");
|
|
232
302
|
}
|
|
233
303
|
catch {
|
|
234
|
-
|
|
304
|
+
return;
|
|
235
305
|
}
|
|
236
306
|
// Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
|
|
237
307
|
// need the git remote of the workspace.
|
|
@@ -267,7 +337,16 @@ async function main() {
|
|
|
267
337
|
// remain the primary (most-recent) values for back-compat.
|
|
268
338
|
const captureDirs = (0, utils_1.resolveCaptureDirs)(transcriptContent, workspaceRoot);
|
|
269
339
|
const keys = (0, utils_1.collectCaptureKeys)(captureDirs);
|
|
270
|
-
|
|
340
|
+
// Union the worktree-dir branches with the branches the agent actually
|
|
341
|
+
// committed/pushed to (parsed from the transcript's git output). Like the
|
|
342
|
+
// SHAs below, these are independent of end-of-session filesystem state, so
|
|
343
|
+
// they rescue branch-join when the worktree was REMOVED before SessionEnd
|
|
344
|
+
// (the dominant miss: dir-resolution falls back to the parent's branch, never
|
|
345
|
+
// the `worktree-<name>` one the PR was opened from). Indexed server-side under
|
|
346
|
+
// every branch in the array, so the worktree's PR attributes regardless.
|
|
347
|
+
const authoredBranches = (0, utils_1.parseAuthoredBranches)(transcriptContent);
|
|
348
|
+
const branches = [...new Set([...keys.branches, ...authoredBranches])];
|
|
349
|
+
const branch = keys.branch ?? branches[0] ?? null;
|
|
271
350
|
// Union the worktree HEAD shas with the SHAs the agent actually committed/
|
|
272
351
|
// pushed (parsed from the transcript's git output, resolved via the shared
|
|
273
352
|
// object store). The authored shas are independent of end-of-session
|
|
@@ -302,11 +381,9 @@ async function main() {
|
|
|
302
381
|
interruptCount: steering.interrupts,
|
|
303
382
|
rejectionCount: steering.rejections,
|
|
304
383
|
});
|
|
305
|
-
// Fire the upload from a
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
// the body had already reached the server. The detached child outlives this
|
|
309
|
-
// process and finishes the POST in the background.
|
|
384
|
+
// Fire the upload from a further-detached child. We're already off the
|
|
385
|
+
// shutdown clock here, but reusing dispatchCaptureDetached keeps the upload
|
|
386
|
+
// retry + last-capture breadcrumb logic (runCaptureUpload) intact verbatim.
|
|
310
387
|
try {
|
|
311
388
|
const hookScriptPath = process.argv[1];
|
|
312
389
|
const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
|
|
@@ -324,7 +401,6 @@ async function main() {
|
|
|
324
401
|
catch (err) {
|
|
325
402
|
process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
|
|
326
403
|
}
|
|
327
|
-
process.exit(0);
|
|
328
404
|
}
|
|
329
405
|
main().catch(() => {
|
|
330
406
|
process.exit(0);
|
package/dist/repoResolver.js
CHANGED
|
@@ -25,17 +25,25 @@ function parseRemoteUrl(url) {
|
|
|
25
25
|
}
|
|
26
26
|
function resolveRepoForCapture(input) {
|
|
27
27
|
const { remoteUrl, providedRepoId, companyRepos } = input;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
// The remoteUrl is the ground truth of where the work happened — resolve it
|
|
29
|
+
// FIRST. A buggy client can send a wrong-but-valid providedRepoId (the Sonifi
|
|
30
|
+
// case: client matchRepo stamped every session with the org's first repo), so
|
|
31
|
+
// we must not blindly trust it over the URL. Keep providedRepoId only when it
|
|
32
|
+
// AGREES with the URL match, or when the URL can't be resolved at all.
|
|
33
33
|
const parsed = remoteUrl ? parseRemoteUrl(remoteUrl) : null;
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
const urlMatch = parsed
|
|
35
|
+
? companyRepos.find((r) => `${r.owner}/${r.name}`.toLowerCase() === parsed.ownerName)
|
|
36
|
+
: undefined;
|
|
37
|
+
const provided = providedRepoId
|
|
38
|
+
? companyRepos.find((r) => r._id === providedRepoId)
|
|
39
|
+
: undefined;
|
|
40
|
+
if (urlMatch) {
|
|
41
|
+
if (provided && provided._id === urlMatch._id) {
|
|
42
|
+
return { repoId: provided._id, source: "provided" };
|
|
43
|
+
}
|
|
44
|
+
return { repoId: urlMatch._id, source: "resolved" };
|
|
36
45
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return { repoId: match._id, source: "resolved" };
|
|
46
|
+
if (provided)
|
|
47
|
+
return { repoId: provided._id, source: "provided" };
|
|
40
48
|
return { repoId: null, source: "unmatched" };
|
|
41
49
|
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -36,7 +36,8 @@ export declare function collectCaptureKeys(dirs: string[], getBranch?: (d: strin
|
|
|
36
36
|
headShas: string[];
|
|
37
37
|
};
|
|
38
38
|
export declare function extractShaTokens(transcriptContent: string): string[];
|
|
39
|
-
export declare function
|
|
39
|
+
export declare function parseAuthoredBranches(transcriptContent: string): string[];
|
|
40
|
+
export declare function parseAuthoredShas(transcriptContent: string, repoRoot: string, resolveFullBatch?: (shas: string[]) => Map<string, string>): string[];
|
|
40
41
|
export declare function getGitBranch(cwd: string): string | null;
|
|
41
42
|
export declare function getGitHeadSha(cwd: string): string | null;
|
|
42
43
|
export interface CapturePayloadInput {
|
|
@@ -171,6 +172,14 @@ interface DispatchDeps {
|
|
|
171
172
|
tmpFile: string;
|
|
172
173
|
nodePath?: string;
|
|
173
174
|
}
|
|
175
|
+
export interface CaptureFinishInput {
|
|
176
|
+
mode: string;
|
|
177
|
+
session_id?: string;
|
|
178
|
+
transcript_path?: string;
|
|
179
|
+
cwd?: string;
|
|
180
|
+
workspace_roots?: string[];
|
|
181
|
+
}
|
|
182
|
+
export declare function dispatchCaptureFinish(input: CaptureFinishInput, hookScriptPath: string, deps: DispatchDeps): void;
|
|
174
183
|
export declare function dispatchCaptureDetached(job: CaptureUploadJob, hookScriptPath: string, deps: DispatchDeps): void;
|
|
175
184
|
interface SweepDeps {
|
|
176
185
|
dir: string;
|
package/dist/utils.js
CHANGED
|
@@ -45,6 +45,7 @@ exports.resolveCaptureDir = resolveCaptureDir;
|
|
|
45
45
|
exports.resolveCaptureDirs = resolveCaptureDirs;
|
|
46
46
|
exports.collectCaptureKeys = collectCaptureKeys;
|
|
47
47
|
exports.extractShaTokens = extractShaTokens;
|
|
48
|
+
exports.parseAuthoredBranches = parseAuthoredBranches;
|
|
48
49
|
exports.parseAuthoredShas = parseAuthoredShas;
|
|
49
50
|
exports.getGitBranch = getGitBranch;
|
|
50
51
|
exports.getGitHeadSha = getGitHeadSha;
|
|
@@ -79,6 +80,7 @@ exports.parseSessionStart = parseSessionStart;
|
|
|
79
80
|
exports.parseClaudeCodeStats = parseClaudeCodeStats;
|
|
80
81
|
exports.parseCodexStats = parseCodexStats;
|
|
81
82
|
exports.extractCodexRepoUrl = extractCodexRepoUrl;
|
|
83
|
+
exports.dispatchCaptureFinish = dispatchCaptureFinish;
|
|
82
84
|
exports.dispatchCaptureDetached = dispatchCaptureDetached;
|
|
83
85
|
exports.sweepPendingCaptures = sweepPendingCaptures;
|
|
84
86
|
exports.buildLastCaptureRecord = buildLastCaptureRecord;
|
|
@@ -256,9 +258,13 @@ function matchRepo(remoteUrl, repos) {
|
|
|
256
258
|
for (const repo of repos) {
|
|
257
259
|
const normalizedUrl = normalizeRepoUrl(repo.url);
|
|
258
260
|
const normalizedName = repo.fullName.toLowerCase();
|
|
261
|
+
// Match the REMOTE against this repo: exact normalized URL, or the remote
|
|
262
|
+
// ending with this repo's full path (`owner/.../name`). Both are anchored to
|
|
263
|
+
// `normalizedRemote` — never compare a repo to ITSELF (the old third clause
|
|
264
|
+
// `normalizedUrl.endsWith('/'+name)` was remote-independent and returned the
|
|
265
|
+
// first repo for every session, collapsing all repos for multi-repo orgs).
|
|
259
266
|
if (normalizedRemote === normalizedUrl ||
|
|
260
|
-
normalizedRemote.endsWith(`/${normalizedName}`)
|
|
261
|
-
normalizedUrl.endsWith(`/${normalizedName}`)) {
|
|
267
|
+
normalizedRemote.endsWith(`/${normalizedName}`)) {
|
|
262
268
|
return repo;
|
|
263
269
|
}
|
|
264
270
|
}
|
|
@@ -417,35 +423,124 @@ function extractShaTokens(transcriptContent) {
|
|
|
417
423
|
out.add(m[1].toLowerCase());
|
|
418
424
|
return [...out];
|
|
419
425
|
}
|
|
426
|
+
// Pull the branch NAMES the agent actually committed/pushed to, straight out of
|
|
427
|
+
// the transcript's git OUTPUT — the branch companion to extractShaTokens. This
|
|
428
|
+
// is the rescue for the dominant capture miss: the agent works in a
|
|
429
|
+
// `.claude/worktrees/<name>` worktree on branch `worktree-<name>`, commits/pushes
|
|
430
|
+
// /PRs, then the worktree is REMOVED before SessionEnd. resolveCaptureDirs then
|
|
431
|
+
// can't resolve the (deleted) worktree's git dir and falls back to the parent
|
|
432
|
+
// dir, capturing the PARENT's branch — so the PR's sourceBranch matches no
|
|
433
|
+
// captured session. The branch name survives in the commit line `[branch sha]`
|
|
434
|
+
// and the push ref `<local> -> <remote>`, both of which prove authorship (a
|
|
435
|
+
// read-only `git log` emits neither), so this stays mutation-scoped like the
|
|
436
|
+
// SHA parse. Pure string parse — no filesystem, no server change.
|
|
437
|
+
function parseAuthoredBranches(transcriptContent) {
|
|
438
|
+
const out = [];
|
|
439
|
+
const add = (b) => {
|
|
440
|
+
if (b && !out.includes(b))
|
|
441
|
+
out.push(b);
|
|
442
|
+
};
|
|
443
|
+
const scan = (text, re) => {
|
|
444
|
+
re.lastIndex = 0;
|
|
445
|
+
let m;
|
|
446
|
+
while ((m = re.exec(text)))
|
|
447
|
+
add(m[1]);
|
|
448
|
+
};
|
|
449
|
+
// From git OUTPUT (proof the work shipped on that branch):
|
|
450
|
+
// commit: `[<branch> [(root-commit) ]<sha>]`. `[^\]\s]+` rejects "detached
|
|
451
|
+
// HEAD" (the space breaks the token before the sha can match).
|
|
452
|
+
// push: `<sha>..<sha> <local> -> <remote>` (updated) / `[new branch]
|
|
453
|
+
// <local> -> <remote>` (created). Anchor on the push-ref shape so prose
|
|
454
|
+
// arrows ("a -> b") don't match; capture the remote (= the PR
|
|
455
|
+
// sourceBranch). `[^\s"]+` stops at the JSON string boundary.
|
|
456
|
+
const commitRe = /\[([^\]\s]+)(?:\s+\(root-commit\))?\s+[0-9a-f]{7,40}\]/g;
|
|
457
|
+
const pushRe = /(?:[0-9a-f]{7,40}\.\.[0-9a-f]{7,40}|\[new branch\])\s+\S+\s+->\s+([^\s"]+)/g;
|
|
458
|
+
// From CREATE commands (rescues work committed/pushed entirely OUTSIDE the
|
|
459
|
+
// session): only `checkout -b`/`switch -c` (branch CREATION — not plain
|
|
460
|
+
// checkout/switch navigation), and only from real Bash tool_use inputs, never
|
|
461
|
+
// prose/output, so a suggested command in assistant text can't false-match.
|
|
462
|
+
const createRe = /\bgit\s+(?:checkout\s+-b|switch\s+-c)\s+([^\s"'&;|]+)/g;
|
|
463
|
+
for (const line of transcriptContent.split("\n")) {
|
|
464
|
+
if (!line.trim())
|
|
465
|
+
continue;
|
|
466
|
+
// commit/push output: scan the raw serialized line (output lives in string
|
|
467
|
+
// fields); robust to non-JSON / partial lines.
|
|
468
|
+
scan(line, commitRe);
|
|
469
|
+
scan(line, pushRe);
|
|
470
|
+
let entry;
|
|
471
|
+
try {
|
|
472
|
+
entry = JSON.parse(line);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const content = entry?.message?.content;
|
|
478
|
+
if (!Array.isArray(content))
|
|
479
|
+
continue;
|
|
480
|
+
for (const b of content) {
|
|
481
|
+
if (b?.type === "tool_use" && b.name === "Bash" && typeof b.input?.command === "string") {
|
|
482
|
+
scan(b.input.command, createRe);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
420
488
|
// Resolve the extracted tokens to FULL 40-char SHAs via the repo's shared object
|
|
421
489
|
// store (a worktree's commits live in the main repo's .git/objects, so they
|
|
422
490
|
// resolve even after the worktree dir is removed). Full SHAs pass through; short
|
|
423
491
|
// ones are resolved; unresolvable ones are dropped (a short sha can't exact-
|
|
424
492
|
// match the worker's full commitShas). Keeps the server-side match exact.
|
|
425
|
-
function parseAuthoredShas(transcriptContent, repoRoot,
|
|
493
|
+
function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas) => defaultResolveFullShaBatch(shas, repoRoot)) {
|
|
426
494
|
const out = new Set();
|
|
495
|
+
const shorts = new Set();
|
|
427
496
|
for (const tok of extractShaTokens(transcriptContent)) {
|
|
428
|
-
if (tok.length === 40)
|
|
497
|
+
if (tok.length === 40)
|
|
429
498
|
out.add(tok);
|
|
430
|
-
|
|
499
|
+
else
|
|
500
|
+
shorts.add(tok);
|
|
501
|
+
}
|
|
502
|
+
if (shorts.size > 0) {
|
|
503
|
+
const resolved = resolveFullBatch([...shorts]);
|
|
504
|
+
for (const full of resolved.values()) {
|
|
505
|
+
if (full && /^[0-9a-f]{40}$/.test(full))
|
|
506
|
+
out.add(full.toLowerCase());
|
|
431
507
|
}
|
|
432
|
-
const full = resolveFull(tok);
|
|
433
|
-
if (full && /^[0-9a-f]{40}$/.test(full))
|
|
434
|
-
out.add(full.toLowerCase());
|
|
435
508
|
}
|
|
436
509
|
return [...out];
|
|
437
510
|
}
|
|
438
|
-
|
|
511
|
+
// Resolve many short SHAs to full in ONE git spawn via `git cat-file
|
|
512
|
+
// --batch-check` (reads requests on stdin, emits one result line per request,
|
|
513
|
+
// in order). The previous one-`git rev-parse`-per-sha approach cost N spawns;
|
|
514
|
+
// on Windows (~50-150ms/spawn) a long session's transcript made the detached
|
|
515
|
+
// capture child run for tens of seconds (measured 28s for 40 shas on a real
|
|
516
|
+
// windows-latest runner) — fragile if the child is torn down before it
|
|
517
|
+
// finishes. This is ~constant-time regardless of sha count. Missing objects
|
|
518
|
+
// yield a "<req> missing" line and are skipped. Never throws.
|
|
519
|
+
function defaultResolveFullShaBatch(shas, repoRoot) {
|
|
520
|
+
const out = new Map();
|
|
521
|
+
if (shas.length === 0)
|
|
522
|
+
return out;
|
|
439
523
|
try {
|
|
440
|
-
const
|
|
524
|
+
const input = shas.map((s) => `${s}^{commit}`).join("\n") + "\n";
|
|
525
|
+
const res = (0, child_process_1.execSync)("git cat-file --batch-check", {
|
|
441
526
|
cwd: repoRoot,
|
|
527
|
+
input,
|
|
442
528
|
stdio: ["pipe", "pipe", "pipe"],
|
|
443
|
-
}).toString()
|
|
444
|
-
|
|
529
|
+
}).toString();
|
|
530
|
+
const lines = res.split("\n");
|
|
531
|
+
// One output line per input line, in order: "<full-sha> commit <size>" when
|
|
532
|
+
// resolved; "<request> missing" / "<request> ambiguous" otherwise.
|
|
533
|
+
shas.forEach((sha, i) => {
|
|
534
|
+
const parts = (lines[i] ?? "").trim().split(/\s+/);
|
|
535
|
+
if (parts.length >= 2 && parts[1] === "commit" && /^[0-9a-f]{40}$/.test(parts[0])) {
|
|
536
|
+
out.set(sha, parts[0].toLowerCase());
|
|
537
|
+
}
|
|
538
|
+
});
|
|
445
539
|
}
|
|
446
540
|
catch {
|
|
447
|
-
|
|
541
|
+
// best-effort — a git failure just means no shas resolve
|
|
448
542
|
}
|
|
543
|
+
return out;
|
|
449
544
|
}
|
|
450
545
|
// Current branch via direct .git/HEAD read (no git binary required, same
|
|
451
546
|
// robustness as getGitRemoteUrl). Returns null on detached HEAD or when
|
|
@@ -1459,6 +1554,20 @@ function extractCodexRepoUrl(content) {
|
|
|
1459
1554
|
// failing job never litters /tmp forever. Paired with UPLOAD_TTL_MS.
|
|
1460
1555
|
exports.MAX_UPLOAD_ATTEMPTS = 5;
|
|
1461
1556
|
exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1557
|
+
// Hand the SessionEnd capture off to a detached child so the inline hook can
|
|
1558
|
+
// exit 0 immediately. Detaching only the network upload (dispatchCaptureDetached)
|
|
1559
|
+
// wasn't enough: the synchronous transcript read + parse and — the killer on
|
|
1560
|
+
// Windows — one `git rev-parse` per SHA in the transcript (parseAuthoredShas)
|
|
1561
|
+
// still ran inline, overrunning Claude Code's SessionEnd shutdown window and
|
|
1562
|
+
// triggering "Hook cancelled" on long sessions. This moves ALL of that work
|
|
1563
|
+
// into the child. The job carries only the raw hook inputs (the child re-reads
|
|
1564
|
+
// the transcript and re-runs git), so the parent's inline work is a single
|
|
1565
|
+
// small file write plus a spawn.
|
|
1566
|
+
function dispatchCaptureFinish(input, hookScriptPath, deps) {
|
|
1567
|
+
deps.writeFileSync(deps.tmpFile, JSON.stringify(input), { mode: 0o600 });
|
|
1568
|
+
const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "capture-finish", deps.tmpFile], { detached: true, stdio: "ignore" });
|
|
1569
|
+
child.unref();
|
|
1570
|
+
}
|
|
1462
1571
|
// Fire-and-forget the session-capture upload from a fully detached child so the
|
|
1463
1572
|
// SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
|
|
1464
1573
|
// Code's shutdown window elapse and kill the hook ("Hook cancelled") on large
|
package/package.json
CHANGED