codiedev 0.9.23 → 0.9.25

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.
@@ -165,6 +165,9 @@ async function runDoctor(_args) {
165
165
  catch {
166
166
  // malformed backendUrl in config — keep the generic placeholder
167
167
  }
168
+ // Live tracked-repo list fetched from validateToken below. Null until (and
169
+ // unless) the backend answers; the checks fall back to `config.repos`.
170
+ let liveRepos = null;
168
171
  try {
169
172
  const res = await fetch(`${config.backendUrl}/api/cli/validateToken`, {
170
173
  method: "POST",
@@ -180,6 +183,21 @@ async function runDoctor(_args) {
180
183
  status: "pass",
181
184
  detail: backendHost,
182
185
  });
186
+ // validateToken returns the company's CURRENT tracked repos. Capture
187
+ // them: the repo-resolution checks below must reflect what the SERVER
188
+ // will resolve at capture time (repoResolver.ts resolves against the
189
+ // live list), not the possibly-stale `config.repos` frozen at the last
190
+ // `connect`. A repo linked in the portal after that connect otherwise
191
+ // reports a false "isn't a tracked repo — link it in the portal" here
192
+ // even though sessions/MRs for it attribute fine server-side.
193
+ try {
194
+ const data = (await res.json());
195
+ if (Array.isArray(data.repos))
196
+ liveRepos = data.repos;
197
+ }
198
+ catch {
199
+ liveRepos = null; // malformed body → fall back to the cached list
200
+ }
183
201
  }
184
202
  else {
185
203
  checks.push({
@@ -219,13 +237,46 @@ async function runDoctor(_args) {
219
237
  detail: err.message,
220
238
  });
221
239
  }
222
- // 4. Tracked repos
223
- const repoCount = config.repos?.length ?? 0;
240
+ // 4. Tracked repos. Prefer the live list from validateToken over the cache:
241
+ // a repo linked after the last `connect` is tracked server-side but absent
242
+ // from `config.repos`, and resolving against the stale cache is exactly what
243
+ // produced the false "isn't a tracked repo — link it in the portal" warning.
244
+ // Treat an EMPTY live list as "no usable live data", NOT an authoritative
245
+ // zero: a transient permission glitch / backend hiccup can return `repos: []`,
246
+ // and both driving the checks from it AND healing the cache to empty would be
247
+ // wrong — the write would durably break every offline doctor run (and the
248
+ // capture hook's providedRepoId) until the next successful connect. So when
249
+ // live is empty, fall back to the cache and skip the write, exactly like
250
+ // backend-unreachable. A genuinely repo-less company also has an empty cache,
251
+ // so it still reports "0 repos" correctly.
252
+ const trackedRepos = liveRepos !== null && liveRepos.length > 0 ? liveRepos : (config.repos ?? []);
253
+ // Detect drift by CONTENT, not count: a same-size swap (one repo removed +
254
+ // another added between connects) or a repo whose path/URL changed while its
255
+ // id stayed stable (GitLab move/rename — exactly the SONIFI migration case)
256
+ // both leave the count unchanged. A length-only check would skip the heal and
257
+ // leave the capture hook's cache — and its advisory providedRepoId — stale.
258
+ const canonRepos = (rs) => JSON.stringify([...rs].map((r) => [r.repoId, r.url, r.fullName]).sort());
259
+ const cacheRefreshed = liveRepos !== null &&
260
+ liveRepos.length > 0 &&
261
+ canonRepos(liveRepos) !== canonRepos(config.repos ?? []);
262
+ // Heal the on-disk cache so the capture hook (which reads `config.repos` for
263
+ // the advisory providedRepoId) stops trailing the portal too. Best-effort:
264
+ // doctor must never fail because the config couldn't be rewritten.
265
+ if (cacheRefreshed && liveRepos) {
266
+ try {
267
+ (0, utils_1.writeConfig)({ ...config, repos: liveRepos, lastSynced: new Date().toISOString() });
268
+ }
269
+ catch {
270
+ // read-only home / permission issue — the in-memory list still drives
271
+ // the checks below; the user just re-syncs on the next `connect`.
272
+ }
273
+ }
274
+ const repoCount = trackedRepos.length;
224
275
  checks.push({
225
276
  name: "Tracked repos",
226
277
  status: repoCount > 0 ? "pass" : "warn",
227
278
  detail: repoCount > 0
228
- ? `${repoCount} repo${repoCount === 1 ? "" : "s"}`
279
+ ? `${repoCount} repo${repoCount === 1 ? "" : "s"}${cacheRefreshed ? " (refreshed from portal)" : ""}`
229
280
  : "0 — link repos in the portal so sessions get captured",
230
281
  });
231
282
  // 4b. Capture readiness — the part that actually decides whether a session
@@ -234,7 +285,7 @@ async function runDoctor(_args) {
234
285
  // last one?" — the blind spot behind lean-container / submodule / no-remote
235
286
  // zeros.
236
287
  const cwd = process.cwd();
237
- const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd, config.repos ?? [], {
288
+ const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd, trackedRepos, {
238
289
  exec: (cmd, dir) => (0, child_process_1.execSync)(cmd, { cwd: dir, stdio: ["pipe", "pipe", "pipe"] }).toString(),
239
290
  getRemoteUrl: utils_1.getGitRemoteUrl,
240
291
  matchRepo: utils_1.matchRepo,
@@ -247,7 +298,7 @@ async function runDoctor(_args) {
247
298
  // instead of flatly warning "not a work tree / no remote" — the exact output
248
299
  // that made working workspace-root setups read as broken.
249
300
  const workspaceScan = readiness.gitOnPath && !readiness.insideWorkTree
250
- ? (0, captureReadiness_1.assessWorkspaceRepos)(cwd, config.repos ?? [], {
301
+ ? (0, captureReadiness_1.assessWorkspaceRepos)(cwd, trackedRepos, {
251
302
  listChildDirs: (dir) => fs
252
303
  .readdirSync(dir, { withFileTypes: true })
253
304
  .filter((e) => e.isDirectory())
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
@@ -67,6 +67,8 @@ export interface CapturePayloadInput {
67
67
  }
68
68
  export declare function buildCapturePayload(input: CapturePayloadInput): Record<string, unknown>;
69
69
  export declare function hashToken(token: string): string;
70
+ export declare function extractHookNodePath(cmd: string | undefined): string | undefined;
71
+ export declare function resetStableNodeCacheForTests(): void;
70
72
  export declare function claudeCodeInstalled(): boolean;
71
73
  export declare function codexInstalled(): boolean;
72
74
  export declare function cursorInstalled(): boolean;
@@ -248,8 +250,11 @@ export declare function pruneCaptureStateDir(deps?: {
248
250
  };
249
251
  unlinkSync: (p: string) => void;
250
252
  }, dir?: string, now?: number): void;
253
+ export declare const DIRECT_UPLOAD_THRESHOLD_CHARS = 2000000;
251
254
  interface UploadDeps {
252
255
  post: (url: string, body: Record<string, unknown>, token: string) => Promise<void>;
256
+ requestJson?: (url: string, body: Record<string, unknown>, token: string) => Promise<any>;
257
+ uploadBytes?: (url: string, bytes: Buffer, contentType: string) => Promise<any>;
253
258
  readFileSync: typeof fs.readFileSync;
254
259
  unlinkSync: typeof fs.unlinkSync;
255
260
  writeFileSync?: typeof fs.writeFileSync;
@@ -286,4 +291,5 @@ interface DeferredUploadDeps extends UploadDeps {
286
291
  sleep?: (ms: number) => Promise<void>;
287
292
  }
288
293
  export declare function runDeferredUpload(jobFile: string, delayMs: number, deps: DeferredUploadDeps): Promise<void>;
294
+ export declare function transcriptReadSkipReason(err: unknown): string;
289
295
  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;
@@ -52,6 +52,8 @@ exports.getGitBranch = getGitBranch;
52
52
  exports.getGitHeadSha = getGitHeadSha;
53
53
  exports.buildCapturePayload = buildCapturePayload;
54
54
  exports.hashToken = hashToken;
55
+ exports.extractHookNodePath = extractHookNodePath;
56
+ exports.resetStableNodeCacheForTests = resetStableNodeCacheForTests;
55
57
  exports.claudeCodeInstalled = claudeCodeInstalled;
56
58
  exports.codexInstalled = codexInstalled;
57
59
  exports.cursorInstalled = cursorInstalled;
@@ -99,6 +101,7 @@ exports.writeLastCapture = writeLastCapture;
99
101
  exports.readLastCapture = readLastCapture;
100
102
  exports.runCaptureUpload = runCaptureUpload;
101
103
  exports.runDeferredUpload = runDeferredUpload;
104
+ exports.transcriptReadSkipReason = transcriptReadSkipReason;
102
105
  const fs = __importStar(require("fs"));
103
106
  const path = __importStar(require("path"));
104
107
  const os = __importStar(require("os"));
@@ -732,7 +735,94 @@ const VSCODE_USER_MCP_PATH = path.join(VSCODE_USER_DIR, "mcp.json");
732
735
  function shellQuote(s) {
733
736
  return `'${s.replace(/'/g, `'\\''`)}'`;
734
737
  }
735
- function resolveHookCommand(subcommand) {
738
+ // Pull the `<node>` prefix out of a codiedev hook command we previously wrote.
739
+ // Commands take the form `'<node>' '<hookScript>' <subcommand>` (each path
740
+ // single-quoted by shellQuote). Returns undefined for the legacy
741
+ // `npx codiedev-hook <subcommand>` form (no explicit node path) or if the
742
+ // command doesn't start with a quoted token. Exported for unit testing.
743
+ function extractHookNodePath(cmd) {
744
+ if (!cmd)
745
+ return undefined;
746
+ // Match the first single-quoted token, honoring shellQuote's `'\''` escape.
747
+ const m = cmd.match(/^'((?:[^']|'\\'')*)'/);
748
+ if (!m)
749
+ return undefined;
750
+ return m[1].replace(/'\\''/g, "'");
751
+ }
752
+ // `process.execPath` is the *resolved* path to the running node binary, so under
753
+ // Homebrew it's the version-specific Cellar path (e.g.
754
+ // /opt/homebrew/Cellar/node/26.4.0/bin/node). Baking that into the hook means a
755
+ // later `brew upgrade node` moves the Cellar dir and the hook path 404s. Prefer
756
+ // a stable alias for the *same* binary:
757
+ // 1. A node path from a hook we previously wrote that still resolves — usually
758
+ // a symlink the user deliberately pinned (e.g. /opt/homebrew/bin/node). A
759
+ // re-`connect`/self-heal then honors it instead of clobbering it.
760
+ // 2. Otherwise, `node` as found on PATH when it points at the binary we're
761
+ // running (the Homebrew symlink, an nvm `current` alias, etc.).
762
+ // 3. Last resort: the resolved `process.execPath`. Always valid right now, and
763
+ // still an absolute path a GUI-launched agent can execute without PATH.
764
+ function realpathOrSelf(p) {
765
+ try {
766
+ return fs.realpathSync(p);
767
+ }
768
+ catch {
769
+ return p;
770
+ }
771
+ }
772
+ // A stable `node` alias on PATH (Homebrew symlink, nvm `current`, …) that points
773
+ // at the very binary we're running, or undefined if none. `installHook` writes
774
+ // five hooks per run, so memoize: `command -v node` is a synchronous subprocess
775
+ // spawn and its result can't change within a process. `null` = "looked, none".
776
+ let cachedStableNodeOnPath;
777
+ // Test-only: the memo lives for the process lifetime, so suites that stage
778
+ // their own PATH/symlink fixtures must clear it between tests.
779
+ function resetStableNodeCacheForTests() {
780
+ cachedStableNodeOnPath = undefined;
781
+ }
782
+ function stableNodeOnPath() {
783
+ if (cachedStableNodeOnPath !== undefined) {
784
+ return cachedStableNodeOnPath ?? undefined;
785
+ }
786
+ const execReal = realpathOrSelf(process.execPath);
787
+ try {
788
+ const onPath = (0, child_process_1.execSync)("command -v node", {
789
+ stdio: ["pipe", "pipe", "pipe"],
790
+ })
791
+ .toString()
792
+ .trim();
793
+ if (onPath && onPath !== process.execPath && realpathOrSelf(onPath) === execReal) {
794
+ cachedStableNodeOnPath = onPath;
795
+ return onPath;
796
+ }
797
+ }
798
+ catch {
799
+ // No node on PATH (e.g. GUI-launched agent) — fall through.
800
+ }
801
+ cachedStableNodeOnPath = null;
802
+ return undefined;
803
+ }
804
+ function resolveHookNodePath(preferredNodePath) {
805
+ const execReal = realpathOrSelf(process.execPath);
806
+ // 1. Honor a still-working node path from the existing hook — but only when
807
+ // it's a *different* string than the resolved realpath. If the existing
808
+ // path already IS `process.execPath` it's the version-specific realpath we
809
+ // want to upgrade away from (that's the Homebrew bug), so fall through to
810
+ // hunt for a stable alias in step 2.
811
+ if (preferredNodePath &&
812
+ preferredNodePath !== process.execPath &&
813
+ fs.existsSync(preferredNodePath) &&
814
+ realpathOrSelf(preferredNodePath) === execReal) {
815
+ return preferredNodePath;
816
+ }
817
+ // 2. Prefer a stable `node` on PATH over the version-specific realpath.
818
+ const onPath = stableNodeOnPath();
819
+ if (onPath)
820
+ return onPath;
821
+ // 3. Last resort: the resolved absolute path. Always valid right now, and
822
+ // still an absolute path a GUI-launched agent can execute without PATH.
823
+ return process.execPath;
824
+ }
825
+ function resolveHookCommand(subcommand, preferredNodePath) {
736
826
  try {
737
827
  const cliEntry = process.argv[1];
738
828
  if (cliEntry) {
@@ -740,7 +830,8 @@ function resolveHookCommand(subcommand) {
740
830
  const distDir = path.dirname(realCli);
741
831
  const hookScript = path.join(distDir, "hook.js");
742
832
  if (fs.existsSync(hookScript)) {
743
- return `${shellQuote(process.execPath)} ${shellQuote(hookScript)} ${subcommand}`;
833
+ const nodePath = resolveHookNodePath(preferredNodePath);
834
+ return `${shellQuote(nodePath)} ${shellQuote(hookScript)} ${subcommand}`;
744
835
  }
745
836
  }
746
837
  }
@@ -797,6 +888,22 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
797
888
  if (!hooks[eventName])
798
889
  hooks[eventName] = [];
799
890
  const existing = hooks[eventName];
891
+ // Before stripping the old codiedev entry, remember the node path it used so a
892
+ // re-run/upgrade can honor a stable symlink the user pinned instead of
893
+ // overwriting it with the version-specific `process.execPath` (see
894
+ // resolveHookNodePath). Done in an explicit scan rather than as a side effect
895
+ // of the filter predicate below, which stays a pure predicate.
896
+ let preservedNodePath;
897
+ for (const hook of existing) {
898
+ const inner = hook.hooks;
899
+ if (!Array.isArray(inner))
900
+ continue;
901
+ const codiedevInner = inner.find((h) => (0, detection_1.isCodiedevHookCommand)(h.command));
902
+ if (codiedevInner) {
903
+ preservedNodePath = extractHookNodePath(codiedevInner.command);
904
+ break;
905
+ }
906
+ }
800
907
  const filtered = existing.filter((hook) => {
801
908
  const inner = hook.hooks;
802
909
  if (Array.isArray(inner)) {
@@ -806,7 +913,7 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
806
913
  });
807
914
  filtered.push({
808
915
  matcher,
809
- hooks: [{ type: "command", command: resolveHookCommand(subcommand) }],
916
+ hooks: [{ type: "command", command: resolveHookCommand(subcommand, preservedNodePath) }],
810
917
  });
811
918
  hooks[eventName] = filtered;
812
919
  }
@@ -2028,6 +2135,13 @@ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now
2028
2135
  // dir missing/unreadable — nothing to prune
2029
2136
  }
2030
2137
  }
2138
+ // Base64 length above which the transcript goes straight to storage instead
2139
+ // of inline in the captureSession JSON body. The backend's HTTP-action
2140
+ // isolate has a 64MB cap and inline base64 amplifies ~8x in memory (request
2141
+ // text + parsed JSON + decode output all resident) — ~7MB transcripts OOM it
2142
+ // (observed live 2026-07-02). 2M base64 chars ≈ 1.5MB raw: far below the
2143
+ // cliff, while per-turn snapshots (typically KBs) skip the extra roundtrips.
2144
+ exports.DIRECT_UPLOAD_THRESHOLD_CHARS = 2_000_000;
2031
2145
  // "Last session captured" breadcrumb. Written next to config.json on a
2032
2146
  // successful capture upload; read by `codiedev doctor` so a user can confirm
2033
2147
  // their most recent session actually landed (the single hardest thing to
@@ -2086,7 +2200,22 @@ async function runCaptureUpload(jobFile, deps) {
2086
2200
  }
2087
2201
  const sessionId = job.body?.sessionId;
2088
2202
  try {
2089
- await deps.post(job.url, job.body, job.token);
2203
+ // Big transcript + capable deps → direct-to-storage: fetch a one-time
2204
+ // upload URL, PUT the raw bytes, send captureSession the storageId. Any
2205
+ // step failing throws into the retry path below, same as a failed POST.
2206
+ let body = job.body;
2207
+ const transcript = body?.transcript;
2208
+ if (transcript &&
2209
+ transcript.length > exports.DIRECT_UPLOAD_THRESHOLD_CHARS &&
2210
+ deps.requestJson &&
2211
+ deps.uploadBytes) {
2212
+ const uploadUrlEndpoint = job.url.replace(/\/api\/captureSession$/, "/api/captureUploadUrl");
2213
+ const { uploadUrl } = await deps.requestJson(uploadUrlEndpoint, {}, job.token);
2214
+ const { storageId } = await deps.uploadBytes(uploadUrl, Buffer.from(transcript, "base64"), "application/json");
2215
+ body = { ...body, transcriptStorageId: storageId };
2216
+ delete body.transcript;
2217
+ }
2218
+ await deps.post(job.url, body, job.token);
2090
2219
  // success — drop a "last session captured" breadcrumb (before removing the
2091
2220
  // job) so doctor can confirm the most recent session actually landed.
2092
2221
  if (deps.recordSuccess) {
@@ -2225,3 +2354,15 @@ async function runDeferredUpload(jobFile, delayMs, deps) {
2225
2354
  }
2226
2355
  await runCaptureUpload(claimed, deps);
2227
2356
  }
2357
+ // Classify a transcript read failure at capture time. ENOENT = the transcript
2358
+ // never existed: a desktop-app phantom helper session (SessionStart/End hooks
2359
+ // fire, no conversation ever happens), not lost work — the backend excludes
2360
+ // "no-transcript" from coverage. Anything else means a real transcript was
2361
+ // there but the read failed: a genuine loss, tagged with the errno so it stays
2362
+ // diagnosable from the server side.
2363
+ function transcriptReadSkipReason(err) {
2364
+ const code = err?.code;
2365
+ if (code === "ENOENT")
2366
+ return "no-transcript";
2367
+ return `transcript-unreadable:${code || "unknown"}`;
2368
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.23",
3
+ "version": "0.9.25",
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",