codiedev 0.9.13 → 0.9.15

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
@@ -41,6 +41,7 @@ const https = __importStar(require("https"));
41
41
  const http = __importStar(require("http"));
42
42
  const child_process_1 = require("child_process");
43
43
  const utils_1 = require("./utils");
44
+ const limitEvent_1 = require("./limitEvent");
44
45
  const version_1 = require("./version");
45
46
  function getJson(url, bearerToken) {
46
47
  return new Promise((resolve, reject) => {
@@ -103,6 +104,39 @@ async function runDiscover(prompt) {
103
104
  }
104
105
  process.exit(0);
105
106
  }
107
+ // StopFailure (matcher rate_limit): the dev just got blocked. Read the local
108
+ // transcript tail for the ground-truth rate_limit line and POST a tiny event.
109
+ // Best-effort + silent — StopFailure output is ignored by Claude Code anyway.
110
+ async function runStopFailure(hookInput) {
111
+ const config = (0, utils_1.readConfig)();
112
+ if (!config)
113
+ process.exit(0);
114
+ let event = null;
115
+ const tp = hookInput.transcript_path;
116
+ if (tp) {
117
+ try {
118
+ event = (0, limitEvent_1.parseLimitEventFromTranscript)(fs.readFileSync(tp, "utf8"));
119
+ }
120
+ catch {
121
+ /* unreadable transcript — fall through to minimal event */
122
+ }
123
+ }
124
+ // The hook is registered with matcher "rate_limit", so a miss still means a
125
+ // real limit — emit a minimal event rather than dropping it.
126
+ const payload = event ?? { kind: "other", at: Date.now(), resetText: null };
127
+ try {
128
+ await postJson(`${config.backendUrl}/api/cli/limitEvent`, {
129
+ kind: payload.kind,
130
+ at: payload.at,
131
+ resetText: payload.resetText,
132
+ sessionId: hookInput.session_id,
133
+ }, config.token);
134
+ }
135
+ catch {
136
+ /* best-effort */
137
+ }
138
+ process.exit(0);
139
+ }
106
140
  function postJson(url, body, bearerToken) {
107
141
  return new Promise((resolve, reject) => {
108
142
  const parsed = new URL(url);
@@ -186,6 +220,34 @@ async function main() {
186
220
  await runBackupUpload(process.argv[3]);
187
221
  process.exit(0);
188
222
  }
223
+ // Detached capture-finish child (spawned by the SessionEnd capture path
224
+ // below). The job carries only the raw hook inputs; this child does ALL the
225
+ // heavy work — read the transcript, parse it, resolve git branch/SHAs (the
226
+ // per-SHA `git rev-parse` calls that were overrunning CC's shutdown window
227
+ // on Windows), build the payload, and dispatch the upload. Running here means
228
+ // none of it is on the SessionEnd shutdown clock, so it can never be
229
+ // "Hook cancelled". No stdin needed — handle it before readStdin().
230
+ if (process.argv[2] === "capture-finish") {
231
+ const jobFile = process.argv[3];
232
+ if (jobFile) {
233
+ try {
234
+ const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
235
+ await runCaptureFinish(job);
236
+ }
237
+ catch {
238
+ // best-effort — never throw out of the detached child
239
+ }
240
+ finally {
241
+ try {
242
+ fs.unlinkSync(jobFile);
243
+ }
244
+ catch {
245
+ /* ignore */
246
+ }
247
+ }
248
+ }
249
+ process.exit(0);
250
+ }
189
251
  let hookInput = {};
190
252
  try {
191
253
  const raw = await readStdin();
@@ -201,12 +263,18 @@ async function main() {
201
263
  await runDiscover(hookInput.prompt);
202
264
  return;
203
265
  }
204
- const mode = resolveMode(process.argv[2]);
266
+ // StopFailure (matcher rate_limit): post the rate-limit event.
267
+ if (process.argv[2] === "limit") {
268
+ await runStopFailure(hookInput);
269
+ return;
270
+ }
205
271
  // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets the
206
- // discovery + auto-backup hooks wired on their next SessionEnd, without
207
- // re-connecting.
272
+ // discovery + auto-backup + limit hooks wired on their next SessionEnd, without
273
+ // re-connecting. Cheap (settings.json read, writes only when missing), so it
274
+ // stays inline; the costly transcript/git work is what moves to the child.
208
275
  (0, utils_1.ensureDiscoveryHookInstalled)();
209
276
  (0, utils_1.ensureBackupHookInstalled)();
277
+ (0, utils_1.ensureLimitHookInstalled)();
210
278
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
211
279
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
212
280
  ? workspace_roots[0]
@@ -219,6 +287,15 @@ async function main() {
219
287
  runBackup(workspaceRoot, process.argv[1]);
220
288
  process.exit(0);
221
289
  }
290
+ // Capture path (SessionEnd). Do the ABSOLUTE MINIMUM inline so this hook
291
+ // exits 0 within Claude Code's SessionEnd shutdown window: a cheap config /
292
+ // transcript guard, then hand ALL the heavy work (transcript read + parse +
293
+ // per-SHA `git rev-parse` + upload) to a detached `capture-finish` child.
294
+ // Detaching only the upload (the old #240 fix) left the synchronous parse and
295
+ // git work on the shutdown clock, which still overran on Windows — where each
296
+ // subprocess spawn costs ~50-150ms — producing "Hook cancelled" even though
297
+ // the data still landed. JS can't be interrupted mid-sync-chain, so the work
298
+ // finished and the upload went through, but CC had already given up waiting.
222
299
  const config = (0, utils_1.readConfig)();
223
300
  if (!config) {
224
301
  process.exit(0);
@@ -226,12 +303,45 @@ async function main() {
226
303
  if (!transcript_path) {
227
304
  process.exit(0);
228
305
  }
306
+ try {
307
+ const jobFile = path.join(os.tmpdir(), `codiedev-capture-job-${session_id ?? process.pid}.json`);
308
+ (0, utils_1.dispatchCaptureFinish)({
309
+ mode: process.argv[2] ?? "capture",
310
+ session_id,
311
+ transcript_path,
312
+ cwd,
313
+ workspace_roots,
314
+ }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile });
315
+ }
316
+ catch (err) {
317
+ process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
318
+ }
319
+ process.exit(0);
320
+ }
321
+ // Runs inside the detached `capture-finish` child spawned by the SessionEnd
322
+ // hook above. This is everything that used to run inline in main(): read the
323
+ // transcript, resolve the repo, parse stats/usage/steering, resolve the git
324
+ // branch + authored SHAs (the per-SHA `git rev-parse` calls that overran the
325
+ // shutdown window on Windows), build the payload, and fire the upload from a
326
+ // further-detached uploader. Running here means none of it is on the SessionEnd
327
+ // shutdown clock, so it can never be "Hook cancelled". Best-effort throughout.
328
+ async function runCaptureFinish(input) {
329
+ const config = (0, utils_1.readConfig)();
330
+ if (!config)
331
+ return;
332
+ const { session_id, transcript_path, cwd, workspace_roots } = input;
333
+ if (!transcript_path)
334
+ return;
335
+ const mode = resolveMode(input.mode);
336
+ const workspaceRoot = workspace_roots && workspace_roots.length > 0
337
+ ? workspace_roots[0]
338
+ : cwd || process.cwd();
229
339
  let transcriptContent;
230
340
  try {
231
341
  transcriptContent = fs.readFileSync(transcript_path, "utf8");
232
342
  }
233
343
  catch {
234
- process.exit(0);
344
+ return;
235
345
  }
236
346
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
237
347
  // need the git remote of the workspace.
@@ -267,7 +377,16 @@ async function main() {
267
377
  // remain the primary (most-recent) values for back-compat.
268
378
  const captureDirs = (0, utils_1.resolveCaptureDirs)(transcriptContent, workspaceRoot);
269
379
  const keys = (0, utils_1.collectCaptureKeys)(captureDirs);
270
- const { branch, branches } = keys;
380
+ // Union the worktree-dir branches with the branches the agent actually
381
+ // committed/pushed to (parsed from the transcript's git output). Like the
382
+ // SHAs below, these are independent of end-of-session filesystem state, so
383
+ // they rescue branch-join when the worktree was REMOVED before SessionEnd
384
+ // (the dominant miss: dir-resolution falls back to the parent's branch, never
385
+ // the `worktree-<name>` one the PR was opened from). Indexed server-side under
386
+ // every branch in the array, so the worktree's PR attributes regardless.
387
+ const authoredBranches = (0, utils_1.parseAuthoredBranches)(transcriptContent);
388
+ const branches = [...new Set([...keys.branches, ...authoredBranches])];
389
+ const branch = keys.branch ?? branches[0] ?? null;
271
390
  // Union the worktree HEAD shas with the SHAs the agent actually committed/
272
391
  // pushed (parsed from the transcript's git output, resolved via the shared
273
392
  // object store). The authored shas are independent of end-of-session
@@ -302,11 +421,9 @@ async function main() {
302
421
  interruptCount: steering.interrupts,
303
422
  rejectionCount: steering.rejections,
304
423
  });
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.
424
+ // Fire the upload from a further-detached child. We're already off the
425
+ // shutdown clock here, but reusing dispatchCaptureDetached keeps the upload
426
+ // retry + last-capture breadcrumb logic (runCaptureUpload) intact verbatim.
310
427
  try {
311
428
  const hookScriptPath = process.argv[1];
312
429
  const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
@@ -324,7 +441,6 @@ async function main() {
324
441
  catch (err) {
325
442
  process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
326
443
  }
327
- process.exit(0);
328
444
  }
329
445
  main().catch(() => {
330
446
  process.exit(0);
@@ -0,0 +1,8 @@
1
+ export type LimitKind = "session" | "org_spend" | "other";
2
+ export interface LimitEvent {
3
+ kind: LimitKind;
4
+ at: number;
5
+ resetText: string | null;
6
+ }
7
+ export declare function classifyLimitKind(text: string): LimitKind;
8
+ export declare function parseLimitEventFromTranscript(transcript: string): LimitEvent | null;
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyLimitKind = classifyLimitKind;
4
+ exports.parseLimitEventFromTranscript = parseLimitEventFromTranscript;
5
+ function classifyLimitKind(text) {
6
+ const t = text.toLowerCase();
7
+ if (t.includes("session limit"))
8
+ return "session";
9
+ if (t.includes("spend limit") || t.includes("monthly spend"))
10
+ return "org_spend";
11
+ return "other";
12
+ }
13
+ // "… · resets 9:50pm (UTC)" → "9:50pm (UTC)". Null when absent.
14
+ function parseResetText(text) {
15
+ const m = text.match(/resets\s+(.+?)\s*$/i);
16
+ return m ? m[1].trim() : null;
17
+ }
18
+ function messageText(entry) {
19
+ const c = entry?.message?.content;
20
+ if (typeof c === "string")
21
+ return c;
22
+ if (Array.isArray(c))
23
+ return c.map((x) => (x && typeof x === "object" ? x.text ?? "" : "")).join(" ");
24
+ return "";
25
+ }
26
+ // Scans transcript JSONL for the LAST top-level `error: "rate_limit"` entry.
27
+ // (Validated shape: error/isApiErrorMessage are top-level, not under message.)
28
+ function parseLimitEventFromTranscript(transcript) {
29
+ let found = null;
30
+ for (const line of transcript.split("\n")) {
31
+ if (!line.includes('"rate_limit"'))
32
+ continue;
33
+ let e;
34
+ try {
35
+ e = JSON.parse(line);
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ if (e?.error !== "rate_limit" && !e?.isApiErrorMessage)
41
+ continue;
42
+ const text = messageText(e);
43
+ const at = e?.timestamp ? Date.parse(e.timestamp) : NaN;
44
+ found = {
45
+ kind: classifyLimitKind(text),
46
+ at: Number.isNaN(at) ? Date.now() : at,
47
+ resetText: parseResetText(text),
48
+ };
49
+ }
50
+ return found;
51
+ }
@@ -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 {
@@ -72,10 +73,11 @@ export declare function cursorInstalled(): boolean;
72
73
  * the presence of any `github.copilot*` directory as the Copilot fingerprint.
73
74
  */
74
75
  export declare function vscodeCopilotInstalled(): boolean;
75
- export declare function upsertCodiedevHook(hooks: Record<string, unknown>, eventName: string, subcommand: string): void;
76
+ export declare function upsertCodiedevHook(hooks: Record<string, unknown>, eventName: string, subcommand: string, matcher?: string): void;
76
77
  export declare function installHook(): void;
77
78
  export declare function ensureDiscoveryHookInstalled(): void;
78
79
  export declare function ensureBackupHookInstalled(): void;
80
+ export declare function ensureLimitHookInstalled(): void;
79
81
  export declare function installClaudeCodeInstructions(): void;
80
82
  export declare function installCodexInstructions(): void;
81
83
  export declare function installCursorInstructions(): void;
@@ -171,6 +173,14 @@ interface DispatchDeps {
171
173
  tmpFile: string;
172
174
  nodePath?: string;
173
175
  }
176
+ export interface CaptureFinishInput {
177
+ mode: string;
178
+ session_id?: string;
179
+ transcript_path?: string;
180
+ cwd?: string;
181
+ workspace_roots?: string[];
182
+ }
183
+ export declare function dispatchCaptureFinish(input: CaptureFinishInput, hookScriptPath: string, deps: DispatchDeps): void;
174
184
  export declare function dispatchCaptureDetached(job: CaptureUploadJob, hookScriptPath: string, deps: DispatchDeps): void;
175
185
  interface SweepDeps {
176
186
  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;
@@ -58,6 +59,7 @@ exports.upsertCodiedevHook = upsertCodiedevHook;
58
59
  exports.installHook = installHook;
59
60
  exports.ensureDiscoveryHookInstalled = ensureDiscoveryHookInstalled;
60
61
  exports.ensureBackupHookInstalled = ensureBackupHookInstalled;
62
+ exports.ensureLimitHookInstalled = ensureLimitHookInstalled;
61
63
  exports.installClaudeCodeInstructions = installClaudeCodeInstructions;
62
64
  exports.installCodexInstructions = installCodexInstructions;
63
65
  exports.installCursorInstructions = installCursorInstructions;
@@ -79,6 +81,7 @@ exports.parseSessionStart = parseSessionStart;
79
81
  exports.parseClaudeCodeStats = parseClaudeCodeStats;
80
82
  exports.parseCodexStats = parseCodexStats;
81
83
  exports.extractCodexRepoUrl = extractCodexRepoUrl;
84
+ exports.dispatchCaptureFinish = dispatchCaptureFinish;
82
85
  exports.dispatchCaptureDetached = dispatchCaptureDetached;
83
86
  exports.sweepPendingCaptures = sweepPendingCaptures;
84
87
  exports.buildLastCaptureRecord = buildLastCaptureRecord;
@@ -256,9 +259,13 @@ function matchRepo(remoteUrl, repos) {
256
259
  for (const repo of repos) {
257
260
  const normalizedUrl = normalizeRepoUrl(repo.url);
258
261
  const normalizedName = repo.fullName.toLowerCase();
262
+ // Match the REMOTE against this repo: exact normalized URL, or the remote
263
+ // ending with this repo's full path (`owner/.../name`). Both are anchored to
264
+ // `normalizedRemote` — never compare a repo to ITSELF (the old third clause
265
+ // `normalizedUrl.endsWith('/'+name)` was remote-independent and returned the
266
+ // first repo for every session, collapsing all repos for multi-repo orgs).
259
267
  if (normalizedRemote === normalizedUrl ||
260
- normalizedRemote.endsWith(`/${normalizedName}`) ||
261
- normalizedUrl.endsWith(`/${normalizedName}`)) {
268
+ normalizedRemote.endsWith(`/${normalizedName}`)) {
262
269
  return repo;
263
270
  }
264
271
  }
@@ -417,35 +424,124 @@ function extractShaTokens(transcriptContent) {
417
424
  out.add(m[1].toLowerCase());
418
425
  return [...out];
419
426
  }
427
+ // Pull the branch NAMES the agent actually committed/pushed to, straight out of
428
+ // the transcript's git OUTPUT — the branch companion to extractShaTokens. This
429
+ // is the rescue for the dominant capture miss: the agent works in a
430
+ // `.claude/worktrees/<name>` worktree on branch `worktree-<name>`, commits/pushes
431
+ // /PRs, then the worktree is REMOVED before SessionEnd. resolveCaptureDirs then
432
+ // can't resolve the (deleted) worktree's git dir and falls back to the parent
433
+ // dir, capturing the PARENT's branch — so the PR's sourceBranch matches no
434
+ // captured session. The branch name survives in the commit line `[branch sha]`
435
+ // and the push ref `<local> -> <remote>`, both of which prove authorship (a
436
+ // read-only `git log` emits neither), so this stays mutation-scoped like the
437
+ // SHA parse. Pure string parse — no filesystem, no server change.
438
+ function parseAuthoredBranches(transcriptContent) {
439
+ const out = [];
440
+ const add = (b) => {
441
+ if (b && !out.includes(b))
442
+ out.push(b);
443
+ };
444
+ const scan = (text, re) => {
445
+ re.lastIndex = 0;
446
+ let m;
447
+ while ((m = re.exec(text)))
448
+ add(m[1]);
449
+ };
450
+ // From git OUTPUT (proof the work shipped on that branch):
451
+ // commit: `[<branch> [(root-commit) ]<sha>]`. `[^\]\s]+` rejects "detached
452
+ // HEAD" (the space breaks the token before the sha can match).
453
+ // push: `<sha>..<sha> <local> -> <remote>` (updated) / `[new branch]
454
+ // <local> -> <remote>` (created). Anchor on the push-ref shape so prose
455
+ // arrows ("a -> b") don't match; capture the remote (= the PR
456
+ // sourceBranch). `[^\s"]+` stops at the JSON string boundary.
457
+ const commitRe = /\[([^\]\s]+)(?:\s+\(root-commit\))?\s+[0-9a-f]{7,40}\]/g;
458
+ const pushRe = /(?:[0-9a-f]{7,40}\.\.[0-9a-f]{7,40}|\[new branch\])\s+\S+\s+->\s+([^\s"]+)/g;
459
+ // From CREATE commands (rescues work committed/pushed entirely OUTSIDE the
460
+ // session): only `checkout -b`/`switch -c` (branch CREATION — not plain
461
+ // checkout/switch navigation), and only from real Bash tool_use inputs, never
462
+ // prose/output, so a suggested command in assistant text can't false-match.
463
+ const createRe = /\bgit\s+(?:checkout\s+-b|switch\s+-c)\s+([^\s"'&;|]+)/g;
464
+ for (const line of transcriptContent.split("\n")) {
465
+ if (!line.trim())
466
+ continue;
467
+ // commit/push output: scan the raw serialized line (output lives in string
468
+ // fields); robust to non-JSON / partial lines.
469
+ scan(line, commitRe);
470
+ scan(line, pushRe);
471
+ let entry;
472
+ try {
473
+ entry = JSON.parse(line);
474
+ }
475
+ catch {
476
+ continue;
477
+ }
478
+ const content = entry?.message?.content;
479
+ if (!Array.isArray(content))
480
+ continue;
481
+ for (const b of content) {
482
+ if (b?.type === "tool_use" && b.name === "Bash" && typeof b.input?.command === "string") {
483
+ scan(b.input.command, createRe);
484
+ }
485
+ }
486
+ }
487
+ return out;
488
+ }
420
489
  // Resolve the extracted tokens to FULL 40-char SHAs via the repo's shared object
421
490
  // store (a worktree's commits live in the main repo's .git/objects, so they
422
491
  // resolve even after the worktree dir is removed). Full SHAs pass through; short
423
492
  // ones are resolved; unresolvable ones are dropped (a short sha can't exact-
424
493
  // match the worker's full commitShas). Keeps the server-side match exact.
425
- function parseAuthoredShas(transcriptContent, repoRoot, resolveFull = (sha) => defaultResolveFullSha(sha, repoRoot)) {
494
+ function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas) => defaultResolveFullShaBatch(shas, repoRoot)) {
426
495
  const out = new Set();
496
+ const shorts = new Set();
427
497
  for (const tok of extractShaTokens(transcriptContent)) {
428
- if (tok.length === 40) {
498
+ if (tok.length === 40)
429
499
  out.add(tok);
430
- continue;
500
+ else
501
+ shorts.add(tok);
502
+ }
503
+ if (shorts.size > 0) {
504
+ const resolved = resolveFullBatch([...shorts]);
505
+ for (const full of resolved.values()) {
506
+ if (full && /^[0-9a-f]{40}$/.test(full))
507
+ out.add(full.toLowerCase());
431
508
  }
432
- const full = resolveFull(tok);
433
- if (full && /^[0-9a-f]{40}$/.test(full))
434
- out.add(full.toLowerCase());
435
509
  }
436
510
  return [...out];
437
511
  }
438
- function defaultResolveFullSha(sha, repoRoot) {
512
+ // Resolve many short SHAs to full in ONE git spawn via `git cat-file
513
+ // --batch-check` (reads requests on stdin, emits one result line per request,
514
+ // in order). The previous one-`git rev-parse`-per-sha approach cost N spawns;
515
+ // on Windows (~50-150ms/spawn) a long session's transcript made the detached
516
+ // capture child run for tens of seconds (measured 28s for 40 shas on a real
517
+ // windows-latest runner) — fragile if the child is torn down before it
518
+ // finishes. This is ~constant-time regardless of sha count. Missing objects
519
+ // yield a "<req> missing" line and are skipped. Never throws.
520
+ function defaultResolveFullShaBatch(shas, repoRoot) {
521
+ const out = new Map();
522
+ if (shas.length === 0)
523
+ return out;
439
524
  try {
440
- const r = (0, child_process_1.execSync)(`git rev-parse ${sha}^{commit}`, {
525
+ const input = shas.map((s) => `${s}^{commit}`).join("\n") + "\n";
526
+ const res = (0, child_process_1.execSync)("git cat-file --batch-check", {
441
527
  cwd: repoRoot,
528
+ input,
442
529
  stdio: ["pipe", "pipe", "pipe"],
443
- }).toString().trim();
444
- return /^[0-9a-f]{40}$/.test(r) ? r : null;
530
+ }).toString();
531
+ const lines = res.split("\n");
532
+ // One output line per input line, in order: "<full-sha> commit <size>" when
533
+ // resolved; "<request> missing" / "<request> ambiguous" otherwise.
534
+ shas.forEach((sha, i) => {
535
+ const parts = (lines[i] ?? "").trim().split(/\s+/);
536
+ if (parts.length >= 2 && parts[1] === "commit" && /^[0-9a-f]{40}$/.test(parts[0])) {
537
+ out.set(sha, parts[0].toLowerCase());
538
+ }
539
+ });
445
540
  }
446
541
  catch {
447
- return null;
542
+ // best-effort — a git failure just means no shas resolve
448
543
  }
544
+ return out;
449
545
  }
450
546
  // Current branch via direct .git/HEAD read (no git binary required, same
451
547
  // robustness as getGitRemoteUrl). Returns null on detached HEAD or when
@@ -665,7 +761,7 @@ function vscodeCopilotInstalled() {
665
761
  // with `subcommand`) into the given hook event, stripping any prior codiedev
666
762
  // entries first so connect re-runs / upgrades refresh the resolved binary path.
667
763
  // Mutates `hooks` in place. Exported for unit testing.
668
- function upsertCodiedevHook(hooks, eventName, subcommand) {
764
+ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
669
765
  if (!hooks[eventName])
670
766
  hooks[eventName] = [];
671
767
  const existing = hooks[eventName];
@@ -677,7 +773,7 @@ function upsertCodiedevHook(hooks, eventName, subcommand) {
677
773
  return !(0, detection_1.isCodiedevHookCommand)(hook.matcher);
678
774
  });
679
775
  filtered.push({
680
- matcher: ".*",
776
+ matcher,
681
777
  hooks: [{ type: "command", command: resolveHookCommand(subcommand) }],
682
778
  });
683
779
  hooks[eventName] = filtered;
@@ -703,6 +799,8 @@ function installHook() {
703
799
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover");
704
800
  // Stop → backup (auto-back-up changed artifact files to your private library).
705
801
  upsertCodiedevHook(hooks, "Stop", "backup");
802
+ // StopFailure (matcher rate_limit) → limit (post rate-limit event to the team).
803
+ upsertCodiedevHook(hooks, "StopFailure", "limit", "rate_limit");
706
804
  if (!fs.existsSync(CLAUDE_DIR)) {
707
805
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
708
806
  }
@@ -758,6 +856,29 @@ function ensureBackupHookInstalled() {
758
856
  }
759
857
  catch { /* best-effort */ }
760
858
  }
859
+ // Self-heal: ensure the StopFailure (matcher rate_limit) → limit hook is
860
+ // present without a full `connect` re-run, mirroring ensureBackupHookInstalled.
861
+ // Called from the capture hook so an upgraded-but-not-reconnected user gets the
862
+ // limit-event hook wired on their next session. Only writes when missing.
863
+ function ensureLimitHookInstalled() {
864
+ try {
865
+ if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
866
+ return;
867
+ const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
868
+ const hooks = (settings.hooks ?? {});
869
+ const sf = (hooks.StopFailure ?? []);
870
+ const present = sf.some((h) => {
871
+ const inner = h.hooks;
872
+ return Array.isArray(inner) && inner.some((x) => (0, detection_1.isCodiedevHookCommand)(x.command));
873
+ });
874
+ if (present)
875
+ return;
876
+ settings.hooks = hooks;
877
+ upsertCodiedevHook(hooks, "StopFailure", "limit", "rate_limit");
878
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
879
+ }
880
+ catch { /* best-effort */ }
881
+ }
761
882
  // ─────────────────────────────────────────────────────────────────────────────
762
883
  // MCP server installation — writes the codiedev MCP server config into each
763
884
  // agent CLI's expected location so "npx codiedev connect" wires it up once.
@@ -1459,6 +1580,20 @@ function extractCodexRepoUrl(content) {
1459
1580
  // failing job never litters /tmp forever. Paired with UPLOAD_TTL_MS.
1460
1581
  exports.MAX_UPLOAD_ATTEMPTS = 5;
1461
1582
  exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
1583
+ // Hand the SessionEnd capture off to a detached child so the inline hook can
1584
+ // exit 0 immediately. Detaching only the network upload (dispatchCaptureDetached)
1585
+ // wasn't enough: the synchronous transcript read + parse and — the killer on
1586
+ // Windows — one `git rev-parse` per SHA in the transcript (parseAuthoredShas)
1587
+ // still ran inline, overrunning Claude Code's SessionEnd shutdown window and
1588
+ // triggering "Hook cancelled" on long sessions. This moves ALL of that work
1589
+ // into the child. The job carries only the raw hook inputs (the child re-reads
1590
+ // the transcript and re-runs git), so the parent's inline work is a single
1591
+ // small file write plus a spawn.
1592
+ function dispatchCaptureFinish(input, hookScriptPath, deps) {
1593
+ deps.writeFileSync(deps.tmpFile, JSON.stringify(input), { mode: 0o600 });
1594
+ const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "capture-finish", deps.tmpFile], { detached: true, stdio: "ignore" });
1595
+ child.unref();
1596
+ }
1462
1597
  // Fire-and-forget the session-capture upload from a fully detached child so the
1463
1598
  // SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
1464
1599
  // 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.15",
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",