codiedev 0.9.22 → 0.9.24

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
@@ -158,6 +158,20 @@ async function runStopFailure(hookInput) {
158
158
  }
159
159
  process.exit(0);
160
160
  }
161
+ // Skip-reason ping: this session is ending WITHOUT a capture, and here's why.
162
+ // Turns silent coverage loss into per-reason buckets on the admin Capture
163
+ // Health panel (patches the session's aiSessionStarts row server-side). Tiny
164
+ // like the sessionStart ping. Best-effort, never throws.
165
+ async function postSessionSkip(config, sessionId, reason) {
166
+ if (!sessionId)
167
+ return;
168
+ try {
169
+ await postJson(`${config.backendUrl}/api/cli/sessionSkip`, { sessionId, reason, at: Date.now() }, config.token, PING_TIMEOUT_MS);
170
+ }
171
+ catch {
172
+ /* best-effort */
173
+ }
174
+ }
161
175
  // SessionStart: the capture-coverage denominator. Record that a session opened
162
176
  // so the backend can later compute captured ÷ started. Deliberately tiny — no
163
177
  // transcript read, no git — so it never slows session startup. Best-effort +
@@ -172,7 +186,7 @@ async function runSessionStart(hookInput) {
172
186
  sessionId: hookInput.session_id,
173
187
  startedAt: Date.now(),
174
188
  source: "claude-code",
175
- }, config.token);
189
+ }, config.token, PING_TIMEOUT_MS);
176
190
  }
177
191
  catch {
178
192
  /* best-effort — never block session startup */
@@ -180,7 +194,13 @@ async function runSessionStart(hookInput) {
180
194
  }
181
195
  process.exit(0);
182
196
  }
183
- function postJson(url, body, bearerToken) {
197
+ function postJson(url, body, bearerToken,
198
+ // Socket timeout. Pass a SMALL value for tiny pings that run on (or near)
199
+ // Claude Code's shutdown clock — a black-holed connection (proxy dropping
200
+ // packets without RST) otherwise hangs the hook until CC kills it. Leave
201
+ // unset for transcript uploads, which legitimately take a while on slow
202
+ // corporate links and already run in detached children with retry.
203
+ timeoutMs) {
184
204
  return new Promise((resolve, reject) => {
185
205
  const parsed = new URL(url);
186
206
  const data = JSON.stringify(body);
@@ -212,10 +232,97 @@ function postJson(url, body, bearerToken) {
212
232
  });
213
233
  });
214
234
  req.on("error", reject);
235
+ if (timeoutMs) {
236
+ req.setTimeout(timeoutMs, () => {
237
+ req.destroy(new Error(`timeout after ${timeoutMs}ms`));
238
+ });
239
+ }
215
240
  req.write(data);
216
241
  req.end();
217
242
  });
218
243
  }
244
+ // postJson variant that returns the parsed response body — the upload-URL
245
+ // handshake needs the { uploadUrl } the server hands back. No timeout: runs
246
+ // only in detached uploader children, never on the shutdown clock.
247
+ function postJsonForResponse(url, body, bearerToken) {
248
+ return new Promise((resolve, reject) => {
249
+ const parsed = new URL(url);
250
+ const data = JSON.stringify(body);
251
+ const lib = parsed.protocol === "https:" ? https : http;
252
+ const req = lib.request({
253
+ hostname: parsed.hostname,
254
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
255
+ path: parsed.pathname + parsed.search,
256
+ method: "POST",
257
+ headers: {
258
+ "Content-Type": "application/json",
259
+ "Content-Length": Buffer.byteLength(data),
260
+ Authorization: `Bearer ${bearerToken}`,
261
+ "X-Codiedev-Cli-Version": version_1.CLI_VERSION,
262
+ },
263
+ }, (res) => {
264
+ let responseData = "";
265
+ res.on("data", (chunk) => (responseData += chunk));
266
+ res.on("end", () => {
267
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
268
+ try {
269
+ resolve(JSON.parse(responseData));
270
+ }
271
+ catch {
272
+ reject(new Error(`HTTP ${res.statusCode}: unparseable response`));
273
+ }
274
+ }
275
+ else {
276
+ reject(new Error(`HTTP ${res.statusCode}: ${responseData}`));
277
+ }
278
+ });
279
+ });
280
+ req.on("error", reject);
281
+ req.write(data);
282
+ req.end();
283
+ });
284
+ }
285
+ // Raw-bytes POST to a Convex storage upload URL (no auth header — the URL
286
+ // itself is the one-time credential). Returns the parsed { storageId }.
287
+ function postBytes(url, bytes, contentType) {
288
+ return new Promise((resolve, reject) => {
289
+ const parsed = new URL(url);
290
+ const lib = parsed.protocol === "https:" ? https : http;
291
+ const req = lib.request({
292
+ hostname: parsed.hostname,
293
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
294
+ path: parsed.pathname + parsed.search,
295
+ method: "POST",
296
+ headers: {
297
+ "Content-Type": contentType,
298
+ "Content-Length": bytes.length,
299
+ },
300
+ }, (res) => {
301
+ let responseData = "";
302
+ res.on("data", (chunk) => (responseData += chunk));
303
+ res.on("end", () => {
304
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
305
+ try {
306
+ resolve(JSON.parse(responseData));
307
+ }
308
+ catch {
309
+ reject(new Error(`HTTP ${res.statusCode}: unparseable response`));
310
+ }
311
+ }
312
+ else {
313
+ reject(new Error(`HTTP ${res.statusCode}: ${responseData}`));
314
+ }
315
+ });
316
+ });
317
+ req.on("error", reject);
318
+ req.write(bytes);
319
+ req.end();
320
+ });
321
+ }
322
+ // Short leash for the tiny coverage pings (sessionStart / sessionSkip): they
323
+ // carry a few bytes and must never stall a hook. 3s is generous for a ping
324
+ // and a hard bound for the shutdown clock.
325
+ const PING_TIMEOUT_MS = 3000;
219
326
  async function readStdin() {
220
327
  return new Promise((resolve, reject) => {
221
328
  let data = "";
@@ -241,6 +348,12 @@ async function main() {
241
348
  function makeUploadDeps() {
242
349
  return {
243
350
  post: postJson,
351
+ // Direct-to-storage flow for big transcripts (see utils.ts
352
+ // DIRECT_UPLOAD_THRESHOLD_CHARS): fetch a one-time upload URL, then
353
+ // POST the raw bytes to it. Keeps ~7MB+ transcripts out of the
354
+ // backend's 64MB captureSession isolate.
355
+ requestJson: postJsonForResponse,
356
+ uploadBytes: postBytes,
244
357
  readFileSync: fs.readFileSync,
245
358
  unlinkSync: fs.unlinkSync,
246
359
  // Persist a bumped attempt count on failure so a later sweep retries
@@ -343,14 +456,12 @@ async function main() {
343
456
  await runSessionStart(hookInput);
344
457
  return;
345
458
  }
346
- // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets the
347
- // discovery + auto-backup + limit hooks wired on their next SessionEnd, without
348
- // re-connecting. Cheap (settings.json read, writes only when missing), so it
349
- // stays inline; the costly transcript/git work is what moves to the child.
350
- (0, utils_1.ensureDiscoveryHookInstalled)();
351
- (0, utils_1.ensureBackupHookInstalled)();
352
- (0, utils_1.ensureLimitHookInstalled)();
353
- (0, utils_1.ensureSessionStartHookInstalled)();
459
+ // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
460
+ // every codiedev hook — INCLUDING SessionEnd→capture, which previously had
461
+ // no self-heal and could stay stripped forever while SessionStart kept
462
+ // pinging (the Sonifi "starts arrive, captures never" loss) — re-wired on
463
+ // their next Stop/SessionEnd. One read + one write, only when missing.
464
+ (0, utils_1.ensureAllCodiedevHooksInstalled)();
354
465
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
355
466
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
356
467
  ? workspace_roots[0]
@@ -407,9 +518,14 @@ async function main() {
407
518
  // finished and the upload went through, but CC had already given up waiting.
408
519
  const config = (0, utils_1.readConfig)();
409
520
  if (!config) {
521
+ // No config = no token; nothing can be sent, not even a skip ping.
410
522
  process.exit(0);
411
523
  }
412
524
  if (!transcript_path) {
525
+ // Nothing to capture — but say so, instead of leaving a start ping with
526
+ // no matching capture (silent coverage loss). One tiny POST; the rare
527
+ // no-transcript case has no other work on the shutdown clock.
528
+ await postSessionSkip(config, session_id, "no-transcript-path");
413
529
  process.exit(0);
414
530
  }
415
531
  try {
@@ -448,8 +564,10 @@ async function runCaptureFinish(input) {
448
564
  if (!config)
449
565
  return;
450
566
  const { session_id, transcript_path, cwd, workspace_roots } = input;
451
- if (!transcript_path)
567
+ if (!transcript_path) {
568
+ await postSessionSkip(config, session_id, "no-transcript-path");
452
569
  return;
570
+ }
453
571
  // Log that this child has started processing — the first diagnostic proof
454
572
  // that the detached capture-finish child actually ran (visible in doctor).
455
573
  (0, captureLog_1.appendCaptureLog)({ stage: "capture-finish", sessionId: session_id, outcome: "start" });
@@ -474,7 +592,18 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
474
592
  try {
475
593
  transcriptContent = fs.readFileSync(transcript_path, "utf8");
476
594
  }
477
- catch {
595
+ catch (err) {
596
+ // ENOENT → "no-transcript" (phantom desktop-app helper session, excluded
597
+ // from coverage server-side); anything else → "transcript-unreadable:<code>"
598
+ // (real transcript, read failed — a genuine, diagnosable loss).
599
+ const reason = (0, utils_1.transcriptReadSkipReason)(err);
600
+ (0, captureLog_1.appendCaptureLog)({
601
+ stage: "capture-finish",
602
+ sessionId: session_id,
603
+ outcome: "skip",
604
+ detail: reason,
605
+ });
606
+ await postSessionSkip(config, session_id, reason);
478
607
  return;
479
608
  }
480
609
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
@@ -593,6 +722,14 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
593
722
  // Retry any earlier capture jobs that failed to upload (transient network
594
723
  // blips), excluding the one we just dispatched. Best-effort, detached.
595
724
  // Skip the sweep on deferred path — the deferred child will handle uploading.
725
+ //
726
+ // Orphan pings are COLLECTED and awaited below: this child process calls
727
+ // process.exit(0) as soon as runCaptureFinish resolves, so a
728
+ // fire-and-forget POST here would be killed mid-flight — after the
729
+ // orphan file was already unlinked, i.e. the loss would stay invisible
730
+ // forever. The await is off the shutdown clock (detached child) and
731
+ // bounded by PING_TIMEOUT_MS.
732
+ const orphanPings = [];
596
733
  (0, utils_1.sweepPendingCaptures)({
597
734
  dir: os.tmpdir(),
598
735
  readdirSync: fs.readdirSync,
@@ -603,7 +740,15 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
603
740
  exclude: tmpFile,
604
741
  openSync: fs.openSync,
605
742
  closeSync: fs.closeSync,
743
+ // Orphaned finish-inputs (capture-finish child died before writing an
744
+ // upload job): unrecoverable payload, but report the loss so it lands
745
+ // as a skip-reason instead of a silent coverage hole.
746
+ unlinkSync: fs.unlinkSync,
747
+ reportOrphan: (sid) => {
748
+ orphanPings.push(postSessionSkip(config, sid, "finish-orphaned"));
749
+ },
606
750
  });
751
+ await Promise.all(orphanPings);
607
752
  }
608
753
  else {
609
754
  // Deferred: write the job file (newer turns overwrite = supersede), then
package/dist/utils.d.ts CHANGED
@@ -81,6 +81,7 @@ export declare function installHook(): void;
81
81
  export declare function ensureDiscoveryHookInstalled(): void;
82
82
  export declare function ensureBackupHookInstalled(): void;
83
83
  export declare function ensureLimitHookInstalled(): void;
84
+ export declare function ensureAllCodiedevHooksInstalled(): void;
84
85
  export declare function ensureSessionStartHookInstalled(): void;
85
86
  export declare function installClaudeCodeInstructions(): void;
86
87
  export declare function installCodexInstructions(): void;
@@ -206,8 +207,11 @@ interface SweepDeps {
206
207
  openSync?: typeof fs.openSync;
207
208
  closeSync?: typeof fs.closeSync;
208
209
  pruneState?: () => void;
210
+ unlinkSync?: (p: string) => void;
211
+ reportOrphan?: (sessionId: string | undefined) => void;
209
212
  }
210
213
  export declare const CLAIM_STALE_MS: number;
214
+ export declare const FINISH_ORPHAN_AGE_MS: number;
211
215
  export declare function sweepPendingCaptures(deps: SweepDeps): void;
212
216
  export declare const CAPTURE_THROTTLE_MS: number;
213
217
  export declare const SWEEP_MIN_AGE_MS: number;
@@ -244,8 +248,11 @@ export declare function pruneCaptureStateDir(deps?: {
244
248
  };
245
249
  unlinkSync: (p: string) => void;
246
250
  }, dir?: string, now?: number): void;
251
+ export declare const DIRECT_UPLOAD_THRESHOLD_CHARS = 2000000;
247
252
  interface UploadDeps {
248
253
  post: (url: string, body: Record<string, unknown>, token: string) => Promise<void>;
254
+ requestJson?: (url: string, body: Record<string, unknown>, token: string) => Promise<any>;
255
+ uploadBytes?: (url: string, bytes: Buffer, contentType: string) => Promise<any>;
249
256
  readFileSync: typeof fs.readFileSync;
250
257
  unlinkSync: typeof fs.unlinkSync;
251
258
  writeFileSync?: typeof fs.writeFileSync;
@@ -282,4 +289,5 @@ interface DeferredUploadDeps extends UploadDeps {
282
289
  sleep?: (ms: number) => Promise<void>;
283
290
  }
284
291
  export declare function runDeferredUpload(jobFile: string, delayMs: number, deps: DeferredUploadDeps): Promise<void>;
292
+ export declare function transcriptReadSkipReason(err: unknown): string;
285
293
  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.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
36
+ exports.DIRECT_UPLOAD_THRESHOLD_CHARS = exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.FINISH_ORPHAN_AGE_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;
@@ -61,6 +61,7 @@ exports.installHook = installHook;
61
61
  exports.ensureDiscoveryHookInstalled = ensureDiscoveryHookInstalled;
62
62
  exports.ensureBackupHookInstalled = ensureBackupHookInstalled;
63
63
  exports.ensureLimitHookInstalled = ensureLimitHookInstalled;
64
+ exports.ensureAllCodiedevHooksInstalled = ensureAllCodiedevHooksInstalled;
64
65
  exports.ensureSessionStartHookInstalled = ensureSessionStartHookInstalled;
65
66
  exports.installClaudeCodeInstructions = installClaudeCodeInstructions;
66
67
  exports.installCodexInstructions = installCodexInstructions;
@@ -98,6 +99,7 @@ exports.writeLastCapture = writeLastCapture;
98
99
  exports.readLastCapture = readLastCapture;
99
100
  exports.runCaptureUpload = runCaptureUpload;
100
101
  exports.runDeferredUpload = runDeferredUpload;
102
+ exports.transcriptReadSkipReason = transcriptReadSkipReason;
101
103
  const fs = __importStar(require("fs"));
102
104
  const path = __importStar(require("path"));
103
105
  const os = __importStar(require("os"));
@@ -913,6 +915,61 @@ function ensureLimitHookInstalled() {
913
915
  }
914
916
  catch { /* best-effort */ }
915
917
  }
918
+ // Self-heal ALL codiedev hooks in one read → one write. This replaces calling
919
+ // the four per-hook ensure* functions in sequence, and fixes two real losses
920
+ // seen in the field (Sonifi, 2026-07):
921
+ // 1. The SessionEnd → capture hook — the one that actually uploads sessions
922
+ // — had NO self-heal at all: it was written once at `connect` and never
923
+ // repaired. Anything that stripped it (IT-managed dotfiles baseline, the
924
+ // race below) left SessionStart pinging forever while captures silently
925
+ // stopped — the exact "start pings arrive, captures never" signature.
926
+ // 2. Each per-hook ensure* did its own read-modify-write of settings.json
927
+ // with no locking; two concurrent Claude Code sessions repairing
928
+ // different hooks could lose each other's writes. One read + one write
929
+ // shrinks that window and removes the intra-process version entirely.
930
+ // The per-hook ensure* functions below are kept for tests/back-compat but are
931
+ // no longer called from the hook entrypoint.
932
+ function ensureAllCodiedevHooksInstalled() {
933
+ try {
934
+ if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
935
+ return; // not a Claude Code user
936
+ const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
937
+ const hooks = (settings.hooks ?? {});
938
+ const wanted = [
939
+ ["SessionEnd", "capture"],
940
+ ["UserPromptSubmit", "discover"],
941
+ ["Stop", "backup"],
942
+ ["StopFailure", "limit", "rate_limit"],
943
+ ["SessionStart", "session-start"],
944
+ ];
945
+ const missing = wanted.filter(([eventName]) => !hasCodiedevHook(hooks, eventName));
946
+ if (missing.length === 0)
947
+ return; // steady state: no write, no churn
948
+ settings.hooks = hooks;
949
+ for (const [eventName, subcommand, matcher] of missing) {
950
+ upsertCodiedevHook(hooks, eventName, subcommand, matcher);
951
+ }
952
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
953
+ }
954
+ catch {
955
+ // Best-effort — never block a session on self-heal.
956
+ }
957
+ }
958
+ function hasCodiedevHook(hooks, eventName) {
959
+ const entries = hooks[eventName] ?? [];
960
+ // A malformed (non-array) event value would make `.some` throw and — under
961
+ // ensureAll's single try/catch — abort EVERY heal, including the capture
962
+ // heal. Report "present" instead: leave the foreign/corrupt key alone and
963
+ // keep healing the other events (the per-hook fault isolation the old
964
+ // ensure* functions had).
965
+ if (!Array.isArray(entries))
966
+ return true;
967
+ return entries.some((hook) => {
968
+ const inner = hook.hooks;
969
+ return (Array.isArray(inner) &&
970
+ inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command)));
971
+ });
972
+ }
916
973
  // Self-heal: ensure the SessionStart → session-start hook is present without a
917
974
  // full `connect` re-run, mirroring ensureLimitHookInstalled. Called from the
918
975
  // capture hook so an upgraded-but-not-reconnected user starts emitting the
@@ -1733,6 +1790,11 @@ function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
1733
1790
  // A claimed job whose uploader hasn't finished after this long is presumed
1734
1791
  // crashed and gets renamed back to its canonical name for a later retry.
1735
1792
  exports.CLAIM_STALE_MS = 15 * 60 * 1000;
1793
+ // A finish-input file older than this is an orphan: its capture-finish child
1794
+ // normally consumes + unlinks it within seconds, so an hour-old file means the
1795
+ // child died (spawn failure, OOM/AV kill, machine sleep). Generous so we never
1796
+ // race a legitimately slow child.
1797
+ exports.FINISH_ORPHAN_AGE_MS = 60 * 60 * 1000;
1736
1798
  // Retry kept-on-failure capture jobs: re-dispatch each pending
1737
1799
  // `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
1738
1800
  // gives up at the cap). Best-effort — never throws into the hook.
@@ -1770,6 +1832,37 @@ function sweepPendingCaptures(deps) {
1770
1832
  // raced another sweeper or the uploader just finished — fine
1771
1833
  }
1772
1834
  }
1835
+ // Orphaned finish-inputs: the FIRST detached child (capture-finish) died
1836
+ // before it could build an upload job. That path has no retry queue — the
1837
+ // payload is gone — but the loss must not stay invisible: report the
1838
+ // sessionId (filename embeds it: codiedev-finish-<sid>-<pid>-<ts>.json) so
1839
+ // it lands as a skip-reason, then clean the file up.
1840
+ if (deps.unlinkSync) {
1841
+ for (const f of files) {
1842
+ const m = /^codiedev-finish-(.+)-\d+-\d+\.json$/.exec(f);
1843
+ if (!m)
1844
+ continue;
1845
+ const orphanPath = path.join(deps.dir, f);
1846
+ try {
1847
+ if (now - deps.statSync(orphanPath).mtimeMs < exports.FINISH_ORPHAN_AGE_MS)
1848
+ continue;
1849
+ deps.unlinkSync(orphanPath);
1850
+ // finishInputTmpFile falls back to the PID when the hook had no
1851
+ // session_id (codiedev-finish-<pid>-<pid>-<ts>.json). A pure-digit
1852
+ // capture is that fallback, not a session id — reporting it would
1853
+ // create a phantom session row server-side. Real session ids are
1854
+ // UUIDs and never all-digits.
1855
+ const sid = /^\d+$/.test(m[1]) ? undefined : m[1];
1856
+ try {
1857
+ deps.reportOrphan?.(sid);
1858
+ }
1859
+ catch { /* report is best-effort */ }
1860
+ }
1861
+ catch {
1862
+ // raced its owner or another sweeper — fine
1863
+ }
1864
+ }
1865
+ }
1773
1866
  const jobs = files
1774
1867
  .filter((f) => /^codiedev-capture-.*\.json$/.test(f))
1775
1868
  .map((f) => path.join(deps.dir, f))
@@ -1936,6 +2029,13 @@ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now
1936
2029
  // dir missing/unreadable — nothing to prune
1937
2030
  }
1938
2031
  }
2032
+ // Base64 length above which the transcript goes straight to storage instead
2033
+ // of inline in the captureSession JSON body. The backend's HTTP-action
2034
+ // isolate has a 64MB cap and inline base64 amplifies ~8x in memory (request
2035
+ // text + parsed JSON + decode output all resident) — ~7MB transcripts OOM it
2036
+ // (observed live 2026-07-02). 2M base64 chars ≈ 1.5MB raw: far below the
2037
+ // cliff, while per-turn snapshots (typically KBs) skip the extra roundtrips.
2038
+ exports.DIRECT_UPLOAD_THRESHOLD_CHARS = 2_000_000;
1939
2039
  // "Last session captured" breadcrumb. Written next to config.json on a
1940
2040
  // successful capture upload; read by `codiedev doctor` so a user can confirm
1941
2041
  // their most recent session actually landed (the single hardest thing to
@@ -1994,7 +2094,22 @@ async function runCaptureUpload(jobFile, deps) {
1994
2094
  }
1995
2095
  const sessionId = job.body?.sessionId;
1996
2096
  try {
1997
- await deps.post(job.url, job.body, job.token);
2097
+ // Big transcript + capable deps → direct-to-storage: fetch a one-time
2098
+ // upload URL, PUT the raw bytes, send captureSession the storageId. Any
2099
+ // step failing throws into the retry path below, same as a failed POST.
2100
+ let body = job.body;
2101
+ const transcript = body?.transcript;
2102
+ if (transcript &&
2103
+ transcript.length > exports.DIRECT_UPLOAD_THRESHOLD_CHARS &&
2104
+ deps.requestJson &&
2105
+ deps.uploadBytes) {
2106
+ const uploadUrlEndpoint = job.url.replace(/\/api\/captureSession$/, "/api/captureUploadUrl");
2107
+ const { uploadUrl } = await deps.requestJson(uploadUrlEndpoint, {}, job.token);
2108
+ const { storageId } = await deps.uploadBytes(uploadUrl, Buffer.from(transcript, "base64"), "application/json");
2109
+ body = { ...body, transcriptStorageId: storageId };
2110
+ delete body.transcript;
2111
+ }
2112
+ await deps.post(job.url, body, job.token);
1998
2113
  // success — drop a "last session captured" breadcrumb (before removing the
1999
2114
  // job) so doctor can confirm the most recent session actually landed.
2000
2115
  if (deps.recordSuccess) {
@@ -2046,10 +2161,30 @@ async function runCaptureUpload(jobFile, deps) {
2046
2161
  }
2047
2162
  catch { /* log is best-effort */ }
2048
2163
  }
2164
+ // Unlink FIRST: if the skip ping below hangs (no timeout on injected
2165
+ // post impls), a still-present job file would be reclaimed by the
2166
+ // 15-min stale-claim sweep and re-enter this give-up path forever.
2049
2167
  try {
2050
2168
  deps.unlinkSync(jobFile);
2051
2169
  }
2052
2170
  catch { /* gone */ }
2171
+ // Tell the server this capture is permanently lost and why. The full
2172
+ // transcript POST may be what's blocked (proxy/DLP body inspection) —
2173
+ // this tiny ping often still passes, turning an invisible loss into a
2174
+ // per-reason bucket on the admin Capture Health panel.
2175
+ if (sessionId) {
2176
+ try {
2177
+ const skipUrl = job.url.replace(/\/api\/captureSession$/, "/api/cli/sessionSkip");
2178
+ if (skipUrl !== job.url) {
2179
+ await deps.post(skipUrl, {
2180
+ sessionId,
2181
+ reason: expired ? "upload-gave-up:ttl" : "upload-gave-up:attempts",
2182
+ at: now,
2183
+ }, job.token);
2184
+ }
2185
+ }
2186
+ catch { /* best-effort — the ping may be blocked too */ }
2187
+ }
2053
2188
  return;
2054
2189
  }
2055
2190
  // Log the error before persisting the retry bump.
@@ -2113,3 +2248,15 @@ async function runDeferredUpload(jobFile, delayMs, deps) {
2113
2248
  }
2114
2249
  await runCaptureUpload(claimed, deps);
2115
2250
  }
2251
+ // Classify a transcript read failure at capture time. ENOENT = the transcript
2252
+ // never existed: a desktop-app phantom helper session (SessionStart/End hooks
2253
+ // fire, no conversation ever happens), not lost work — the backend excludes
2254
+ // "no-transcript" from coverage. Anything else means a real transcript was
2255
+ // there but the read failed: a genuine loss, tagged with the errno so it stays
2256
+ // diagnosable from the server side.
2257
+ function transcriptReadSkipReason(err) {
2258
+ const code = err?.code;
2259
+ if (code === "ENOENT")
2260
+ return "no-transcript";
2261
+ return `transcript-unreadable:${code || "unknown"}`;
2262
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
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",