codiedev 0.9.18 → 0.9.20

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,20 @@
1
+ import * as fs from "fs";
2
+ export declare const CAPTURE_LOG_MAX_BYTES: number;
3
+ export declare const CAPTURE_LOG_PATH: string;
4
+ export interface CaptureLogEntry {
5
+ stage: "capture-finish" | "upload" | "upload-deferred" | "stderr";
6
+ sessionId: string | undefined;
7
+ outcome: string;
8
+ detail?: string;
9
+ }
10
+ interface LogDeps {
11
+ readFileSync: typeof fs.readFileSync;
12
+ writeFileSync: typeof fs.writeFileSync;
13
+ appendFileSync: typeof fs.appendFileSync;
14
+ existsSync: typeof fs.existsSync;
15
+ mkdirSync: typeof fs.mkdirSync;
16
+ statSync: typeof fs.statSync;
17
+ }
18
+ export declare function appendCaptureLog(entry: CaptureLogEntry, deps?: LogDeps, filePath?: string, now?: () => number): void;
19
+ export declare function readCaptureLogTail(n: number, deps?: Pick<LogDeps, "readFileSync" | "existsSync">, filePath?: string): string[];
20
+ export {};
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CAPTURE_LOG_PATH = exports.CAPTURE_LOG_MAX_BYTES = void 0;
37
+ exports.appendCaptureLog = appendCaptureLog;
38
+ exports.readCaptureLogTail = readCaptureLogTail;
39
+ // Rotating capture diagnostic log at ~/.codiedev/capture.log.
40
+ //
41
+ // Every stage of the capture pipeline (capture-finish child, upload child,
42
+ // stderr from detached children) writes one line here so users — and
43
+ // `codiedev doctor` — can see exactly why a capture succeeded or failed
44
+ // without having to file a support ticket or grep /tmp. The log is
45
+ // best-effort throughout: appendCaptureLog NEVER throws into the hook path.
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const os = __importStar(require("os"));
49
+ // Rotate the log when it exceeds this size (keeps the newest ~half). 256 KB
50
+ // holds thousands of entries; a user would have to see hundreds of errors
51
+ // before hitting this limit, so it's a generous safety valve.
52
+ exports.CAPTURE_LOG_MAX_BYTES = 256 * 1024;
53
+ exports.CAPTURE_LOG_PATH = path.join(os.homedir(), ".codiedev", "capture.log");
54
+ // Append one structured line to the capture log. Rotates the file when it
55
+ // grows past CAPTURE_LOG_MAX_BYTES by truncating it to the newest half (keeps
56
+ // the most recent diagnostics). Best-effort: NEVER throws into the hook path —
57
+ // a diagnostic log must not break the thing it's diagnosing.
58
+ function appendCaptureLog(entry, deps = fs, filePath = exports.CAPTURE_LOG_PATH, now = Date.now) {
59
+ try {
60
+ const dir = path.dirname(filePath);
61
+ if (!deps.existsSync(dir))
62
+ deps.mkdirSync(dir, { recursive: true });
63
+ if (deps.existsSync(filePath) && deps.statSync(filePath).size >= exports.CAPTURE_LOG_MAX_BYTES) {
64
+ // Rotation: keep the newest half. Slice from the middle to a line
65
+ // boundary so we never persist a torn (partial) first line.
66
+ const content = deps.readFileSync(filePath, "utf8");
67
+ const half = content.slice(Math.floor(content.length / 2));
68
+ const firstNewline = half.indexOf("\n");
69
+ // If no newline is found in the second half, drop it all and start
70
+ // fresh — better than keeping a torn line.
71
+ const fromLine = firstNewline === -1 ? "" : half.slice(firstNewline + 1);
72
+ deps.writeFileSync(filePath, fromLine);
73
+ }
74
+ // Sanitize before composing: error messages can carry newlines (e.g. a
75
+ // proxy's HTML error body in err.message) which would tear the one-line
76
+ // format that doctor's parser depends on.
77
+ const outcome = entry.outcome.replace(/\s+/g, " ").trim();
78
+ const detail = entry.detail?.replace(/\s+/g, " ").trim();
79
+ const line = `${new Date(now()).toISOString()} ${entry.stage} session=${entry.sessionId ?? "-"} ${outcome}` +
80
+ (detail ? ` ${detail}` : "") +
81
+ "\n";
82
+ deps.appendFileSync(filePath, line);
83
+ }
84
+ catch {
85
+ // diagnostics must never break capture
86
+ }
87
+ }
88
+ // Read the last N lines from the capture log. Returns [] when the file is
89
+ // missing or unreadable — callers (e.g. `codiedev doctor`) treat that as
90
+ // "nothing captured yet" rather than an error.
91
+ function readCaptureLogTail(n, deps = fs, filePath = exports.CAPTURE_LOG_PATH) {
92
+ try {
93
+ if (!deps.existsSync(filePath))
94
+ return [];
95
+ const lines = deps.readFileSync(filePath, "utf8").split("\n").filter(Boolean);
96
+ return lines.slice(-n);
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ }
@@ -32,6 +32,11 @@ export interface LastCaptureSummary {
32
32
  detail: string;
33
33
  }
34
34
  export declare function summarizeLastCapture(record: LastCaptureRecord | null, nowMs: number): LastCaptureSummary;
35
+ export interface CaptureActivitySummary {
36
+ status: "pass" | "warn" | "fail";
37
+ detail: string;
38
+ }
39
+ export declare function summarizeCaptureActivity(tailLines: string[], pendingJobCount: number): CaptureActivitySummary;
35
40
  export interface ServerCaptureResponse {
36
41
  found: boolean;
37
42
  latest: {
@@ -11,6 +11,7 @@ exports.assessCaptureReadiness = assessCaptureReadiness;
11
11
  exports.assessWorkspaceRepos = assessWorkspaceRepos;
12
12
  exports.formatRelativeAge = formatRelativeAge;
13
13
  exports.summarizeLastCapture = summarizeLastCapture;
14
+ exports.summarizeCaptureActivity = summarizeCaptureActivity;
14
15
  exports.summarizeServerCapture = summarizeServerCapture;
15
16
  function assessCaptureReadiness(cwd, repos, deps) {
16
17
  let gitOnPath = false;
@@ -95,6 +96,115 @@ function summarizeLastCapture(record, nowMs) {
95
96
  }
96
97
  return { status: "pass", detail: `last session captured ${age} ago` };
97
98
  }
99
+ // ─── Capture-activity summarizer ──────────────────────────────────────────────
100
+ // Turns the tail of ~/.codiedev/capture.log + a count of pending upload jobs
101
+ // into a single doctor CheckResult-compatible {status, detail} object.
102
+ //
103
+ // Pure (no fs/os imports) — all data is injected by the caller (doctor.ts).
104
+ const LOG_PATH_SUFFIX = "— details: ~/.codiedev/capture.log";
105
+ // Parse regex: a valid structured line has exactly this shape.
106
+ // stage is one of the four known stages; outcome is a bare token.
107
+ const STRUCTURED_LINE_RE = /^(\S+) (capture-finish|upload|upload-deferred|stderr) session=(\S+) (\S+)(?: (.*))?$/;
108
+ /** Append the shared log-path suffix to any detail string. */
109
+ function withSuffix(detail) {
110
+ return `${detail} ${LOG_PATH_SUFFIX}`;
111
+ }
112
+ /**
113
+ * Derive remediation text and status from the last failing upload-ish line.
114
+ * Inspects the `detail` field of the log line for http= and msg= tokens.
115
+ */
116
+ function failDetail(rawLine, outcome, lineDetail) {
117
+ const d = lineDetail ?? "";
118
+ const truncated = rawLine.slice(0, 140);
119
+ let hint;
120
+ if (/http=40[13]/.test(d)) {
121
+ hint = `token invalid or expired — run \`codiedev connect\` with a fresh token`;
122
+ }
123
+ else if (/http=5/.test(d) || /fetch|network|ECONN|ETIMEDOUT/i.test(d)) {
124
+ hint = `backend unreachable — uploads are queued and retried automatically`;
125
+ }
126
+ else if (outcome === "gave-up") {
127
+ hint = `an upload exhausted its retries — check network/proxy, then run a new session`;
128
+ }
129
+ else {
130
+ hint = `see the log line for details`;
131
+ }
132
+ return withSuffix(`${hint} (${truncated})`);
133
+ }
134
+ function summarizeCaptureActivity(tailLines, pendingJobCount) {
135
+ const parsed = [];
136
+ for (const raw of tailLines) {
137
+ const m = STRUCTURED_LINE_RE.exec(raw);
138
+ if (!m)
139
+ continue;
140
+ parsed.push({ ts: m[1], stage: m[2], session: m[3], outcome: m[4], detail: m[5], raw });
141
+ }
142
+ // We care only about upload-ish lines (upload + upload-deferred).
143
+ // capture-finish and stderr lines are noise for this check.
144
+ const uploadLines = parsed.filter((p) => p.stage === "upload" || p.stage === "upload-deferred");
145
+ // Rule 1: last upload-ish outcome is ERROR or gave-up → fail.
146
+ if (uploadLines.length > 0) {
147
+ const last = uploadLines[uploadLines.length - 1];
148
+ if (last.outcome === "ERROR" || last.outcome === "gave-up") {
149
+ return {
150
+ status: "fail",
151
+ detail: failDetail(last.raw, last.outcome, last.detail),
152
+ };
153
+ }
154
+ }
155
+ // Rule 2: pending jobs queued (last upload wasn't a hard failure, but
156
+ // retries are still in-flight).
157
+ if (pendingJobCount > 0) {
158
+ const noun = pendingJobCount === 1 ? "job" : "jobs";
159
+ return {
160
+ status: "warn",
161
+ detail: withSuffix(`${pendingJobCount} pending upload ${noun} queued for retry`),
162
+ };
163
+ }
164
+ // Rule 3: no upload-ish lines at all — log is empty, garbage-only, or
165
+ // only capture-finish lines.
166
+ if (uploadLines.length === 0) {
167
+ return {
168
+ status: "warn",
169
+ detail: withSuffix("no capture uploads recorded yet on this machine — run a session and exit, then re-run `codiedev doctor`"),
170
+ };
171
+ }
172
+ // Rule 4a: last upload-ish outcome is ok → pass.
173
+ const last = uploadLines[uploadLines.length - 1];
174
+ const hasOk = uploadLines.some((p) => p.outcome === "ok");
175
+ if (last.outcome === "ok") {
176
+ return {
177
+ status: "pass",
178
+ detail: withSuffix(`last upload ok (${last.ts})`),
179
+ };
180
+ }
181
+ // Rule 4b: last is "deferred" — a throttled/scheduled defer was queued.
182
+ // If an ok exists somewhere in the tail → pass (next upload deferred is fine).
183
+ // If no ok exists → warn.
184
+ if (last.outcome === "deferred") {
185
+ if (hasOk) {
186
+ return {
187
+ status: "pass",
188
+ detail: withSuffix(`last upload ok; next upload deferred (throttle)`),
189
+ };
190
+ }
191
+ return {
192
+ status: "warn",
193
+ detail: withSuffix("deferred uploads were superseded but no completed upload found in the recent log"),
194
+ };
195
+ }
196
+ // Rule 4c: last is "superseded".
197
+ if (hasOk) {
198
+ return {
199
+ status: "pass",
200
+ detail: withSuffix(`last upload ok (a later upload superseded an earlier one)`),
201
+ };
202
+ }
203
+ return {
204
+ status: "warn",
205
+ detail: withSuffix("deferred uploads were superseded but no completed upload found in the recent log"),
206
+ };
207
+ }
98
208
  // Server-confirmed "did my last session land?". Prefers portal truth over the
99
209
  // local 2xx breadcrumb; the highest-value branch is "uploaded locally but the
100
210
  // portal has no record" — the exact silent failure devs hit (2xx to nothing,
@@ -44,6 +44,7 @@ const detection_1 = require("../detection");
44
44
  const version_1 = require("../version");
45
45
  const upgradeNudge_1 = require("../upgradeNudge");
46
46
  const captureReadiness_1 = require("../captureReadiness");
47
+ const captureLog_1 = require("../captureLog");
47
48
  const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
48
49
  const CLAUDE_INSTRUCTIONS_PATH = path.join(os.homedir(), ".claude", "CLAUDE.md");
49
50
  const CODEX_HOOKS_PATH = path.join(os.homedir(), ".codex", "hooks.json");
@@ -310,6 +311,23 @@ async function runDoctor(_args) {
310
311
  status: lastCapture.status,
311
312
  detail: lastCapture.detail,
312
313
  });
314
+ // 4d. Capture-log activity: did recent uploads succeed? Are there pending jobs?
315
+ const logTail = (0, captureLog_1.readCaptureLogTail)(40);
316
+ let pendingJobCount = 0;
317
+ try {
318
+ pendingJobCount = fs
319
+ .readdirSync(os.tmpdir())
320
+ .filter((f) => /^codiedev-capture-.*\.json(\.claim-\d+)?$/.test(f)).length;
321
+ }
322
+ catch {
323
+ pendingJobCount = 0;
324
+ }
325
+ const captureActivity = (0, captureReadiness_1.summarizeCaptureActivity)(logTail, pendingJobCount);
326
+ checks.push({
327
+ name: "Recent capture activity",
328
+ status: captureActivity.status,
329
+ detail: captureActivity.detail,
330
+ });
313
331
  // 5. Claude Code setup (if present)
314
332
  if ((0, utils_1.claudeCodeInstalled)()) {
315
333
  checks.push({
package/dist/connect.js CHANGED
@@ -42,7 +42,10 @@ const connectFlow_1 = require("./connectFlow");
42
42
  const version_1 = require("./version");
43
43
  const upgradeNudge_1 = require("./upgradeNudge");
44
44
  const shared_1 = require("./commands/shared");
45
- const BACKEND_URL = process.env.CODIEDEV_URL || "https://judicious-falcon-861.convex.site";
45
+ // cli.codiedev.com is a Convex custom domain on the prod deployment; the
46
+ // underlying judicious-falcon-861.convex.site URL keeps serving, so configs
47
+ // written by older CLI versions stay valid without a re-connect.
48
+ const BACKEND_URL = process.env.CODIEDEV_URL || "https://cli.codiedev.com";
46
49
  /**
47
50
  * Reads a line from stdin without echoing the typed/pasted characters —
48
51
  * for tokens, passwords, anything that shouldn't end up in terminal
package/dist/hook.js CHANGED
@@ -43,6 +43,7 @@ const child_process_1 = require("child_process");
43
43
  const utils_1 = require("./utils");
44
44
  const limitEvent_1 = require("./limitEvent");
45
45
  const version_1 = require("./version");
46
+ const captureLog_1 = require("./captureLog");
46
47
  function getJson(url, bearerToken) {
47
48
  return new Promise((resolve, reject) => {
48
49
  const parsed = new URL(url);
@@ -220,22 +221,49 @@ function resolveMode(arg) {
220
221
  return "claude-code";
221
222
  }
222
223
  async function main() {
224
+ // Shared upload deps used by both "upload" and "upload-deferred" branches.
225
+ // Extracted to keep both branches in lockstep — any new dep added here
226
+ // automatically applies to the deferred path too.
227
+ function makeUploadDeps() {
228
+ return {
229
+ post: postJson,
230
+ readFileSync: fs.readFileSync,
231
+ unlinkSync: fs.unlinkSync,
232
+ // Persist a bumped attempt count on failure so a later sweep retries
233
+ // (instead of dropping a session on a transient network blip).
234
+ writeFileSync: fs.writeFileSync,
235
+ // On a successful upload, drop the "last session captured" breadcrumb
236
+ // that `codiedev doctor` reads to confirm the session landed.
237
+ recordSuccess: (job) => (0, utils_1.writeLastCapture)((0, utils_1.buildLastCaptureRecord)(job, Date.now())),
238
+ // Write structured outcome lines (ok / ERROR / gave-up) to the
239
+ // rotating capture log so users and doctor can see why captures fail.
240
+ log: (e) => (0, captureLog_1.appendCaptureLog)(e),
241
+ // Write per-session upload state so the throttle can defer later
242
+ // non-final uploads within the same window.
243
+ writeSessionState: utils_1.writeSessionUploadState,
244
+ };
245
+ }
223
246
  // Detached uploader child (spawned by the capture path below). The job —
224
247
  // including transcript and token — is on disk; no stdin is needed, so handle
225
248
  // this before readStdin() to avoid blocking on the ignored stdin pipe.
226
249
  if (process.argv[2] === "upload") {
227
250
  const jobFile = process.argv[3];
228
251
  if (jobFile) {
229
- await (0, utils_1.runCaptureUpload)(jobFile, {
230
- post: postJson,
231
- readFileSync: fs.readFileSync,
232
- unlinkSync: fs.unlinkSync,
233
- // Persist a bumped attempt count on failure so a later sweep retries
234
- // (instead of dropping a session on a transient network blip).
235
- writeFileSync: fs.writeFileSync,
236
- // On a successful upload, drop the "last session captured" breadcrumb
237
- // that `codiedev doctor` reads to confirm the session landed.
238
- recordSuccess: (job) => (0, utils_1.writeLastCapture)((0, utils_1.buildLastCaptureRecord)(job, Date.now())),
252
+ await (0, utils_1.runCaptureUpload)(jobFile, makeUploadDeps());
253
+ }
254
+ process.exit(0);
255
+ }
256
+ // Deferred uploader child (spawned by runCaptureFinish when the per-turn
257
+ // Stop capture is inside the throttle window). Sleeps for the remaining
258
+ // delay, then claims the job file (if it hasn't been superseded by a newer
259
+ // turn) and uploads. No stdin needed — handle before readStdin().
260
+ if (process.argv[2] === "upload-deferred") {
261
+ const jobFile = process.argv[3];
262
+ const delayMs = Math.max(0, Number(process.argv[4]) || 0);
263
+ if (jobFile) {
264
+ await (0, utils_1.runDeferredUpload)(jobFile, delayMs, {
265
+ ...makeUploadDeps(),
266
+ renameSync: fs.renameSync,
239
267
  });
240
268
  }
241
269
  process.exit(0);
@@ -316,7 +344,40 @@ async function main() {
316
344
  // Stop/sessionEnd backup path — separate from session capture and needs no
317
345
  // transcript. Lists changed artifact files in the workspace and dispatches a
318
346
  // detached child that backs them up privately. Best-effort, never blocks.
347
+ //
348
+ // Per-turn capture: Stop fires reliably in editor-embedded clients (VS Code
349
+ // extension) where SessionEnd may never come — the silent zero-capture bug.
350
+ // Same detached capture-finish child as SessionEnd, marked non-final so the
351
+ // server renews the session lease instead of summarizing immediately.
319
352
  if (process.argv[2] === "backup") {
353
+ if (transcript_path) {
354
+ try {
355
+ (0, utils_1.dispatchCaptureFinish)({
356
+ // "capture" = the claude-code entrypoint literal, same as the
357
+ // SessionEnd path below (resolveMode maps it to claude-code).
358
+ mode: "capture",
359
+ session_id,
360
+ transcript_path,
361
+ cwd,
362
+ workspace_roots,
363
+ // non-final: server should renew the lease, not close it yet.
364
+ final: false,
365
+ }, process.argv[1], {
366
+ spawn: child_process_1.spawn,
367
+ writeFileSync: fs.writeFileSync,
368
+ openSync: fs.openSync,
369
+ closeSync: fs.closeSync,
370
+ // UNIQUE per dispatch (sid+pid+ts): Stop and SessionEnd children
371
+ // each own their input file exclusively — a shared per-session
372
+ // name let one child's `finally` unlink eat the other's input
373
+ // (lost final:true) or double-dispatch. See finishInputTmpFile.
374
+ tmpFile: (0, utils_1.finishInputTmpFile)(session_id),
375
+ });
376
+ }
377
+ catch {
378
+ // capture is best-effort; never break backup
379
+ }
380
+ }
320
381
  const { runBackup } = await Promise.resolve().then(() => __importStar(require("./backup/run")));
321
382
  runBackup(workspaceRoot, process.argv[1]);
322
383
  process.exit(0);
@@ -338,14 +399,23 @@ async function main() {
338
399
  process.exit(0);
339
400
  }
340
401
  try {
341
- const jobFile = path.join(os.tmpdir(), `codiedev-capture-job-${session_id ?? process.pid}.json`);
402
+ // "codiedev-finish-" prefix (NOT "codiedev-capture-") so the sweep regex
403
+ // /^codiedev-capture-.*\.json$/ never mistakes a finish-input file for an
404
+ // upload job. UNIQUE per dispatch (sid+pid+ts) so a concurrent Stop-hook
405
+ // child's `finally` unlink can never eat this input (lost final:true).
406
+ const jobFile = (0, utils_1.finishInputTmpFile)(session_id);
342
407
  (0, utils_1.dispatchCaptureFinish)({
343
408
  mode: process.argv[2] ?? "capture",
344
409
  session_id,
345
410
  transcript_path,
346
411
  cwd,
347
412
  workspace_roots,
348
- }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile });
413
+ // final:true ONLY for claude-code's SessionEnd. Codex routes its
414
+ // per-turn Stop hook through "capture-codex" — final:true there would
415
+ // close the server lease every turn. Codex/Cursor keep legacy
416
+ // behaviour (final omitted).
417
+ final: (0, utils_1.resolveCaptureFinal)(process.argv[2]),
418
+ }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile, openSync: fs.openSync, closeSync: fs.closeSync });
349
419
  }
350
420
  catch (err) {
351
421
  process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
@@ -366,6 +436,22 @@ async function runCaptureFinish(input) {
366
436
  const { session_id, transcript_path, cwd, workspace_roots } = input;
367
437
  if (!transcript_path)
368
438
  return;
439
+ // Log that this child has started processing — the first diagnostic proof
440
+ // that the detached capture-finish child actually ran (visible in doctor).
441
+ (0, captureLog_1.appendCaptureLog)({ stage: "capture-finish", sessionId: session_id, outcome: "start" });
442
+ try {
443
+ await runCaptureFinishInner(input, config, session_id, transcript_path, cwd, workspace_roots);
444
+ }
445
+ catch (err) {
446
+ (0, captureLog_1.appendCaptureLog)({
447
+ stage: "capture-finish",
448
+ sessionId: session_id,
449
+ outcome: "ERROR",
450
+ detail: (err instanceof Error ? err.message : String(err)).slice(0, 200),
451
+ });
452
+ }
453
+ }
454
+ async function runCaptureFinishInner(input, config, session_id, transcript_path, cwd, workspace_roots) {
369
455
  const mode = resolveMode(input.mode);
370
456
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
371
457
  ? workspace_roots[0]
@@ -458,23 +544,59 @@ async function runCaptureFinish(input) {
458
544
  sessionStartedAt,
459
545
  interruptCount: steering.interrupts,
460
546
  rejectionCount: steering.rejections,
547
+ // Carry the final flag into the HTTP body — the server uses it to decide
548
+ // whether to close the session lease (true) or renew it (false/absent).
549
+ final: input.final,
550
+ });
551
+ // Throttle decision: final captures go immediately; non-final (per-turn Stop)
552
+ // are deferred if we uploaded this session recently, to avoid per-turn churn.
553
+ const uploadState = (0, utils_1.readSessionUploadState)(session_id ?? "");
554
+ const decision = (0, utils_1.decideDispatch)({
555
+ final: input.final === true,
556
+ lastUploadAt: uploadState?.lastUploadAt ?? null,
557
+ now: Date.now(),
558
+ });
559
+ // Log capture-finish outcome with dispatch context so doctor can see whether
560
+ // the upload went immediately or was deferred (and by how much).
561
+ (0, captureLog_1.appendCaptureLog)({
562
+ stage: "capture-finish",
563
+ sessionId: session_id,
564
+ outcome: decision.mode === "deferred" ? "deferred" : "ok",
565
+ detail: decision.mode === "deferred"
566
+ ? `dispatch=deferred delayMs=${decision.delayMs} final=${input.final === true} repo=${remoteUrl ?? "-"} branch=${branch ?? "-"} msgs=${stats.messageCount}`
567
+ : `dispatch=immediate final=${input.final === true} repo=${remoteUrl ?? "-"} branch=${branch ?? "-"} msgs=${stats.messageCount}`,
461
568
  });
462
569
  // Fire the upload from a further-detached child. We're already off the
463
570
  // shutdown clock here, but reusing dispatchCaptureDetached keeps the upload
464
571
  // retry + last-capture breadcrumb logic (runCaptureUpload) intact verbatim.
572
+ // Per-session job file: one file per session; newer turns overwrite it,
573
+ // which is the "supersede" mechanism for the deferred path.
465
574
  try {
466
575
  const hookScriptPath = process.argv[1];
467
576
  const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
468
- (0, utils_1.dispatchCaptureDetached)({ url: `${config.backendUrl}/api/captureSession`, token: config.token, body: payload }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile });
469
- // Retry any earlier capture jobs that failed to upload (transient network
470
- // blips), excluding the one we just dispatched. Best-effort, detached.
471
- (0, utils_1.sweepPendingCaptures)({
472
- dir: os.tmpdir(),
473
- readdirSync: fs.readdirSync,
474
- spawn: child_process_1.spawn,
475
- hookScriptPath,
476
- exclude: tmpFile,
477
- });
577
+ if (decision.mode === "immediate") {
578
+ (0, utils_1.dispatchCaptureDetached)({ url: `${config.backendUrl}/api/captureSession`, token: config.token, body: payload }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
579
+ // Retry any earlier capture jobs that failed to upload (transient network
580
+ // blips), excluding the one we just dispatched. Best-effort, detached.
581
+ // Skip the sweep on deferred path — the deferred child will handle uploading.
582
+ (0, utils_1.sweepPendingCaptures)({
583
+ dir: os.tmpdir(),
584
+ readdirSync: fs.readdirSync,
585
+ renameSync: fs.renameSync,
586
+ statSync: fs.statSync,
587
+ spawn: child_process_1.spawn,
588
+ hookScriptPath,
589
+ exclude: tmpFile,
590
+ openSync: fs.openSync,
591
+ closeSync: fs.closeSync,
592
+ });
593
+ }
594
+ else {
595
+ // Deferred: write the job file (newer turns overwrite = supersede), then
596
+ // spawn the deferred-upload child which will sleep, claim, and upload.
597
+ // Same stdio/fd-hygiene contract as dispatchCaptureDetached.
598
+ (0, utils_1.dispatchDeferredUpload)({ url: `${config.backendUrl}/api/captureSession`, token: config.token, body: payload }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync }, decision.delayMs);
599
+ }
478
600
  }
479
601
  catch (err) {
480
602
  process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import { spawn as nodeSpawn } from "child_process";
3
+ import type { CaptureLogEntry } from "./captureLog";
3
4
  export interface CodiedevConfig {
4
5
  token: string;
5
6
  companyId: string;
@@ -62,6 +63,7 @@ export interface CapturePayloadInput {
62
63
  sessionStartedAt?: number | null;
63
64
  interruptCount?: number;
64
65
  rejectionCount?: number;
66
+ final?: boolean;
65
67
  }
66
68
  export declare function buildCapturePayload(input: CapturePayloadInput): Record<string, unknown>;
67
69
  export declare function hashToken(token: string): string;
@@ -174,6 +176,8 @@ interface DispatchDeps {
174
176
  writeFileSync: typeof fs.writeFileSync;
175
177
  tmpFile: string;
176
178
  nodePath?: string;
179
+ openSync?: typeof fs.openSync;
180
+ closeSync?: typeof fs.closeSync;
177
181
  }
178
182
  export interface CaptureFinishInput {
179
183
  mode: string;
@@ -181,19 +185,65 @@ export interface CaptureFinishInput {
181
185
  transcript_path?: string;
182
186
  cwd?: string;
183
187
  workspace_roots?: string[];
188
+ final?: boolean;
184
189
  }
185
190
  export declare function dispatchCaptureFinish(input: CaptureFinishInput, hookScriptPath: string, deps: DispatchDeps): void;
186
191
  export declare function dispatchCaptureDetached(job: CaptureUploadJob, hookScriptPath: string, deps: DispatchDeps): void;
192
+ export declare function dispatchDeferredUpload(job: CaptureUploadJob, hookScriptPath: string, deps: DispatchDeps, delayMs: number): void;
187
193
  interface SweepDeps {
188
194
  dir: string;
189
195
  readdirSync: (dir: string) => string[];
196
+ renameSync: (from: string, to: string) => void;
197
+ statSync: (p: string) => {
198
+ mtimeMs: number;
199
+ };
190
200
  spawn: typeof nodeSpawn;
191
201
  hookScriptPath: string;
192
202
  nodePath?: string;
193
203
  exclude?: string;
194
204
  max?: number;
205
+ now?: () => number;
206
+ openSync?: typeof fs.openSync;
207
+ closeSync?: typeof fs.closeSync;
208
+ pruneState?: () => void;
195
209
  }
210
+ export declare const CLAIM_STALE_MS: number;
196
211
  export declare function sweepPendingCaptures(deps: SweepDeps): void;
212
+ export declare const CAPTURE_THROTTLE_MS: number;
213
+ export declare const SWEEP_MIN_AGE_MS: number;
214
+ export declare function resolveCaptureFinal(argv2: string | undefined): boolean | undefined;
215
+ export declare function finishInputTmpFile(sessionId: string | undefined): string;
216
+ export declare function decideDispatch(args: {
217
+ final: boolean;
218
+ lastUploadAt: number | null;
219
+ now: number;
220
+ }): {
221
+ mode: "immediate";
222
+ } | {
223
+ mode: "deferred";
224
+ delayMs: number;
225
+ };
226
+ export interface SessionUploadState {
227
+ lastUploadAt: number;
228
+ }
229
+ interface SessionStateDeps {
230
+ readFileSync: typeof fs.readFileSync;
231
+ existsSync: typeof fs.existsSync;
232
+ }
233
+ interface SessionStateWriteDeps {
234
+ writeFileSync: typeof fs.writeFileSync;
235
+ existsSync: typeof fs.existsSync;
236
+ mkdirSync: typeof fs.mkdirSync;
237
+ }
238
+ export declare function readSessionUploadState(sessionId: string, deps?: SessionStateDeps): SessionUploadState | null;
239
+ export declare function writeSessionUploadState(sessionId: string, state: SessionUploadState, deps?: SessionStateWriteDeps): void;
240
+ export declare function pruneCaptureStateDir(deps?: {
241
+ readdirSync: (d: string) => string[];
242
+ statSync: (p: string) => {
243
+ mtimeMs: number;
244
+ };
245
+ unlinkSync: (p: string) => void;
246
+ }, dir?: string, now?: number): void;
197
247
  interface UploadDeps {
198
248
  post: (url: string, body: Record<string, unknown>, token: string) => Promise<void>;
199
249
  readFileSync: typeof fs.readFileSync;
@@ -201,6 +251,8 @@ interface UploadDeps {
201
251
  writeFileSync?: typeof fs.writeFileSync;
202
252
  now?: () => number;
203
253
  recordSuccess?: (job: CaptureUploadJob) => void;
254
+ log?: (entry: CaptureLogEntry) => void;
255
+ writeSessionState?: (sessionId: string, state: SessionUploadState) => void;
204
256
  }
205
257
  export interface LastCaptureRecord {
206
258
  sessionId: string | undefined;
@@ -225,4 +277,9 @@ interface LastCaptureReadDeps {
225
277
  }
226
278
  export declare function readLastCapture(deps?: LastCaptureReadDeps, filePath?: string): LastCaptureRecord | null;
227
279
  export declare function runCaptureUpload(jobFile: string, deps: UploadDeps): Promise<void>;
280
+ interface DeferredUploadDeps extends UploadDeps {
281
+ renameSync: typeof fs.renameSync;
282
+ sleep?: (ms: number) => Promise<void>;
283
+ }
284
+ export declare function runDeferredUpload(jobFile: string, delayMs: number, deps: DeferredUploadDeps): Promise<void>;
228
285
  export {};
package/dist/utils.js CHANGED
@@ -33,7 +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
+ exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
37
37
  exports.readConfig = readConfig;
38
38
  exports.writeConfig = writeConfig;
39
39
  exports.parseGitRemoteFromConfig = parseGitRemoteFromConfig;
@@ -85,16 +85,25 @@ exports.parseCodexStats = parseCodexStats;
85
85
  exports.extractCodexRepoUrl = extractCodexRepoUrl;
86
86
  exports.dispatchCaptureFinish = dispatchCaptureFinish;
87
87
  exports.dispatchCaptureDetached = dispatchCaptureDetached;
88
+ exports.dispatchDeferredUpload = dispatchDeferredUpload;
88
89
  exports.sweepPendingCaptures = sweepPendingCaptures;
90
+ exports.resolveCaptureFinal = resolveCaptureFinal;
91
+ exports.finishInputTmpFile = finishInputTmpFile;
92
+ exports.decideDispatch = decideDispatch;
93
+ exports.readSessionUploadState = readSessionUploadState;
94
+ exports.writeSessionUploadState = writeSessionUploadState;
95
+ exports.pruneCaptureStateDir = pruneCaptureStateDir;
89
96
  exports.buildLastCaptureRecord = buildLastCaptureRecord;
90
97
  exports.writeLastCapture = writeLastCapture;
91
98
  exports.readLastCapture = readLastCapture;
92
99
  exports.runCaptureUpload = runCaptureUpload;
100
+ exports.runDeferredUpload = runDeferredUpload;
93
101
  const fs = __importStar(require("fs"));
94
102
  const path = __importStar(require("path"));
95
103
  const os = __importStar(require("os"));
96
104
  const child_process_1 = require("child_process");
97
105
  const detection_1 = require("./detection");
106
+ const captureLog_1 = require("./captureLog");
98
107
  const CONFIG_DIR = path.join(os.homedir(), ".codiedev");
99
108
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
100
109
  function readConfig() {
@@ -676,6 +685,8 @@ function buildCapturePayload(input) {
676
685
  sessionStartedAt: input.sessionStartedAt ?? undefined,
677
686
  interruptCount: input.interruptCount,
678
687
  rejectionCount: input.rejectionCount,
688
+ // final is only emitted when explicitly set (not for Codex/Cursor).
689
+ final: input.final,
679
690
  };
680
691
  }
681
692
  function hashToken(token) {
@@ -1626,6 +1637,32 @@ function extractCodexRepoUrl(content) {
1626
1637
  // failing job never litters /tmp forever. Paired with UPLOAD_TTL_MS.
1627
1638
  exports.MAX_UPLOAD_ATTEMPTS = 5;
1628
1639
  exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
1640
+ // Open the capture log for appending so a detached child's stderr lands there
1641
+ // instead of vanishing into stdio:"ignore". Returns the stdio value to spawn
1642
+ // with plus the parent's fd (to close after spawn — the child gets its own
1643
+ // duplicate). Falls back to plain "ignore" when openSync is absent or throws,
1644
+ // so a missing/unwritable log dir never breaks a dispatch.
1645
+ function openCaptureLogStdio(openSync) {
1646
+ if (!openSync)
1647
+ return { stdio: "ignore" };
1648
+ try {
1649
+ const fd = openSync(captureLog_1.CAPTURE_LOG_PATH, "a");
1650
+ return { stdio: ["ignore", "ignore", fd], fd };
1651
+ }
1652
+ catch {
1653
+ return { stdio: "ignore" };
1654
+ }
1655
+ }
1656
+ // Best-effort close of the parent's log-fd copy. No-op when there's no fd or
1657
+ // no closeSync dep — never throws into the capture path.
1658
+ function closeCaptureLogFd(fd, closeSync) {
1659
+ if (fd === undefined || !closeSync)
1660
+ return;
1661
+ try {
1662
+ closeSync(fd);
1663
+ }
1664
+ catch { /* best-effort */ }
1665
+ }
1629
1666
  // Hand the SessionEnd capture off to a detached child so the inline hook can
1630
1667
  // exit 0 immediately. Detaching only the network upload (dispatchCaptureDetached)
1631
1668
  // wasn't enough: the synchronous transcript read + parse and — the killer on
@@ -1637,8 +1674,21 @@ exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
1637
1674
  // small file write plus a spawn.
1638
1675
  function dispatchCaptureFinish(input, hookScriptPath, deps) {
1639
1676
  deps.writeFileSync(deps.tmpFile, JSON.stringify(input), { mode: 0o600 });
1640
- const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "capture-finish", deps.tmpFile], { detached: true, stdio: "ignore" });
1641
- child.unref();
1677
+ // Route the child's stderr to the capture log so any crash output is
1678
+ // visible to `codiedev doctor`. The caller must explicitly pass openSync
1679
+ // (e.g. fs.openSync in hook.ts) to enable this; when omitted (tests,
1680
+ // older call-sites) we fall back to "ignore" so existing behaviour is
1681
+ // unchanged and no real fd is opened.
1682
+ const { stdio, fd } = openCaptureLogStdio(deps.openSync);
1683
+ try {
1684
+ const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "capture-finish", deps.tmpFile], { detached: true, stdio });
1685
+ child.unref();
1686
+ }
1687
+ finally {
1688
+ // Close the PARENT's fd copy even when spawn throws — the child (if
1689
+ // spawned) holds its own duplicate, so this never cuts off its stderr.
1690
+ closeCaptureLogFd(fd, deps.closeSync);
1691
+ }
1642
1692
  }
1643
1693
  // Fire-and-forget the session-capture upload from a fully detached child so the
1644
1694
  // SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
@@ -1647,12 +1697,52 @@ function dispatchCaptureFinish(input, hookScriptPath, deps) {
1647
1697
  // passed via argv so it never shows up in the process list.
1648
1698
  function dispatchCaptureDetached(job, hookScriptPath, deps) {
1649
1699
  deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
1650
- const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload", deps.tmpFile], { detached: true, stdio: "ignore" });
1651
- child.unref();
1700
+ // Route the child's stderr to the capture log (same pattern as
1701
+ // dispatchCaptureFinish). The caller must explicitly pass openSync to
1702
+ // enable this; when omitted we fall back to "ignore" so existing callers
1703
+ // keep working unchanged.
1704
+ const { stdio, fd } = openCaptureLogStdio(deps.openSync);
1705
+ try {
1706
+ const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload", deps.tmpFile], { detached: true, stdio });
1707
+ child.unref();
1708
+ }
1709
+ finally {
1710
+ // Close the PARENT's fd copy even when spawn throws — the child (if
1711
+ // spawned) holds its own duplicate, so this never cuts off its stderr.
1712
+ closeCaptureLogFd(fd, deps.closeSync);
1713
+ }
1652
1714
  }
1715
+ // Deferred twin of dispatchCaptureDetached: persist the job, then spawn the
1716
+ // `upload-deferred` child which sleeps for delayMs before claiming + uploading
1717
+ // (or finding itself superseded by a newer turn). Used when decideDispatch
1718
+ // says a non-final per-turn capture is inside the throttle window. Identical
1719
+ // stdio/fd-hygiene contract to the other dispatch fns.
1720
+ function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
1721
+ deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
1722
+ const { stdio, fd } = openCaptureLogStdio(deps.openSync);
1723
+ try {
1724
+ const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload-deferred", deps.tmpFile, String(delayMs)], { detached: true, stdio });
1725
+ child.unref();
1726
+ }
1727
+ finally {
1728
+ // Close the PARENT's fd copy even when spawn throws — the child (if
1729
+ // spawned) holds its own duplicate, so this never cuts off its stderr.
1730
+ closeCaptureLogFd(fd, deps.closeSync);
1731
+ }
1732
+ }
1733
+ // A claimed job whose uploader hasn't finished after this long is presumed
1734
+ // crashed and gets renamed back to its canonical name for a later retry.
1735
+ exports.CLAIM_STALE_MS = 15 * 60 * 1000;
1653
1736
  // Retry kept-on-failure capture jobs: re-dispatch each pending
1654
1737
  // `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
1655
1738
  // gives up at the cap). Best-effort — never throws into the hook.
1739
+ //
1740
+ // Each job is CLAIMED with an atomic rename before dispatch. Several
1741
+ // session-end hooks can run this sweep over the same tmpdir at once, and
1742
+ // without the claim each of them dispatched an uploader for the same job —
1743
+ // every duplicate upload triggered a full server-side re-summarize of the
1744
+ // session (real LLM spend per duplicate). Rename is atomic, so exactly one
1745
+ // sweeper wins each file; the losers' renames throw and they skip it.
1656
1746
  function sweepPendingCaptures(deps) {
1657
1747
  let files;
1658
1748
  try {
@@ -1661,20 +1751,190 @@ function sweepPendingCaptures(deps) {
1661
1751
  catch {
1662
1752
  return;
1663
1753
  }
1754
+ const now = deps.now?.() ?? Date.now();
1755
+ // Recover claims orphaned by a crashed/killed uploader: a stale claim is
1756
+ // renamed back to its canonical name so a LATER sweep retries it. (Not this
1757
+ // one — the uploader may legitimately still be running near the threshold,
1758
+ // and dispatching reclaimed work immediately would race it.)
1759
+ for (const f of files) {
1760
+ const m = /^(codiedev-capture-.*\.json)\.claim-\d+$/.exec(f);
1761
+ if (!m)
1762
+ continue;
1763
+ const claimedPath = path.join(deps.dir, f);
1764
+ try {
1765
+ if (now - deps.statSync(claimedPath).mtimeMs > exports.CLAIM_STALE_MS) {
1766
+ deps.renameSync(claimedPath, path.join(deps.dir, m[1]));
1767
+ }
1768
+ }
1769
+ catch {
1770
+ // raced another sweeper or the uploader just finished — fine
1771
+ }
1772
+ }
1664
1773
  const jobs = files
1665
1774
  .filter((f) => /^codiedev-capture-.*\.json$/.test(f))
1666
1775
  .map((f) => path.join(deps.dir, f))
1667
1776
  .filter((p) => p !== deps.exclude)
1777
+ // Min-age guard: a YOUNG job file may be a sleeping deferred upload whose
1778
+ // own upload-deferred child will consume it within CAPTURE_THROTTLE_MS of
1779
+ // its mtime. The sweep must not steal it — that would upload per-turn and
1780
+ // defeat the throttle with 2+ concurrent sessions. Only genuinely stuck
1781
+ // jobs (older than SWEEP_MIN_AGE_MS) are retried. A stat failure means the
1782
+ // job was claimed/consumed mid-sweep — skip it.
1783
+ .filter((p) => {
1784
+ try {
1785
+ return now - deps.statSync(p).mtimeMs >= exports.SWEEP_MIN_AGE_MS;
1786
+ }
1787
+ catch {
1788
+ return false;
1789
+ }
1790
+ })
1668
1791
  .slice(0, deps.max ?? 10);
1792
+ // One log fd shared by every re-dispatched uploader in this batch (each
1793
+ // spawn duplicates it); the parent's copy is closed after the loop.
1794
+ const { stdio, fd } = openCaptureLogStdio(deps.openSync);
1669
1795
  for (const jobFile of jobs) {
1796
+ const claimed = `${jobFile}.claim-${process.pid}`;
1670
1797
  try {
1671
- const child = deps.spawn(deps.nodePath ?? process.execPath, [deps.hookScriptPath, "upload", jobFile], { detached: true, stdio: "ignore" });
1798
+ deps.renameSync(jobFile, claimed);
1799
+ }
1800
+ catch {
1801
+ continue; // another sweeper claimed it first
1802
+ }
1803
+ try {
1804
+ const child = deps.spawn(deps.nodePath ?? process.execPath, [deps.hookScriptPath, "upload", claimed], { detached: true, stdio });
1672
1805
  child.unref();
1673
1806
  }
1674
1807
  catch {
1675
- // ignore — best-effort retry
1808
+ // Couldn't fork the uploader — release the claim so a later sweep
1809
+ // retries instead of the job dying invisible under the claimed name.
1810
+ try {
1811
+ deps.renameSync(claimed, jobFile);
1812
+ }
1813
+ catch {
1814
+ // best-effort
1815
+ }
1676
1816
  }
1677
1817
  }
1818
+ closeCaptureLogFd(fd, deps.closeSync);
1819
+ // Housekeeping: prune per-session upload-state files older than the upload
1820
+ // TTL so capture-state doesn't grow forever. Best-effort, never throws.
1821
+ try {
1822
+ (deps.pruneState ?? pruneCaptureStateDir)();
1823
+ }
1824
+ catch {
1825
+ // best-effort
1826
+ }
1827
+ }
1828
+ // ─────────────────────────────────────────────────────────────────────────────
1829
+ // Per-session upload throttle — defers mid-session Stop-hook uploads so we
1830
+ // don't hammer the server (and re-summarize) after every single turn.
1831
+ // ─────────────────────────────────────────────────────────────────────────────
1832
+ // Minimum interval between non-final uploads for the same session. 10 minutes:
1833
+ // fine-grained enough to capture recent work while avoiding per-turn churn.
1834
+ exports.CAPTURE_THROTTLE_MS = 10 * 60 * 1000;
1835
+ // Minimum AGE of a job file before the retry sweep may claim it. An actively
1836
+ // deferred job is consumed by its own upload-deferred child within
1837
+ // CAPTURE_THROTTLE_MS of its mtime — a sweep stealing it early would upload
1838
+ // per-turn and defeat the throttle whenever 2+ sessions run concurrently.
1839
+ // The 2-min slack covers the child's wake-up + claim. Side effect: failed
1840
+ // uploads (whose retry bump refreshes mtime) now wait ~12 min between retry
1841
+ // attempts — fine for a 7-day-TTL retry queue, and a free rate-limit on
1842
+ // retry storms.
1843
+ exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS + 2 * 60 * 1000;
1844
+ // Map the hook's capture argv to the payload's `final` flag. EXPLICIT
1845
+ // ALLOWLIST: only claude-code's SessionEnd entrypoints ("capture", or the bare
1846
+ // hook with no subcommand) are a true final signal. Everything else —
1847
+ // capture-codex (Codex's Stop fires per-turn; final:true would close the
1848
+ // server-side session lease every turn), capture-cursor, and any FUTURE mode
1849
+ // (e.g. a capture-windsurf added to resolveMode but not audited here) — gets
1850
+ // undefined, i.e. legacy server behaviour. Defaulting unknowns to true would
1851
+ // silently re-introduce the per-turn lease-close bug for every new agent.
1852
+ function resolveCaptureFinal(argv2) {
1853
+ return argv2 === "capture" || argv2 === undefined ? true : undefined;
1854
+ }
1855
+ // Build a UNIQUE finish-input tmpFile name per dispatch. Both the Stop hook and
1856
+ // SessionEnd dispatch capture-finish children, and the child unlinks its input
1857
+ // in `finally` — a SHARED per-session name let interleavings drop the final
1858
+ // dispatch (Stop's child unlinks the file SessionEnd just wrote) or
1859
+ // double-dispatch. Unique names mean each child owns its file exclusively; at
1860
+ // worst concurrent Stop+SessionEnd children both upload, which is harmless
1861
+ // (the server upserts by sessionId). Orphans (child dies before unlink) sit in
1862
+ // tmpdir OUTSIDE the sweep regex (codiedev-finish- ≠ codiedev-capture-);
1863
+ // negligible volume (one tiny JSON per crashed child), though note Windows
1864
+ // %TEMP% is not auto-cleaned — acceptable.
1865
+ function finishInputTmpFile(sessionId) {
1866
+ return path.join(os.tmpdir(), `codiedev-finish-${sessionId ?? process.pid}-${process.pid}-${Date.now()}.json`);
1867
+ }
1868
+ // Decide whether to upload immediately or defer until the throttle window expires.
1869
+ // final=true → always immediate (SessionEnd: close the session now)
1870
+ // null → immediate (first upload for this session)
1871
+ // outside window → immediate
1872
+ // inside window → deferred with exact remaining delay
1873
+ // future clock skew → deferred clamped to CAPTURE_THROTTLE_MS
1874
+ function decideDispatch(args) {
1875
+ if (args.final || args.lastUploadAt === null)
1876
+ return { mode: "immediate" };
1877
+ const elapsed = args.now - args.lastUploadAt;
1878
+ // Clock-skew guard: elapsed < 0 means lastUploadAt is in the future. Clamping
1879
+ // to 0 also bounds delayMs to at most CAPTURE_THROTTLE_MS (safeElapsed >= 0).
1880
+ const safeElapsed = Math.max(0, elapsed);
1881
+ if (safeElapsed >= exports.CAPTURE_THROTTLE_MS)
1882
+ return { mode: "immediate" };
1883
+ return { mode: "deferred", delayMs: exports.CAPTURE_THROTTLE_MS - safeElapsed };
1884
+ }
1885
+ // Per-session state dir: ~/.codiedev/capture-state/<sanitized-session-id>.json
1886
+ const CAPTURE_STATE_DIR = path.join(CONFIG_DIR, "capture-state");
1887
+ function captureStatePath(sessionId) {
1888
+ const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
1889
+ return path.join(CAPTURE_STATE_DIR, `${safe}.json`);
1890
+ }
1891
+ // Read the per-session upload state from ~/.codiedev/capture-state/<id>.json.
1892
+ // Returns null when missing or unreadable. Never throws.
1893
+ function readSessionUploadState(sessionId, deps = fs) {
1894
+ try {
1895
+ const p = captureStatePath(sessionId);
1896
+ if (!deps.existsSync(p))
1897
+ return null;
1898
+ return JSON.parse(deps.readFileSync(p, "utf8"));
1899
+ }
1900
+ catch {
1901
+ return null;
1902
+ }
1903
+ }
1904
+ // Write the per-session upload state to ~/.codiedev/capture-state/<id>.json.
1905
+ // Best-effort: never throws into the capture path.
1906
+ function writeSessionUploadState(sessionId, state, deps = fs) {
1907
+ try {
1908
+ if (!deps.existsSync(CAPTURE_STATE_DIR)) {
1909
+ deps.mkdirSync(CAPTURE_STATE_DIR, { recursive: true });
1910
+ }
1911
+ const p = captureStatePath(sessionId);
1912
+ deps.writeFileSync(p, JSON.stringify(state), { mode: 0o600 });
1913
+ }
1914
+ catch {
1915
+ // best-effort — never break the capture path
1916
+ }
1917
+ }
1918
+ // Prune per-session upload-state files older than UPLOAD_TTL_MS (7 days — same
1919
+ // TTL as the upload retry queue) so ~/.codiedev/capture-state (one tiny file
1920
+ // per session) doesn't grow forever. Called from sweepPendingCaptures, i.e. on
1921
+ // the cadence captures already run housekeeping. Best-effort: never throws.
1922
+ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now()) {
1923
+ try {
1924
+ for (const f of deps.readdirSync(dir)) {
1925
+ const p = path.join(dir, f);
1926
+ try {
1927
+ if (now - deps.statSync(p).mtimeMs > exports.UPLOAD_TTL_MS)
1928
+ deps.unlinkSync(p);
1929
+ }
1930
+ catch {
1931
+ // raced another pruner or unreadable — skip
1932
+ }
1933
+ }
1934
+ }
1935
+ catch {
1936
+ // dir missing/unreadable — nothing to prune
1937
+ }
1678
1938
  }
1679
1939
  // "Last session captured" breadcrumb. Written next to config.json on a
1680
1940
  // successful capture upload; read by `codiedev doctor` so a user can confirm
@@ -1732,6 +1992,7 @@ async function runCaptureUpload(jobFile, deps) {
1732
1992
  catch { /* gone */ }
1733
1993
  return;
1734
1994
  }
1995
+ const sessionId = job.body?.sessionId;
1735
1996
  try {
1736
1997
  await deps.post(job.url, job.body, job.token);
1737
1998
  // success — drop a "last session captured" breadcrumb (before removing the
@@ -1742,28 +2003,82 @@ async function runCaptureUpload(jobFile, deps) {
1742
2003
  }
1743
2004
  catch { /* breadcrumb is best-effort */ }
1744
2005
  }
2006
+ // Write the per-session upload state so the throttle can defer the next
2007
+ // non-final (per-turn) upload until the window expires.
2008
+ const uploadedSessionId = job.body?.sessionId;
2009
+ if (uploadedSessionId && deps.writeSessionState) {
2010
+ try {
2011
+ deps.writeSessionState(uploadedSessionId, { lastUploadAt: deps.now ? deps.now() : Date.now() });
2012
+ }
2013
+ catch { /* best-effort */ }
2014
+ }
2015
+ const attempt = (job.attempts ?? 0) + 1;
2016
+ if (deps.log) {
2017
+ try {
2018
+ deps.log({ stage: "upload", sessionId, outcome: "ok", detail: `attempt=${attempt}` });
2019
+ }
2020
+ catch { /* log is best-effort */ }
2021
+ }
1745
2022
  try {
1746
2023
  deps.unlinkSync(jobFile);
1747
2024
  }
1748
2025
  catch { /* gone */ }
1749
2026
  return;
1750
2027
  }
1751
- catch {
2028
+ catch (err) {
1752
2029
  // Failure: keep the job for a later sweep to retry, UNLESS we've hit the
1753
2030
  // attempt cap or TTL — then give up so /tmp doesn't accumulate forever.
1754
2031
  const now = deps.now ? deps.now() : Date.now();
1755
2032
  const attempts = (job.attempts ?? 0) + 1;
1756
2033
  const queuedAt = job.queuedAt ?? now;
1757
2034
  const expired = now - queuedAt > exports.UPLOAD_TTL_MS;
1758
- if (attempts >= exports.MAX_UPLOAD_ATTEMPTS || expired || !deps.writeFileSync) {
2035
+ const giveUp = attempts >= exports.MAX_UPLOAD_ATTEMPTS || expired || !deps.writeFileSync;
2036
+ if (giveUp) {
2037
+ if (deps.log) {
2038
+ try {
2039
+ const ageMs = now - queuedAt;
2040
+ deps.log({
2041
+ stage: "upload",
2042
+ sessionId,
2043
+ outcome: "gave-up",
2044
+ detail: `attempt=${attempts} age=${ageMs}ms`,
2045
+ });
2046
+ }
2047
+ catch { /* log is best-effort */ }
2048
+ }
1759
2049
  try {
1760
2050
  deps.unlinkSync(jobFile);
1761
2051
  }
1762
2052
  catch { /* gone */ }
1763
2053
  return;
1764
2054
  }
2055
+ // Log the error before persisting the retry bump.
2056
+ if (deps.log) {
2057
+ try {
2058
+ const msg = err.message ?? String(err);
2059
+ // Extract http=<status> when the error message carries one (format used
2060
+ // by postJson: "HTTP <status>: <body>"). Truncate to 200 chars.
2061
+ const httpMatch = msg.match(/\bhttp[= ](\d{3})\b/i);
2062
+ const httpPart = httpMatch ? `http=${httpMatch[1]} ` : "";
2063
+ const detail = `${httpPart}${msg.slice(0, 200)} attempt=${attempts}`;
2064
+ deps.log({ stage: "upload", sessionId, outcome: "ERROR", detail });
2065
+ }
2066
+ catch { /* log is best-effort */ }
2067
+ }
2068
+ // The sweep hands us a CLAIMED path (`<job>.claim-<pid>`). Persist the
2069
+ // retry bump under the canonical name — the sweep only matches
2070
+ // `codiedev-capture-*.json`, so a bump left on the claimed path would
2071
+ // never be retried (a silently dropped capture).
2072
+ const canonical = jobFile.replace(/\.claim-\d+$/, "");
2073
+ const writeFn = deps.writeFileSync; // always set here: giveUp branch checked !deps.writeFileSync
1765
2074
  try {
1766
- deps.writeFileSync(jobFile, JSON.stringify({ ...job, attempts, queuedAt }), { mode: 0o600 });
2075
+ writeFn(canonical, JSON.stringify({ ...job, attempts, queuedAt }), { mode: 0o600 });
2076
+ if (canonical !== jobFile) {
2077
+ try {
2078
+ deps.unlinkSync(jobFile);
2079
+ }
2080
+ catch { /* gone */ }
2081
+ }
1767
2082
  }
1768
2083
  catch {
1769
2084
  // couldn't persist the bump — drop it rather than risk a stuck file.
@@ -1774,3 +2089,27 @@ async function runCaptureUpload(jobFile, deps) {
1774
2089
  }
1775
2090
  }
1776
2091
  }
2092
+ async function runDeferredUpload(jobFile, delayMs, deps) {
2093
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2094
+ await sleep(delayMs);
2095
+ // Claim via rename. If the file is gone or already claimed, a newer turn's
2096
+ // upload superseded this one: log and exit silently.
2097
+ const claimed = `${jobFile}.claim-${process.pid}`;
2098
+ try {
2099
+ deps.renameSync(jobFile, claimed);
2100
+ }
2101
+ catch {
2102
+ if (deps.log) {
2103
+ try {
2104
+ // The job file is gone, so its body (and sessionId) is unreadable —
2105
+ // recover the session id from the per-session filename so doctor can
2106
+ // still tie this line to a session.
2107
+ const sid = /^codiedev-capture-(.+)\.json/.exec(path.basename(jobFile))?.[1];
2108
+ deps.log({ stage: "upload-deferred", sessionId: sid, outcome: "superseded" });
2109
+ }
2110
+ catch { /* log is best-effort */ }
2111
+ }
2112
+ return;
2113
+ }
2114
+ await runCaptureUpload(claimed, deps);
2115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.18",
3
+ "version": "0.9.20",
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",