codiedev 0.9.22 → 0.9.23

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,19 @@ 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
+ // Short leash for the tiny coverage pings (sessionStart / sessionSkip): they
245
+ // carry a few bytes and must never stall a hook. 3s is generous for a ping
246
+ // and a hard bound for the shutdown clock.
247
+ const PING_TIMEOUT_MS = 3000;
219
248
  async function readStdin() {
220
249
  return new Promise((resolve, reject) => {
221
250
  let data = "";
@@ -343,14 +372,12 @@ async function main() {
343
372
  await runSessionStart(hookInput);
344
373
  return;
345
374
  }
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)();
375
+ // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
376
+ // every codiedev hook — INCLUDING SessionEnd→capture, which previously had
377
+ // no self-heal and could stay stripped forever while SessionStart kept
378
+ // pinging (the Sonifi "starts arrive, captures never" loss) — re-wired on
379
+ // their next Stop/SessionEnd. One read + one write, only when missing.
380
+ (0, utils_1.ensureAllCodiedevHooksInstalled)();
354
381
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
355
382
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
356
383
  ? workspace_roots[0]
@@ -407,9 +434,14 @@ async function main() {
407
434
  // finished and the upload went through, but CC had already given up waiting.
408
435
  const config = (0, utils_1.readConfig)();
409
436
  if (!config) {
437
+ // No config = no token; nothing can be sent, not even a skip ping.
410
438
  process.exit(0);
411
439
  }
412
440
  if (!transcript_path) {
441
+ // Nothing to capture — but say so, instead of leaving a start ping with
442
+ // no matching capture (silent coverage loss). One tiny POST; the rare
443
+ // no-transcript case has no other work on the shutdown clock.
444
+ await postSessionSkip(config, session_id, "no-transcript-path");
413
445
  process.exit(0);
414
446
  }
415
447
  try {
@@ -448,8 +480,10 @@ async function runCaptureFinish(input) {
448
480
  if (!config)
449
481
  return;
450
482
  const { session_id, transcript_path, cwd, workspace_roots } = input;
451
- if (!transcript_path)
483
+ if (!transcript_path) {
484
+ await postSessionSkip(config, session_id, "no-transcript-path");
452
485
  return;
486
+ }
453
487
  // Log that this child has started processing — the first diagnostic proof
454
488
  // that the detached capture-finish child actually ran (visible in doctor).
455
489
  (0, captureLog_1.appendCaptureLog)({ stage: "capture-finish", sessionId: session_id, outcome: "start" });
@@ -475,6 +509,16 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
475
509
  transcriptContent = fs.readFileSync(transcript_path, "utf8");
476
510
  }
477
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.
515
+ (0, captureLog_1.appendCaptureLog)({
516
+ stage: "capture-finish",
517
+ sessionId: session_id,
518
+ outcome: "skip",
519
+ detail: "transcript-unreadable",
520
+ });
521
+ await postSessionSkip(config, session_id, "transcript-unreadable");
478
522
  return;
479
523
  }
480
524
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
@@ -593,6 +637,14 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
593
637
  // Retry any earlier capture jobs that failed to upload (transient network
594
638
  // blips), excluding the one we just dispatched. Best-effort, detached.
595
639
  // Skip the sweep on deferred path — the deferred child will handle uploading.
640
+ //
641
+ // Orphan pings are COLLECTED and awaited below: this child process calls
642
+ // process.exit(0) as soon as runCaptureFinish resolves, so a
643
+ // fire-and-forget POST here would be killed mid-flight — after the
644
+ // orphan file was already unlinked, i.e. the loss would stay invisible
645
+ // forever. The await is off the shutdown clock (detached child) and
646
+ // bounded by PING_TIMEOUT_MS.
647
+ const orphanPings = [];
596
648
  (0, utils_1.sweepPendingCaptures)({
597
649
  dir: os.tmpdir(),
598
650
  readdirSync: fs.readdirSync,
@@ -603,7 +655,15 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
603
655
  exclude: tmpFile,
604
656
  openSync: fs.openSync,
605
657
  closeSync: fs.closeSync,
658
+ // Orphaned finish-inputs (capture-finish child died before writing an
659
+ // upload job): unrecoverable payload, but report the loss so it lands
660
+ // as a skip-reason instead of a silent coverage hole.
661
+ unlinkSync: fs.unlinkSync,
662
+ reportOrphan: (sid) => {
663
+ orphanPings.push(postSessionSkip(config, sid, "finish-orphaned"));
664
+ },
606
665
  });
666
+ await Promise.all(orphanPings);
607
667
  }
608
668
  else {
609
669
  // 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;
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.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;
@@ -913,6 +914,61 @@ function ensureLimitHookInstalled() {
913
914
  }
914
915
  catch { /* best-effort */ }
915
916
  }
917
+ // Self-heal ALL codiedev hooks in one read → one write. This replaces calling
918
+ // the four per-hook ensure* functions in sequence, and fixes two real losses
919
+ // seen in the field (Sonifi, 2026-07):
920
+ // 1. The SessionEnd → capture hook — the one that actually uploads sessions
921
+ // — had NO self-heal at all: it was written once at `connect` and never
922
+ // repaired. Anything that stripped it (IT-managed dotfiles baseline, the
923
+ // race below) left SessionStart pinging forever while captures silently
924
+ // stopped — the exact "start pings arrive, captures never" signature.
925
+ // 2. Each per-hook ensure* did its own read-modify-write of settings.json
926
+ // with no locking; two concurrent Claude Code sessions repairing
927
+ // different hooks could lose each other's writes. One read + one write
928
+ // shrinks that window and removes the intra-process version entirely.
929
+ // The per-hook ensure* functions below are kept for tests/back-compat but are
930
+ // no longer called from the hook entrypoint.
931
+ function ensureAllCodiedevHooksInstalled() {
932
+ try {
933
+ if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
934
+ return; // not a Claude Code user
935
+ const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
936
+ const hooks = (settings.hooks ?? {});
937
+ const wanted = [
938
+ ["SessionEnd", "capture"],
939
+ ["UserPromptSubmit", "discover"],
940
+ ["Stop", "backup"],
941
+ ["StopFailure", "limit", "rate_limit"],
942
+ ["SessionStart", "session-start"],
943
+ ];
944
+ const missing = wanted.filter(([eventName]) => !hasCodiedevHook(hooks, eventName));
945
+ if (missing.length === 0)
946
+ return; // steady state: no write, no churn
947
+ settings.hooks = hooks;
948
+ for (const [eventName, subcommand, matcher] of missing) {
949
+ upsertCodiedevHook(hooks, eventName, subcommand, matcher);
950
+ }
951
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
952
+ }
953
+ catch {
954
+ // Best-effort — never block a session on self-heal.
955
+ }
956
+ }
957
+ function hasCodiedevHook(hooks, eventName) {
958
+ const entries = hooks[eventName] ?? [];
959
+ // A malformed (non-array) event value would make `.some` throw and — under
960
+ // ensureAll's single try/catch — abort EVERY heal, including the capture
961
+ // heal. Report "present" instead: leave the foreign/corrupt key alone and
962
+ // keep healing the other events (the per-hook fault isolation the old
963
+ // ensure* functions had).
964
+ if (!Array.isArray(entries))
965
+ return true;
966
+ return entries.some((hook) => {
967
+ const inner = hook.hooks;
968
+ return (Array.isArray(inner) &&
969
+ inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command)));
970
+ });
971
+ }
916
972
  // Self-heal: ensure the SessionStart → session-start hook is present without a
917
973
  // full `connect` re-run, mirroring ensureLimitHookInstalled. Called from the
918
974
  // capture hook so an upgraded-but-not-reconnected user starts emitting the
@@ -1733,6 +1789,11 @@ function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
1733
1789
  // A claimed job whose uploader hasn't finished after this long is presumed
1734
1790
  // crashed and gets renamed back to its canonical name for a later retry.
1735
1791
  exports.CLAIM_STALE_MS = 15 * 60 * 1000;
1792
+ // A finish-input file older than this is an orphan: its capture-finish child
1793
+ // normally consumes + unlinks it within seconds, so an hour-old file means the
1794
+ // child died (spawn failure, OOM/AV kill, machine sleep). Generous so we never
1795
+ // race a legitimately slow child.
1796
+ exports.FINISH_ORPHAN_AGE_MS = 60 * 60 * 1000;
1736
1797
  // Retry kept-on-failure capture jobs: re-dispatch each pending
1737
1798
  // `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
1738
1799
  // gives up at the cap). Best-effort — never throws into the hook.
@@ -1770,6 +1831,37 @@ function sweepPendingCaptures(deps) {
1770
1831
  // raced another sweeper or the uploader just finished — fine
1771
1832
  }
1772
1833
  }
1834
+ // Orphaned finish-inputs: the FIRST detached child (capture-finish) died
1835
+ // before it could build an upload job. That path has no retry queue — the
1836
+ // payload is gone — but the loss must not stay invisible: report the
1837
+ // sessionId (filename embeds it: codiedev-finish-<sid>-<pid>-<ts>.json) so
1838
+ // it lands as a skip-reason, then clean the file up.
1839
+ if (deps.unlinkSync) {
1840
+ for (const f of files) {
1841
+ const m = /^codiedev-finish-(.+)-\d+-\d+\.json$/.exec(f);
1842
+ if (!m)
1843
+ continue;
1844
+ const orphanPath = path.join(deps.dir, f);
1845
+ try {
1846
+ if (now - deps.statSync(orphanPath).mtimeMs < exports.FINISH_ORPHAN_AGE_MS)
1847
+ continue;
1848
+ deps.unlinkSync(orphanPath);
1849
+ // finishInputTmpFile falls back to the PID when the hook had no
1850
+ // session_id (codiedev-finish-<pid>-<pid>-<ts>.json). A pure-digit
1851
+ // capture is that fallback, not a session id — reporting it would
1852
+ // create a phantom session row server-side. Real session ids are
1853
+ // UUIDs and never all-digits.
1854
+ const sid = /^\d+$/.test(m[1]) ? undefined : m[1];
1855
+ try {
1856
+ deps.reportOrphan?.(sid);
1857
+ }
1858
+ catch { /* report is best-effort */ }
1859
+ }
1860
+ catch {
1861
+ // raced its owner or another sweeper — fine
1862
+ }
1863
+ }
1864
+ }
1773
1865
  const jobs = files
1774
1866
  .filter((f) => /^codiedev-capture-.*\.json$/.test(f))
1775
1867
  .map((f) => path.join(deps.dir, f))
@@ -2046,10 +2138,30 @@ async function runCaptureUpload(jobFile, deps) {
2046
2138
  }
2047
2139
  catch { /* log is best-effort */ }
2048
2140
  }
2141
+ // Unlink FIRST: if the skip ping below hangs (no timeout on injected
2142
+ // post impls), a still-present job file would be reclaimed by the
2143
+ // 15-min stale-claim sweep and re-enter this give-up path forever.
2049
2144
  try {
2050
2145
  deps.unlinkSync(jobFile);
2051
2146
  }
2052
2147
  catch { /* gone */ }
2148
+ // Tell the server this capture is permanently lost and why. The full
2149
+ // transcript POST may be what's blocked (proxy/DLP body inspection) —
2150
+ // this tiny ping often still passes, turning an invisible loss into a
2151
+ // per-reason bucket on the admin Capture Health panel.
2152
+ if (sessionId) {
2153
+ try {
2154
+ const skipUrl = job.url.replace(/\/api\/captureSession$/, "/api/cli/sessionSkip");
2155
+ if (skipUrl !== job.url) {
2156
+ await deps.post(skipUrl, {
2157
+ sessionId,
2158
+ reason: expired ? "upload-gave-up:ttl" : "upload-gave-up:attempts",
2159
+ at: now,
2160
+ }, job.token);
2161
+ }
2162
+ }
2163
+ catch { /* best-effort — the ping may be blocked too */ }
2164
+ }
2053
2165
  return;
2054
2166
  }
2055
2167
  // Log the error before persisting the retry bump.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.22",
3
+ "version": "0.9.23",
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",