codiedev 0.9.23 → 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
@@ -241,6 +241,84 @@ timeoutMs) {
241
241
  req.end();
242
242
  });
243
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
+ }
244
322
  // Short leash for the tiny coverage pings (sessionStart / sessionSkip): they
245
323
  // carry a few bytes and must never stall a hook. 3s is generous for a ping
246
324
  // and a hard bound for the shutdown clock.
@@ -270,6 +348,12 @@ async function main() {
270
348
  function makeUploadDeps() {
271
349
  return {
272
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,
273
357
  readFileSync: fs.readFileSync,
274
358
  unlinkSync: fs.unlinkSync,
275
359
  // Persist a bumped attempt count on failure so a later sweep retries
@@ -508,17 +592,18 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
508
592
  try {
509
593
  transcriptContent = fs.readFileSync(transcript_path, "utf8");
510
594
  }
511
- catch {
512
- // Previously the one fully-silent gap in the detached child: no log, no
513
- // server signal. Diagnose it — deleted/rotated transcript, permissions,
514
- // unmounted path.
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);
515
600
  (0, captureLog_1.appendCaptureLog)({
516
601
  stage: "capture-finish",
517
602
  sessionId: session_id,
518
603
  outcome: "skip",
519
- detail: "transcript-unreadable",
604
+ detail: reason,
520
605
  });
521
- await postSessionSkip(config, session_id, "transcript-unreadable");
606
+ await postSessionSkip(config, session_id, reason);
522
607
  return;
523
608
  }
524
609
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
package/dist/utils.d.ts CHANGED
@@ -248,8 +248,11 @@ export declare function pruneCaptureStateDir(deps?: {
248
248
  };
249
249
  unlinkSync: (p: string) => void;
250
250
  }, dir?: string, now?: number): void;
251
+ export declare const DIRECT_UPLOAD_THRESHOLD_CHARS = 2000000;
251
252
  interface UploadDeps {
252
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>;
253
256
  readFileSync: typeof fs.readFileSync;
254
257
  unlinkSync: typeof fs.unlinkSync;
255
258
  writeFileSync?: typeof fs.writeFileSync;
@@ -286,4 +289,5 @@ interface DeferredUploadDeps extends UploadDeps {
286
289
  sleep?: (ms: number) => Promise<void>;
287
290
  }
288
291
  export declare function runDeferredUpload(jobFile: string, delayMs: number, deps: DeferredUploadDeps): Promise<void>;
292
+ export declare function transcriptReadSkipReason(err: unknown): string;
289
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.FINISH_ORPHAN_AGE_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;
@@ -99,6 +99,7 @@ exports.writeLastCapture = writeLastCapture;
99
99
  exports.readLastCapture = readLastCapture;
100
100
  exports.runCaptureUpload = runCaptureUpload;
101
101
  exports.runDeferredUpload = runDeferredUpload;
102
+ exports.transcriptReadSkipReason = transcriptReadSkipReason;
102
103
  const fs = __importStar(require("fs"));
103
104
  const path = __importStar(require("path"));
104
105
  const os = __importStar(require("os"));
@@ -2028,6 +2029,13 @@ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now
2028
2029
  // dir missing/unreadable — nothing to prune
2029
2030
  }
2030
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;
2031
2039
  // "Last session captured" breadcrumb. Written next to config.json on a
2032
2040
  // successful capture upload; read by `codiedev doctor` so a user can confirm
2033
2041
  // their most recent session actually landed (the single hardest thing to
@@ -2086,7 +2094,22 @@ async function runCaptureUpload(jobFile, deps) {
2086
2094
  }
2087
2095
  const sessionId = job.body?.sessionId;
2088
2096
  try {
2089
- 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);
2090
2113
  // success — drop a "last session captured" breadcrumb (before removing the
2091
2114
  // job) so doctor can confirm the most recent session actually landed.
2092
2115
  if (deps.recordSuccess) {
@@ -2225,3 +2248,15 @@ async function runDeferredUpload(jobFile, delayMs, deps) {
2225
2248
  }
2226
2249
  await runCaptureUpload(claimed, deps);
2227
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.23",
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",