codiedev 0.9.8 → 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 +21 -2
- package/dist/utils.d.ts +26 -0
- package/dist/utils.js +145 -5
- package/package.json +1 -1
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);
|
|
@@ -254,8 +257,13 @@ async function main() {
|
|
|
254
257
|
const transcriptBase64 = Buffer.from(transcriptContent, "utf8").toString("base64");
|
|
255
258
|
// Session→merge join keys: the branch + HEAD sha the work happened on. Matched
|
|
256
259
|
// server-side against a merged MR/PR's source_branch / commits for AI attribution.
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
// Capture from the worktree the agent actually worked in (the launch dir stays
|
|
261
|
+
// on main/detached when work happens in a `git worktree`), not workspaceRoot.
|
|
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);
|
|
259
267
|
// Cost/velocity/steering signals parsed straight from the transcript we're
|
|
260
268
|
// already holding — no extra hooks, no extra git/process calls.
|
|
261
269
|
const usage = (0, utils_1.parseUsage)(transcriptContent, mode);
|
|
@@ -273,6 +281,8 @@ async function main() {
|
|
|
273
281
|
source: mode,
|
|
274
282
|
branch,
|
|
275
283
|
headSha,
|
|
284
|
+
branches,
|
|
285
|
+
headShas,
|
|
276
286
|
inputTokens: usage.inputTokens,
|
|
277
287
|
outputTokens: usage.outputTokens,
|
|
278
288
|
cacheTokens: usage.cacheTokens,
|
|
@@ -290,6 +300,15 @@ async function main() {
|
|
|
290
300
|
const hookScriptPath = process.argv[1];
|
|
291
301
|
const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
|
|
292
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
|
+
});
|
|
293
312
|
}
|
|
294
313
|
catch (err) {
|
|
295
314
|
process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
|
package/dist/utils.d.ts
CHANGED
|
@@ -27,6 +27,14 @@ export declare function matchRepo(remoteUrl: string, repos: Array<{
|
|
|
27
27
|
url: string;
|
|
28
28
|
fullName: string;
|
|
29
29
|
} | null;
|
|
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
|
+
};
|
|
30
38
|
export declare function getGitBranch(cwd: string): string | null;
|
|
31
39
|
export declare function getGitHeadSha(cwd: string): string | null;
|
|
32
40
|
export interface CapturePayloadInput {
|
|
@@ -41,6 +49,8 @@ export interface CapturePayloadInput {
|
|
|
41
49
|
source: string;
|
|
42
50
|
branch?: string | null;
|
|
43
51
|
headSha?: string | null;
|
|
52
|
+
branches?: string[];
|
|
53
|
+
headShas?: string[];
|
|
44
54
|
inputTokens?: number;
|
|
45
55
|
outputTokens?: number;
|
|
46
56
|
cacheTokens?: number;
|
|
@@ -148,7 +158,11 @@ export interface CaptureUploadJob {
|
|
|
148
158
|
url: string;
|
|
149
159
|
token: string;
|
|
150
160
|
body: Record<string, unknown>;
|
|
161
|
+
attempts?: number;
|
|
162
|
+
queuedAt?: number;
|
|
151
163
|
}
|
|
164
|
+
export declare const MAX_UPLOAD_ATTEMPTS = 5;
|
|
165
|
+
export declare const UPLOAD_TTL_MS: number;
|
|
152
166
|
interface DispatchDeps {
|
|
153
167
|
spawn: typeof nodeSpawn;
|
|
154
168
|
writeFileSync: typeof fs.writeFileSync;
|
|
@@ -156,10 +170,22 @@ interface DispatchDeps {
|
|
|
156
170
|
nodePath?: string;
|
|
157
171
|
}
|
|
158
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;
|
|
159
183
|
interface UploadDeps {
|
|
160
184
|
post: (url: string, body: Record<string, unknown>, token: string) => Promise<void>;
|
|
161
185
|
readFileSync: typeof fs.readFileSync;
|
|
162
186
|
unlinkSync: typeof fs.unlinkSync;
|
|
187
|
+
writeFileSync?: typeof fs.writeFileSync;
|
|
188
|
+
now?: () => number;
|
|
163
189
|
}
|
|
164
190
|
export declare function runCaptureUpload(jobFile: string, deps: UploadDeps): Promise<void>;
|
|
165
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;
|
|
@@ -40,6 +41,9 @@ exports.readGitRemoteFromConfig = readGitRemoteFromConfig;
|
|
|
40
41
|
exports.getGitRemoteUrl = getGitRemoteUrl;
|
|
41
42
|
exports.normalizeRepoUrl = normalizeRepoUrl;
|
|
42
43
|
exports.matchRepo = matchRepo;
|
|
44
|
+
exports.resolveCaptureDir = resolveCaptureDir;
|
|
45
|
+
exports.resolveCaptureDirs = resolveCaptureDirs;
|
|
46
|
+
exports.collectCaptureKeys = collectCaptureKeys;
|
|
43
47
|
exports.getGitBranch = getGitBranch;
|
|
44
48
|
exports.getGitHeadSha = getGitHeadSha;
|
|
45
49
|
exports.buildCapturePayload = buildCapturePayload;
|
|
@@ -74,6 +78,7 @@ exports.parseClaudeCodeStats = parseClaudeCodeStats;
|
|
|
74
78
|
exports.parseCodexStats = parseCodexStats;
|
|
75
79
|
exports.extractCodexRepoUrl = extractCodexRepoUrl;
|
|
76
80
|
exports.dispatchCaptureDetached = dispatchCaptureDetached;
|
|
81
|
+
exports.sweepPendingCaptures = sweepPendingCaptures;
|
|
77
82
|
exports.runCaptureUpload = runCaptureUpload;
|
|
78
83
|
const fs = __importStar(require("fs"));
|
|
79
84
|
const path = __importStar(require("path"));
|
|
@@ -254,6 +259,76 @@ function matchRepo(remoteUrl, repos) {
|
|
|
254
259
|
}
|
|
255
260
|
return null;
|
|
256
261
|
}
|
|
262
|
+
// Resolve the directory to capture git branch/headSha from.
|
|
263
|
+
//
|
|
264
|
+
// The session→merge join keys must describe the work that becomes the PR. But
|
|
265
|
+
// agents do that work in a `git worktree` (.claude/worktrees/<name> or a
|
|
266
|
+
// sibling dir) on a feature branch, while the session's LAUNCH dir
|
|
267
|
+
// (workspaceRoot) stays on main/detached — a feature branch checked out in a
|
|
268
|
+
// worktree cannot also be checked out in the launch dir. Capturing from the
|
|
269
|
+
// launch dir therefore recorded main/null, matching no PR (velocity starved).
|
|
270
|
+
//
|
|
271
|
+
// Fix: scan the transcript for the cwd the agent actually worked in — the
|
|
272
|
+
// most-recent one that is a SEPARATE worktree (its resolved gitdir differs from
|
|
273
|
+
// the launch root's; a worktree's gitdir is .git/worktrees/<name>, while the
|
|
274
|
+
// root and its subdirs share .git). Fall back to the launch root when no
|
|
275
|
+
// separate worktree was used (work done directly in the repo).
|
|
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) {
|
|
285
|
+
const rootGitDir = resolveGitDir(workspaceRoot);
|
|
286
|
+
const cwds = [];
|
|
287
|
+
for (const line of transcriptContent.split("\n")) {
|
|
288
|
+
if (!line.trim())
|
|
289
|
+
continue;
|
|
290
|
+
try {
|
|
291
|
+
const entry = JSON.parse(line);
|
|
292
|
+
if (typeof entry?.cwd === "string" && entry.cwd)
|
|
293
|
+
cwds.push(entry.cwd);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// non-JSON / partial line — skip
|
|
297
|
+
}
|
|
298
|
+
}
|
|
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();
|
|
304
|
+
for (let i = cwds.length - 1; i >= 0; i--) {
|
|
305
|
+
const gitDir = resolveGitDir(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]);
|
|
312
|
+
}
|
|
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 };
|
|
331
|
+
}
|
|
257
332
|
// Current branch via direct .git/HEAD read (no git binary required, same
|
|
258
333
|
// robustness as getGitRemoteUrl). Returns null on detached HEAD or when
|
|
259
334
|
// unresolvable. This is the session→merge join key: a captured session's
|
|
@@ -349,6 +424,10 @@ function buildCapturePayload(input) {
|
|
|
349
424
|
companyId: input.companyId,
|
|
350
425
|
branch: input.branch ?? "",
|
|
351
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,
|
|
352
431
|
transcript: input.transcriptBase64,
|
|
353
432
|
messageCount: input.messageCount,
|
|
354
433
|
toolCallCount: input.toolCallCount,
|
|
@@ -1258,6 +1337,10 @@ function extractCodexRepoUrl(content) {
|
|
|
1258
1337
|
}
|
|
1259
1338
|
return null;
|
|
1260
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;
|
|
1261
1344
|
// Fire-and-forget the session-capture upload from a fully detached child so the
|
|
1262
1345
|
// SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
|
|
1263
1346
|
// Code's shutdown window elapse and kill the hook ("Hook cancelled") on large
|
|
@@ -1268,24 +1351,81 @@ function dispatchCaptureDetached(job, hookScriptPath, deps) {
|
|
|
1268
1351
|
const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload", deps.tmpFile], { detached: true, stdio: "ignore" });
|
|
1269
1352
|
child.unref();
|
|
1270
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
|
+
}
|
|
1271
1380
|
// Runs inside the detached child spawned by `dispatchCaptureDetached`: read the
|
|
1272
1381
|
// persisted job, POST it, and ALWAYS delete the temp file afterwards (even on
|
|
1273
1382
|
// failure) so we don't litter the tmp dir. Never throws — capture is
|
|
1274
1383
|
// best-effort.
|
|
1275
1384
|
async function runCaptureUpload(jobFile, deps) {
|
|
1385
|
+
let job = null;
|
|
1276
1386
|
try {
|
|
1277
|
-
|
|
1278
|
-
await deps.post(job.url, job.body, job.token);
|
|
1387
|
+
job = JSON.parse(deps.readFileSync(jobFile, "utf8"));
|
|
1279
1388
|
}
|
|
1280
1389
|
catch {
|
|
1281
|
-
//
|
|
1390
|
+
// unreadable/corrupt job — delete it and stop.
|
|
1391
|
+
try {
|
|
1392
|
+
deps.unlinkSync(jobFile);
|
|
1393
|
+
}
|
|
1394
|
+
catch { /* gone */ }
|
|
1395
|
+
return;
|
|
1282
1396
|
}
|
|
1283
|
-
|
|
1397
|
+
try {
|
|
1398
|
+
await deps.post(job.url, job.body, job.token);
|
|
1399
|
+
// success — remove the job.
|
|
1284
1400
|
try {
|
|
1285
1401
|
deps.unlinkSync(jobFile);
|
|
1286
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
|
+
}
|
|
1287
1423
|
catch {
|
|
1288
|
-
//
|
|
1424
|
+
// couldn't persist the bump — drop it rather than risk a stuck file.
|
|
1425
|
+
try {
|
|
1426
|
+
deps.unlinkSync(jobFile);
|
|
1427
|
+
}
|
|
1428
|
+
catch { /* gone */ }
|
|
1289
1429
|
}
|
|
1290
1430
|
}
|
|
1291
1431
|
}
|
package/package.json
CHANGED