codiedev 0.9.9 → 0.9.10

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
@@ -168,6 +168,9 @@ async function main() {
168
168
  post: postJson,
169
169
  readFileSync: fs.readFileSync,
170
170
  unlinkSync: fs.unlinkSync,
171
+ // Persist a bumped attempt count on failure so a later sweep retries
172
+ // (instead of dropping a session on a transient network blip).
173
+ writeFileSync: fs.writeFileSync,
171
174
  });
172
175
  }
173
176
  process.exit(0);
@@ -256,9 +259,11 @@ async function main() {
256
259
  // server-side against a merged MR/PR's source_branch / commits for AI attribution.
257
260
  // Capture from the worktree the agent actually worked in (the launch dir stays
258
261
  // on main/detached when work happens in a `git worktree`), not workspaceRoot.
259
- const captureDir = (0, utils_1.resolveCaptureDir)(transcriptContent, workspaceRoot);
260
- const branch = (0, utils_1.getGitBranch)(captureDir);
261
- const headSha = (0, utils_1.getGitHeadSha)(captureDir);
262
+ // One session can touch several worktrees (→ several PRs); capture EVERY
263
+ // worktree's branch+sha so all the resulting PRs attribute. branch/headSha
264
+ // remain the primary (most-recent) values for back-compat.
265
+ const captureDirs = (0, utils_1.resolveCaptureDirs)(transcriptContent, workspaceRoot);
266
+ const { branch, headSha, branches, headShas } = (0, utils_1.collectCaptureKeys)(captureDirs);
262
267
  // Cost/velocity/steering signals parsed straight from the transcript we're
263
268
  // already holding — no extra hooks, no extra git/process calls.
264
269
  const usage = (0, utils_1.parseUsage)(transcriptContent, mode);
@@ -276,6 +281,8 @@ async function main() {
276
281
  source: mode,
277
282
  branch,
278
283
  headSha,
284
+ branches,
285
+ headShas,
279
286
  inputTokens: usage.inputTokens,
280
287
  outputTokens: usage.outputTokens,
281
288
  cacheTokens: usage.cacheTokens,
@@ -293,6 +300,15 @@ async function main() {
293
300
  const hookScriptPath = process.argv[1];
294
301
  const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
295
302
  (0, utils_1.dispatchCaptureDetached)({ url: `${config.backendUrl}/api/captureSession`, token: config.token, body: payload }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile });
303
+ // Retry any earlier capture jobs that failed to upload (transient network
304
+ // blips), excluding the one we just dispatched. Best-effort, detached.
305
+ (0, utils_1.sweepPendingCaptures)({
306
+ dir: os.tmpdir(),
307
+ readdirSync: fs.readdirSync,
308
+ spawn: child_process_1.spawn,
309
+ hookScriptPath,
310
+ exclude: tmpFile,
311
+ });
296
312
  }
297
313
  catch (err) {
298
314
  process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
package/dist/utils.d.ts CHANGED
@@ -28,6 +28,13 @@ export declare function matchRepo(remoteUrl: string, repos: Array<{
28
28
  fullName: string;
29
29
  } | null;
30
30
  export declare function resolveCaptureDir(transcriptContent: string, workspaceRoot: string): string;
31
+ export declare function resolveCaptureDirs(transcriptContent: string, workspaceRoot: string): string[];
32
+ export declare function collectCaptureKeys(dirs: string[], getBranch?: (d: string) => string | null, getSha?: (d: string) => string | null): {
33
+ branch: string | null;
34
+ headSha: string | null;
35
+ branches: string[];
36
+ headShas: string[];
37
+ };
31
38
  export declare function getGitBranch(cwd: string): string | null;
32
39
  export declare function getGitHeadSha(cwd: string): string | null;
33
40
  export interface CapturePayloadInput {
@@ -42,6 +49,8 @@ export interface CapturePayloadInput {
42
49
  source: string;
43
50
  branch?: string | null;
44
51
  headSha?: string | null;
52
+ branches?: string[];
53
+ headShas?: string[];
45
54
  inputTokens?: number;
46
55
  outputTokens?: number;
47
56
  cacheTokens?: number;
@@ -149,7 +158,11 @@ export interface CaptureUploadJob {
149
158
  url: string;
150
159
  token: string;
151
160
  body: Record<string, unknown>;
161
+ attempts?: number;
162
+ queuedAt?: number;
152
163
  }
164
+ export declare const MAX_UPLOAD_ATTEMPTS = 5;
165
+ export declare const UPLOAD_TTL_MS: number;
153
166
  interface DispatchDeps {
154
167
  spawn: typeof nodeSpawn;
155
168
  writeFileSync: typeof fs.writeFileSync;
@@ -157,10 +170,22 @@ interface DispatchDeps {
157
170
  nodePath?: string;
158
171
  }
159
172
  export declare function dispatchCaptureDetached(job: CaptureUploadJob, hookScriptPath: string, deps: DispatchDeps): void;
173
+ interface SweepDeps {
174
+ dir: string;
175
+ readdirSync: (dir: string) => string[];
176
+ spawn: typeof nodeSpawn;
177
+ hookScriptPath: string;
178
+ nodePath?: string;
179
+ exclude?: string;
180
+ max?: number;
181
+ }
182
+ export declare function sweepPendingCaptures(deps: SweepDeps): void;
160
183
  interface UploadDeps {
161
184
  post: (url: string, body: Record<string, unknown>, token: string) => Promise<void>;
162
185
  readFileSync: typeof fs.readFileSync;
163
186
  unlinkSync: typeof fs.unlinkSync;
187
+ writeFileSync?: typeof fs.writeFileSync;
188
+ now?: () => number;
164
189
  }
165
190
  export declare function runCaptureUpload(jobFile: string, deps: UploadDeps): Promise<void>;
166
191
  export {};
package/dist/utils.js CHANGED
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
36
37
  exports.readConfig = readConfig;
37
38
  exports.writeConfig = writeConfig;
38
39
  exports.parseGitRemoteFromConfig = parseGitRemoteFromConfig;
@@ -41,6 +42,8 @@ exports.getGitRemoteUrl = getGitRemoteUrl;
41
42
  exports.normalizeRepoUrl = normalizeRepoUrl;
42
43
  exports.matchRepo = matchRepo;
43
44
  exports.resolveCaptureDir = resolveCaptureDir;
45
+ exports.resolveCaptureDirs = resolveCaptureDirs;
46
+ exports.collectCaptureKeys = collectCaptureKeys;
44
47
  exports.getGitBranch = getGitBranch;
45
48
  exports.getGitHeadSha = getGitHeadSha;
46
49
  exports.buildCapturePayload = buildCapturePayload;
@@ -75,6 +78,7 @@ exports.parseClaudeCodeStats = parseClaudeCodeStats;
75
78
  exports.parseCodexStats = parseCodexStats;
76
79
  exports.extractCodexRepoUrl = extractCodexRepoUrl;
77
80
  exports.dispatchCaptureDetached = dispatchCaptureDetached;
81
+ exports.sweepPendingCaptures = sweepPendingCaptures;
78
82
  exports.runCaptureUpload = runCaptureUpload;
79
83
  const fs = __importStar(require("fs"));
80
84
  const path = __importStar(require("path"));
@@ -270,6 +274,14 @@ function matchRepo(remoteUrl, repos) {
270
274
  // root and its subdirs share .git). Fall back to the launch root when no
271
275
  // separate worktree was used (work done directly in the repo).
272
276
  function resolveCaptureDir(transcriptContent, workspaceRoot) {
277
+ return resolveCaptureDirs(transcriptContent, workspaceRoot)[0] ?? workspaceRoot;
278
+ }
279
+ // Plural: EVERY separate worktree the session touched, most-recent first. One
280
+ // session can produce multiple PRs (work across several worktrees, each on its
281
+ // own feature branch); capturing every worktree's branch+sha lets all of those
282
+ // PRs attribute. Falls back to [workspaceRoot] when no separate worktree was
283
+ // used (work done directly in the repo).
284
+ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
273
285
  const rootGitDir = resolveGitDir(workspaceRoot);
274
286
  const cwds = [];
275
287
  for (const line of transcriptContent.split("\n")) {
@@ -284,14 +296,38 @@ function resolveCaptureDir(transcriptContent, workspaceRoot) {
284
296
  // non-JSON / partial line — skip
285
297
  }
286
298
  }
287
- // Most-recent first: the last worktree touched is the one most likely to have
288
- // produced the PR.
299
+ // Walk most-recent first; keep one entry per distinct worktree gitdir (a
300
+ // worktree's gitdir is .git/worktrees/<name>; the root and its subdirs share
301
+ // .git, so they're excluded as not-separate).
302
+ const out = [];
303
+ const seenGitDirs = new Set();
289
304
  for (let i = cwds.length - 1; i >= 0; i--) {
290
305
  const gitDir = resolveGitDir(cwds[i]);
291
- if (gitDir && (!rootGitDir || gitDir !== rootGitDir))
292
- return cwds[i];
306
+ if (!gitDir || (rootGitDir && gitDir === rootGitDir))
307
+ continue;
308
+ if (seenGitDirs.has(gitDir))
309
+ continue;
310
+ seenGitDirs.add(gitDir);
311
+ out.push(cwds[i]);
293
312
  }
294
- return workspaceRoot;
313
+ return out.length > 0 ? out : [workspaceRoot];
314
+ }
315
+ // Turn the worktrees a session touched into the upload join keys: dedup'd
316
+ // branch + headSha arrays (so one session → many PRs all attribute), plus the
317
+ // primary single values (first entry) for back-compat with servers that read
318
+ // only branch/headSha. getBranch/getSha are injectable for testing.
319
+ function collectCaptureKeys(dirs, getBranch = getGitBranch, getSha = getGitHeadSha) {
320
+ const branches = [];
321
+ const headShas = [];
322
+ for (const d of dirs) {
323
+ const b = getBranch(d);
324
+ if (b && !branches.includes(b))
325
+ branches.push(b);
326
+ const s = getSha(d);
327
+ if (s && !headShas.includes(s))
328
+ headShas.push(s);
329
+ }
330
+ return { branch: branches[0] ?? null, headSha: headShas[0] ?? null, branches, headShas };
295
331
  }
296
332
  // Current branch via direct .git/HEAD read (no git binary required, same
297
333
  // robustness as getGitRemoteUrl). Returns null on detached HEAD or when
@@ -388,6 +424,10 @@ function buildCapturePayload(input) {
388
424
  companyId: input.companyId,
389
425
  branch: input.branch ?? "",
390
426
  headSha: input.headSha ?? "",
427
+ // Arrays omitted when empty so older servers (which ignore unknown keys)
428
+ // see no change; newer servers index the session under every key.
429
+ branches: input.branches && input.branches.length ? input.branches : undefined,
430
+ headShas: input.headShas && input.headShas.length ? input.headShas : undefined,
391
431
  transcript: input.transcriptBase64,
392
432
  messageCount: input.messageCount,
393
433
  toolCallCount: input.toolCallCount,
@@ -1297,6 +1337,10 @@ function extractCodexRepoUrl(content) {
1297
1337
  }
1298
1338
  return null;
1299
1339
  }
1340
+ // Give up after this many failed uploads (across sessions) so a permanently
1341
+ // failing job never litters /tmp forever. Paired with UPLOAD_TTL_MS.
1342
+ exports.MAX_UPLOAD_ATTEMPTS = 5;
1343
+ exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
1300
1344
  // Fire-and-forget the session-capture upload from a fully detached child so the
1301
1345
  // SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
1302
1346
  // Code's shutdown window elapse and kill the hook ("Hook cancelled") on large
@@ -1307,24 +1351,81 @@ function dispatchCaptureDetached(job, hookScriptPath, deps) {
1307
1351
  const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload", deps.tmpFile], { detached: true, stdio: "ignore" });
1308
1352
  child.unref();
1309
1353
  }
1354
+ // Retry kept-on-failure capture jobs: re-dispatch each pending
1355
+ // `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
1356
+ // gives up at the cap). Best-effort — never throws into the hook.
1357
+ function sweepPendingCaptures(deps) {
1358
+ let files;
1359
+ try {
1360
+ files = deps.readdirSync(deps.dir);
1361
+ }
1362
+ catch {
1363
+ return;
1364
+ }
1365
+ const jobs = files
1366
+ .filter((f) => /^codiedev-capture-.*\.json$/.test(f))
1367
+ .map((f) => path.join(deps.dir, f))
1368
+ .filter((p) => p !== deps.exclude)
1369
+ .slice(0, deps.max ?? 10);
1370
+ for (const jobFile of jobs) {
1371
+ try {
1372
+ const child = deps.spawn(deps.nodePath ?? process.execPath, [deps.hookScriptPath, "upload", jobFile], { detached: true, stdio: "ignore" });
1373
+ child.unref();
1374
+ }
1375
+ catch {
1376
+ // ignore — best-effort retry
1377
+ }
1378
+ }
1379
+ }
1310
1380
  // Runs inside the detached child spawned by `dispatchCaptureDetached`: read the
1311
1381
  // persisted job, POST it, and ALWAYS delete the temp file afterwards (even on
1312
1382
  // failure) so we don't litter the tmp dir. Never throws — capture is
1313
1383
  // best-effort.
1314
1384
  async function runCaptureUpload(jobFile, deps) {
1385
+ let job = null;
1315
1386
  try {
1316
- const job = JSON.parse(deps.readFileSync(jobFile, "utf8"));
1317
- await deps.post(job.url, job.body, job.token);
1387
+ job = JSON.parse(deps.readFileSync(jobFile, "utf8"));
1318
1388
  }
1319
1389
  catch {
1320
- // best-effort — the parent already exited; nothing to report to.
1390
+ // unreadable/corrupt job — delete it and stop.
1391
+ try {
1392
+ deps.unlinkSync(jobFile);
1393
+ }
1394
+ catch { /* gone */ }
1395
+ return;
1321
1396
  }
1322
- finally {
1397
+ try {
1398
+ await deps.post(job.url, job.body, job.token);
1399
+ // success — remove the job.
1323
1400
  try {
1324
1401
  deps.unlinkSync(jobFile);
1325
1402
  }
1403
+ catch { /* gone */ }
1404
+ return;
1405
+ }
1406
+ catch {
1407
+ // Failure: keep the job for a later sweep to retry, UNLESS we've hit the
1408
+ // attempt cap or TTL — then give up so /tmp doesn't accumulate forever.
1409
+ const now = deps.now ? deps.now() : Date.now();
1410
+ const attempts = (job.attempts ?? 0) + 1;
1411
+ const queuedAt = job.queuedAt ?? now;
1412
+ const expired = now - queuedAt > exports.UPLOAD_TTL_MS;
1413
+ if (attempts >= exports.MAX_UPLOAD_ATTEMPTS || expired || !deps.writeFileSync) {
1414
+ try {
1415
+ deps.unlinkSync(jobFile);
1416
+ }
1417
+ catch { /* gone */ }
1418
+ return;
1419
+ }
1420
+ try {
1421
+ deps.writeFileSync(jobFile, JSON.stringify({ ...job, attempts, queuedAt }), { mode: 0o600 });
1422
+ }
1326
1423
  catch {
1327
- // temp file already gone — fine.
1424
+ // couldn't persist the bump — drop it rather than risk a stuck file.
1425
+ try {
1426
+ deps.unlinkSync(jobFile);
1427
+ }
1428
+ catch { /* gone */ }
1328
1429
  }
1329
1430
  }
1330
1431
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
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",