codiedev 0.9.12 → 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.
@@ -0,0 +1,36 @@
1
+ import type { LastCaptureRecord } from "./utils";
2
+ export interface CaptureReadinessDeps<R = unknown> {
3
+ exec: (cmd: string, cwd: string) => string;
4
+ getRemoteUrl: (cwd: string) => string | null;
5
+ matchRepo: (url: string, repos: R[]) => R | null;
6
+ getBranch: (cwd: string) => string | null;
7
+ getHeadSha: (cwd: string) => string | null;
8
+ }
9
+ export interface CaptureReadiness {
10
+ gitOnPath: boolean;
11
+ insideWorkTree: boolean;
12
+ remoteUrl: string | null;
13
+ matched: boolean;
14
+ branch: string | null;
15
+ headSha: string | null;
16
+ }
17
+ export declare function assessCaptureReadiness<R>(cwd: string, repos: R[], deps: CaptureReadinessDeps<R>): CaptureReadiness;
18
+ export declare function formatRelativeAge(ms: number): string;
19
+ export interface LastCaptureSummary {
20
+ status: "pass" | "warn";
21
+ detail: string;
22
+ }
23
+ export declare function summarizeLastCapture(record: LastCaptureRecord | null, nowMs: number): LastCaptureSummary;
24
+ export interface ServerCaptureResponse {
25
+ found: boolean;
26
+ latest: {
27
+ sessionId: string;
28
+ capturedAt: number;
29
+ createdAt: number;
30
+ repoMatched: boolean;
31
+ branch: string | null;
32
+ headSha: string | null;
33
+ } | null;
34
+ sessionFound?: boolean;
35
+ }
36
+ export declare function summarizeServerCapture(server: ServerCaptureResponse | null, local: LastCaptureRecord | null, nowMs: number): LastCaptureSummary;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ // Capture-readiness assessment for `codiedev doctor`. Answers: "from where I'm
3
+ // standing right now, would a session actually capture?" — the blind spot that
4
+ // let lean-container / submodule / no-remote setups silently produce zero
5
+ // sessions while doctor's setup checks stayed green.
6
+ //
7
+ // Pure + fully injectable so it unit-tests without a real git repo. The default
8
+ // production wiring (in doctor.ts) plugs in execSync + the real git helpers.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.assessCaptureReadiness = assessCaptureReadiness;
11
+ exports.formatRelativeAge = formatRelativeAge;
12
+ exports.summarizeLastCapture = summarizeLastCapture;
13
+ exports.summarizeServerCapture = summarizeServerCapture;
14
+ function assessCaptureReadiness(cwd, repos, deps) {
15
+ let gitOnPath = false;
16
+ try {
17
+ deps.exec("git --version", cwd);
18
+ gitOnPath = true;
19
+ }
20
+ catch {
21
+ gitOnPath = false;
22
+ }
23
+ let insideWorkTree = false;
24
+ if (gitOnPath) {
25
+ try {
26
+ const out = deps.exec("git rev-parse --is-inside-work-tree", cwd);
27
+ insideWorkTree = out.trim() === "true";
28
+ }
29
+ catch {
30
+ insideWorkTree = false;
31
+ }
32
+ }
33
+ const remoteUrl = deps.getRemoteUrl(cwd);
34
+ const matched = remoteUrl ? deps.matchRepo(remoteUrl, repos) !== null : false;
35
+ return {
36
+ gitOnPath,
37
+ insideWorkTree,
38
+ remoteUrl,
39
+ matched,
40
+ branch: deps.getBranch(cwd),
41
+ headSha: deps.getHeadSha(cwd),
42
+ };
43
+ }
44
+ function formatRelativeAge(ms) {
45
+ if (ms < 0)
46
+ ms = 0;
47
+ const s = Math.floor(ms / 1000);
48
+ if (s < 60)
49
+ return `${s}s`;
50
+ const m = Math.floor(s / 60);
51
+ if (m < 60)
52
+ return `${m}m`;
53
+ const h = Math.floor(m / 60);
54
+ if (h < 24)
55
+ return `${h}h`;
56
+ return `${Math.floor(h / 24)}d`;
57
+ }
58
+ // Turns the "last session captured" breadcrumb into a doctor line. This is the
59
+ // payoff check: it lets a user confirm their most recent session actually
60
+ // landed without opening the portal.
61
+ function summarizeLastCapture(record, nowMs) {
62
+ if (!record) {
63
+ return {
64
+ status: "warn",
65
+ detail: "no session captured yet from this machine — run a session, exit, then re-run `codiedev doctor`",
66
+ };
67
+ }
68
+ const age = formatRelativeAge(nowMs - record.recordedAt);
69
+ if (!record.matched) {
70
+ return {
71
+ status: "warn",
72
+ detail: `last session captured ${age} ago, but NOT matched to a tracked repo (${record.repoUrl || "no remote"}) — link the repo in the portal`,
73
+ };
74
+ }
75
+ return { status: "pass", detail: `last session captured ${age} ago` };
76
+ }
77
+ // Server-confirmed "did my last session land?". Prefers portal truth over the
78
+ // local 2xx breadcrumb; the highest-value branch is "uploaded locally but the
79
+ // portal has no record" — the exact silent failure devs hit (2xx to nothing,
80
+ // wrong identity, backend drop).
81
+ function summarizeServerCapture(server, local, nowMs) {
82
+ // Endpoint unavailable / old backend → fall back to the local breadcrumb.
83
+ if (server === null)
84
+ return summarizeLastCapture(local, nowMs);
85
+ if (server.found && server.latest) {
86
+ const age = formatRelativeAge(nowMs - server.latest.createdAt);
87
+ if (!server.latest.repoMatched) {
88
+ return {
89
+ status: "warn",
90
+ detail: `last session is in the portal (${age} ago) but NOT matched to a tracked repo — link the repo in the portal`,
91
+ };
92
+ }
93
+ return { status: "pass", detail: `confirmed in the portal — last session captured ${age} ago` };
94
+ }
95
+ // Server reachable but has no session for this user.
96
+ if (local) {
97
+ const age = formatRelativeAge(nowMs - local.recordedAt);
98
+ return {
99
+ status: "warn",
100
+ detail: `uploaded from this machine ${age} ago but NOT found in the portal — capture isn't landing (check \`codiedev connect\`, token, and that the repo is linked)`,
101
+ };
102
+ }
103
+ return {
104
+ status: "warn",
105
+ detail: "no session captured yet from this machine — run a session, exit, then re-run `codiedev doctor`",
106
+ };
107
+ }
@@ -43,6 +43,7 @@ const utils_1 = require("../utils");
43
43
  const detection_1 = require("../detection");
44
44
  const version_1 = require("../version");
45
45
  const upgradeNudge_1 = require("../upgradeNudge");
46
+ const captureReadiness_1 = require("../captureReadiness");
46
47
  const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
47
48
  const CLAUDE_INSTRUCTIONS_PATH = path.join(os.homedir(), ".claude", "CLAUDE.md");
48
49
  const CODEX_HOOKS_PATH = path.join(os.homedir(), ".codex", "hooks.json");
@@ -210,6 +211,80 @@ async function runDoctor(_args) {
210
211
  ? `${repoCount} repo${repoCount === 1 ? "" : "s"}`
211
212
  : "0 — link repos in the portal so sessions get captured",
212
213
  });
214
+ // 4b. Capture readiness — the part that actually decides whether a session
215
+ // lands. doctor used to verify only that the hook was *installed*; these
216
+ // checks answer "from this directory, would a session capture, and did my
217
+ // last one?" — the blind spot behind lean-container / submodule / no-remote
218
+ // zeros.
219
+ const cwd = process.cwd();
220
+ const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd, config.repos ?? [], {
221
+ exec: (cmd, dir) => (0, child_process_1.execSync)(cmd, { cwd: dir, stdio: ["pipe", "pipe", "pipe"] }).toString(),
222
+ getRemoteUrl: utils_1.getGitRemoteUrl,
223
+ matchRepo: utils_1.matchRepo,
224
+ getBranch: utils_1.getGitBranch,
225
+ getHeadSha: utils_1.getGitHeadSha,
226
+ });
227
+ checks.push({
228
+ name: "git on PATH",
229
+ status: readiness.gitOnPath ? "pass" : "fail",
230
+ detail: readiness.gitOnPath
231
+ ? undefined
232
+ : "not found — capture can't tag sessions to a repo (install git, e.g. `apk add git`)",
233
+ });
234
+ checks.push({
235
+ name: "Inside a git work tree (current dir)",
236
+ status: readiness.insideWorkTree ? "pass" : "warn",
237
+ detail: readiness.insideWorkTree
238
+ ? undefined
239
+ : "current dir isn't a git work tree — if your repo is a mounted submodule, mount the full project so its .git is present",
240
+ });
241
+ checks.push({
242
+ name: "Current repo resolves to a tracked repo",
243
+ status: readiness.matched ? "pass" : "warn",
244
+ detail: readiness.matched
245
+ ? `${readiness.remoteUrl}`
246
+ : readiness.remoteUrl
247
+ ? `remote ${readiness.remoteUrl} isn't a tracked repo — link it in the portal`
248
+ : "no origin remote here — capture uploads but won't link to a repo",
249
+ });
250
+ checks.push({
251
+ name: "What this session would capture",
252
+ status: readiness.branch || readiness.headSha ? "pass" : "warn",
253
+ detail: readiness.branch || readiness.headSha
254
+ ? `branch ${readiness.branch ?? "—"} @ ${(readiness.headSha ?? "—").slice(0, 8)}`
255
+ : "no branch/sha resolved — session→merge attribution will be weaker",
256
+ });
257
+ // 4c. The payoff: did the last session actually land? Ask the server for
258
+ // portal truth (does the row exist for this user?), falling back to the local
259
+ // 2xx breadcrumb if the endpoint is unreachable or predates this CLI/backend.
260
+ const localMarker = (0, utils_1.readLastCapture)();
261
+ let server = null;
262
+ try {
263
+ const u = new URL(`${config.backendUrl}/api/cli/lastCapture`);
264
+ if (localMarker?.sessionId)
265
+ u.searchParams.set("sessionId", localMarker.sessionId);
266
+ const ctrl = new AbortController();
267
+ const timer = setTimeout(() => ctrl.abort(), 3000);
268
+ const res = await fetch(u.toString(), {
269
+ headers: {
270
+ Authorization: `Bearer ${config.token}`,
271
+ "X-Codiedev-Cli-Version": version_1.CLI_VERSION,
272
+ },
273
+ signal: ctrl.signal,
274
+ });
275
+ clearTimeout(timer);
276
+ if (res.ok)
277
+ server = (await res.json());
278
+ }
279
+ catch {
280
+ server = null; // unreachable / old backend → fall back to the local marker
281
+ }
282
+ const lastCapture = (0, captureReadiness_1.summarizeServerCapture)(server, localMarker, Date.now());
283
+ checks.push({
284
+ name: "Last session captured",
285
+ status: lastCapture.status,
286
+ detail: lastCapture.detail,
287
+ });
213
288
  // 5. Claude Code setup (if present)
214
289
  if ((0, utils_1.claudeCodeInstalled)()) {
215
290
  checks.push({
package/dist/hook.js CHANGED
@@ -171,6 +171,9 @@ async function main() {
171
171
  // Persist a bumped attempt count on failure so a later sweep retries
172
172
  // (instead of dropping a session on a transient network blip).
173
173
  writeFileSync: fs.writeFileSync,
174
+ // On a successful upload, drop the "last session captured" breadcrumb
175
+ // that `codiedev doctor` reads to confirm the session landed.
176
+ recordSuccess: (job) => (0, utils_1.writeLastCapture)((0, utils_1.buildLastCaptureRecord)(job, Date.now())),
174
177
  });
175
178
  }
176
179
  process.exit(0);
@@ -183,6 +186,34 @@ async function main() {
183
186
  await runBackupUpload(process.argv[3]);
184
187
  process.exit(0);
185
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
+ }
186
217
  let hookInput = {};
187
218
  try {
188
219
  const raw = await readStdin();
@@ -198,10 +229,10 @@ async function main() {
198
229
  await runDiscover(hookInput.prompt);
199
230
  return;
200
231
  }
201
- const mode = resolveMode(process.argv[2]);
202
232
  // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets the
203
233
  // discovery + auto-backup hooks wired on their next SessionEnd, without
204
- // 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.
205
236
  (0, utils_1.ensureDiscoveryHookInstalled)();
206
237
  (0, utils_1.ensureBackupHookInstalled)();
207
238
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
@@ -216,6 +247,15 @@ async function main() {
216
247
  runBackup(workspaceRoot, process.argv[1]);
217
248
  process.exit(0);
218
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.
219
259
  const config = (0, utils_1.readConfig)();
220
260
  if (!config) {
221
261
  process.exit(0);
@@ -223,12 +263,45 @@ async function main() {
223
263
  if (!transcript_path) {
224
264
  process.exit(0);
225
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();
226
299
  let transcriptContent;
227
300
  try {
228
301
  transcriptContent = fs.readFileSync(transcript_path, "utf8");
229
302
  }
230
303
  catch {
231
- process.exit(0);
304
+ return;
232
305
  }
233
306
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
234
307
  // need the git remote of the workspace.
@@ -264,7 +337,16 @@ async function main() {
264
337
  // remain the primary (most-recent) values for back-compat.
265
338
  const captureDirs = (0, utils_1.resolveCaptureDirs)(transcriptContent, workspaceRoot);
266
339
  const keys = (0, utils_1.collectCaptureKeys)(captureDirs);
267
- 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;
268
350
  // Union the worktree HEAD shas with the SHAs the agent actually committed/
269
351
  // pushed (parsed from the transcript's git output, resolved via the shared
270
352
  // object store). The authored shas are independent of end-of-session
@@ -299,11 +381,9 @@ async function main() {
299
381
  interruptCount: steering.interrupts,
300
382
  rejectionCount: steering.rejections,
301
383
  });
302
- // Fire the upload from a fully detached child and exit immediately. Awaiting
303
- // it inline let Claude Code's SessionEnd shutdown window elapse and kill the
304
- // still-running hook ("Hook cancelled") on large transcripts — even though
305
- // the body had already reached the server. The detached child outlives this
306
- // 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.
307
387
  try {
308
388
  const hookScriptPath = process.argv[1];
309
389
  const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
@@ -321,7 +401,6 @@ async function main() {
321
401
  catch (err) {
322
402
  process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
323
403
  }
324
- process.exit(0);
325
404
  }
326
405
  main().catch(() => {
327
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;
@@ -188,6 +197,29 @@ interface UploadDeps {
188
197
  unlinkSync: typeof fs.unlinkSync;
189
198
  writeFileSync?: typeof fs.writeFileSync;
190
199
  now?: () => number;
200
+ recordSuccess?: (job: CaptureUploadJob) => void;
201
+ }
202
+ export interface LastCaptureRecord {
203
+ sessionId: string | undefined;
204
+ repoUrl: string;
205
+ matched: boolean;
206
+ branch: string;
207
+ headSha: string;
208
+ source: string;
209
+ capturedAt: number | undefined;
210
+ recordedAt: number;
211
+ }
212
+ export declare function buildLastCaptureRecord(job: CaptureUploadJob, now: number): LastCaptureRecord;
213
+ interface LastCaptureWriteDeps {
214
+ writeFileSync: typeof fs.writeFileSync;
215
+ existsSync: typeof fs.existsSync;
216
+ mkdirSync: typeof fs.mkdirSync;
217
+ }
218
+ export declare function writeLastCapture(record: LastCaptureRecord, deps?: LastCaptureWriteDeps, filePath?: string): void;
219
+ interface LastCaptureReadDeps {
220
+ readFileSync: typeof fs.readFileSync;
221
+ existsSync: typeof fs.existsSync;
191
222
  }
223
+ export declare function readLastCapture(deps?: LastCaptureReadDeps, filePath?: string): LastCaptureRecord | null;
192
224
  export declare function runCaptureUpload(jobFile: string, deps: UploadDeps): Promise<void>;
193
225
  export {};
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,8 +80,12 @@ 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;
86
+ exports.buildLastCaptureRecord = buildLastCaptureRecord;
87
+ exports.writeLastCapture = writeLastCapture;
88
+ exports.readLastCapture = readLastCapture;
84
89
  exports.runCaptureUpload = runCaptureUpload;
85
90
  const fs = __importStar(require("fs"));
86
91
  const path = __importStar(require("path"));
@@ -253,9 +258,13 @@ function matchRepo(remoteUrl, repos) {
253
258
  for (const repo of repos) {
254
259
  const normalizedUrl = normalizeRepoUrl(repo.url);
255
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).
256
266
  if (normalizedRemote === normalizedUrl ||
257
- normalizedRemote.endsWith(`/${normalizedName}`) ||
258
- normalizedUrl.endsWith(`/${normalizedName}`)) {
267
+ normalizedRemote.endsWith(`/${normalizedName}`)) {
259
268
  return repo;
260
269
  }
261
270
  }
@@ -414,35 +423,124 @@ function extractShaTokens(transcriptContent) {
414
423
  out.add(m[1].toLowerCase());
415
424
  return [...out];
416
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
+ }
417
488
  // Resolve the extracted tokens to FULL 40-char SHAs via the repo's shared object
418
489
  // store (a worktree's commits live in the main repo's .git/objects, so they
419
490
  // resolve even after the worktree dir is removed). Full SHAs pass through; short
420
491
  // ones are resolved; unresolvable ones are dropped (a short sha can't exact-
421
492
  // match the worker's full commitShas). Keeps the server-side match exact.
422
- function parseAuthoredShas(transcriptContent, repoRoot, resolveFull = (sha) => defaultResolveFullSha(sha, repoRoot)) {
493
+ function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas) => defaultResolveFullShaBatch(shas, repoRoot)) {
423
494
  const out = new Set();
495
+ const shorts = new Set();
424
496
  for (const tok of extractShaTokens(transcriptContent)) {
425
- if (tok.length === 40) {
497
+ if (tok.length === 40)
426
498
  out.add(tok);
427
- 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());
428
507
  }
429
- const full = resolveFull(tok);
430
- if (full && /^[0-9a-f]{40}$/.test(full))
431
- out.add(full.toLowerCase());
432
508
  }
433
509
  return [...out];
434
510
  }
435
- 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;
436
523
  try {
437
- 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", {
438
526
  cwd: repoRoot,
527
+ input,
439
528
  stdio: ["pipe", "pipe", "pipe"],
440
- }).toString().trim();
441
- 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
+ });
442
539
  }
443
540
  catch {
444
- return null;
541
+ // best-effort — a git failure just means no shas resolve
445
542
  }
543
+ return out;
446
544
  }
447
545
  // Current branch via direct .git/HEAD read (no git binary required, same
448
546
  // robustness as getGitRemoteUrl). Returns null on detached HEAD or when
@@ -1456,6 +1554,20 @@ function extractCodexRepoUrl(content) {
1456
1554
  // failing job never litters /tmp forever. Paired with UPLOAD_TTL_MS.
1457
1555
  exports.MAX_UPLOAD_ATTEMPTS = 5;
1458
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
+ }
1459
1571
  // Fire-and-forget the session-capture upload from a fully detached child so the
1460
1572
  // SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
1461
1573
  // Code's shutdown window elapse and kill the hook ("Hook cancelled") on large
@@ -1492,6 +1604,45 @@ function sweepPendingCaptures(deps) {
1492
1604
  }
1493
1605
  }
1494
1606
  }
1607
+ // "Last session captured" breadcrumb. Written next to config.json on a
1608
+ // successful capture upload; read by `codiedev doctor` so a user can confirm
1609
+ // their most recent session actually landed (the single hardest thing to
1610
+ // self-verify today — it previously required eyeballing the portal).
1611
+ const LAST_CAPTURE_PATH = path.join(CONFIG_DIR, "last-capture.json");
1612
+ function buildLastCaptureRecord(job, now) {
1613
+ const b = job.body;
1614
+ return {
1615
+ sessionId: b.sessionId,
1616
+ repoUrl: b.repoUrl ?? "",
1617
+ matched: Boolean(b.repoId),
1618
+ branch: b.branch ?? "",
1619
+ headSha: b.headSha ?? "",
1620
+ source: b.source ?? "",
1621
+ capturedAt: b.capturedAt,
1622
+ recordedAt: now,
1623
+ };
1624
+ }
1625
+ function writeLastCapture(record, deps = fs, filePath = LAST_CAPTURE_PATH) {
1626
+ try {
1627
+ const dir = path.dirname(filePath);
1628
+ if (!deps.existsSync(dir))
1629
+ deps.mkdirSync(dir, { recursive: true });
1630
+ deps.writeFileSync(filePath, JSON.stringify(record), { mode: 0o600 });
1631
+ }
1632
+ catch {
1633
+ // breadcrumb is best-effort — never throw on the capture path.
1634
+ }
1635
+ }
1636
+ function readLastCapture(deps = fs, filePath = LAST_CAPTURE_PATH) {
1637
+ try {
1638
+ if (!deps.existsSync(filePath))
1639
+ return null;
1640
+ return JSON.parse(deps.readFileSync(filePath, "utf8"));
1641
+ }
1642
+ catch {
1643
+ return null;
1644
+ }
1645
+ }
1495
1646
  // Runs inside the detached child spawned by `dispatchCaptureDetached`: read the
1496
1647
  // persisted job, POST it, and ALWAYS delete the temp file afterwards (even on
1497
1648
  // failure) so we don't litter the tmp dir. Never throws — capture is
@@ -1511,7 +1662,14 @@ async function runCaptureUpload(jobFile, deps) {
1511
1662
  }
1512
1663
  try {
1513
1664
  await deps.post(job.url, job.body, job.token);
1514
- // success — remove the job.
1665
+ // success — drop a "last session captured" breadcrumb (before removing the
1666
+ // job) so doctor can confirm the most recent session actually landed.
1667
+ if (deps.recordSuccess) {
1668
+ try {
1669
+ deps.recordSuccess(job);
1670
+ }
1671
+ catch { /* breadcrumb is best-effort */ }
1672
+ }
1515
1673
  try {
1516
1674
  deps.unlinkSync(jobFile);
1517
1675
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.12",
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",