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 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
- process.exit(0);
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
- const { branch, branches } = keys;
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 fully detached child and exit immediately. Awaiting
306
- // it inline let Claude Code's SessionEnd shutdown window elapse and kill the
307
- // still-running hook ("Hook cancelled") on large transcripts — even though
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);
@@ -25,17 +25,25 @@ function parseRemoteUrl(url) {
25
25
  }
26
26
  function resolveRepoForCapture(input) {
27
27
  const { remoteUrl, providedRepoId, companyRepos } = input;
28
- if (providedRepoId) {
29
- const hit = companyRepos.find((r) => r._id === providedRepoId);
30
- if (hit)
31
- return { repoId: hit._id, source: "provided" };
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
- if (!parsed) {
35
- return { repoId: null, source: "unmatched" };
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
- const match = companyRepos.find((r) => `${r.owner}/${r.name}`.toLowerCase() === parsed.ownerName);
38
- if (match)
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 parseAuthoredShas(transcriptContent: string, repoRoot: string, resolveFull?: (sha: string) => string | null): string[];
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, resolveFull = (sha) => defaultResolveFullSha(sha, 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
- continue;
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
- function defaultResolveFullSha(sha, repoRoot) {
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 r = (0, child_process_1.execSync)(`git rev-parse ${sha}^{commit}`, {
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().trim();
444
- return /^[0-9a-f]{40}$/.test(r) ? r : null;
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
- return null;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "Connect Claude Code, Codex, Cursor, or VS Code Copilot to Codiedev for org-wide session capture and artifact collaboration",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",