codiedev 0.9.51 → 0.9.53

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.
Files changed (50) hide show
  1. package/README.md +8 -1
  2. package/dist/captureLog.d.ts +1 -1
  3. package/dist/cli.js +7 -22
  4. package/dist/codie.js +1 -146
  5. package/dist/codieLauncher.d.ts +0 -5
  6. package/dist/codieLauncher.js +1 -22
  7. package/dist/codieRuntime.d.ts +1 -11
  8. package/dist/codieRuntime.js +3 -20
  9. package/dist/codieRuntimeInstall.d.ts +11 -12
  10. package/dist/codieRuntimeInstall.js +1 -3
  11. package/dist/commands/doctor.js +0 -8
  12. package/dist/connect.js +4 -53
  13. package/dist/cursorHooks.d.ts +9 -0
  14. package/dist/cursorHooks.js +56 -0
  15. package/dist/hook.js +95 -227
  16. package/dist/limitEvent.d.ts +4 -1
  17. package/dist/limitEvent.js +54 -0
  18. package/dist/limitEventReporter.d.ts +21 -0
  19. package/dist/limitEventReporter.js +91 -0
  20. package/dist/mcp.js +1 -93
  21. package/dist/repoResolver.js +1 -1
  22. package/dist/upgradeNudge.d.ts +0 -1
  23. package/dist/upgradeNudge.js +0 -1
  24. package/dist/utils.d.ts +10 -8
  25. package/dist/utils.js +174 -199
  26. package/package.json +5 -11
  27. package/dist/codieStartup.d.ts +0 -24
  28. package/dist/codieStartup.js +0 -29
  29. package/dist/commands/post.d.ts +0 -2
  30. package/dist/commands/post.js +0 -96
  31. package/dist/commands/security.d.ts +0 -1
  32. package/dist/commands/security.js +0 -156
  33. package/dist/security.d.ts +0 -97
  34. package/dist/security.js +0 -422
  35. package/dist/securityDeepScan.d.ts +0 -41
  36. package/dist/securityDeepScan.js +0 -248
  37. package/dist/securityDoctor.d.ts +0 -32
  38. package/dist/securityDoctor.js +0 -63
  39. package/dist/securityFeature.d.ts +0 -5
  40. package/dist/securityFeature.js +0 -7
  41. package/dist/securityInstall.d.ts +0 -62
  42. package/dist/securityInstall.js +0 -227
  43. package/dist/securityInventory.d.ts +0 -54
  44. package/dist/securityInventory.js +0 -556
  45. package/dist/securityPilot.d.ts +0 -19
  46. package/dist/securityPilot.js +0 -61
  47. package/dist/securityPolicy.d.ts +0 -32
  48. package/dist/securityPolicy.js +0 -179
  49. package/dist/securitySession.d.ts +0 -6
  50. package/dist/securitySession.js +0 -72
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeCursorHookInput = normalizeCursorHookInput;
4
+ exports.normalizeCursorTranscript = normalizeCursorTranscript;
5
+ exports.isCursorTranscriptEmpty = isCursorTranscriptEmpty;
6
+ /** Normalize Cursor's native hook payload into the shared capture shape. */
7
+ function normalizeCursorHookInput(input) {
8
+ const sessionId = firstString(input.session_id, input.sessionId, input.conversation_id);
9
+ const roots = Array.isArray(input.workspace_roots)
10
+ ? input.workspace_roots.filter((root) => typeof root === "string")
11
+ : [];
12
+ const cwd = firstString(input.cwd, roots[0]);
13
+ const duration = firstNumber(input.duration_ms, input.durationMs, input.duration);
14
+ return {
15
+ ...input,
16
+ ...(sessionId ? { session_id: sessionId } : {}),
17
+ ...(cwd ? { cwd } : {}),
18
+ ...(input.tool_response === undefined && input.toolResponse === undefined && input.tool_output !== undefined
19
+ ? { tool_response: input.tool_output }
20
+ : {}),
21
+ ...(input.error === undefined && input.error_message !== undefined
22
+ ? { error: input.error_message }
23
+ : {}),
24
+ ...(duration !== undefined ? { duration_ms: duration } : {}),
25
+ };
26
+ }
27
+ /** Add Claude-compatible message discriminators while preserving Cursor JSONL. */
28
+ function normalizeCursorTranscript(content) {
29
+ return content.split("\n").map((line) => {
30
+ if (!line.trim())
31
+ return line;
32
+ try {
33
+ const entry = JSON.parse(line);
34
+ if ((entry.role === "user" || entry.role === "assistant") && entry.type === undefined) {
35
+ return JSON.stringify({ ...entry, type: entry.role });
36
+ }
37
+ }
38
+ catch {
39
+ // Tolerant downstream parsers skip malformed/non-JSON lines.
40
+ }
41
+ return line;
42
+ }).join("\n");
43
+ }
44
+ /** Cursor may change its private JSONL record shape independently of the hook
45
+ * contract. Only a byte-empty snapshot is safe to discard; a non-empty file
46
+ * that the current normalizer cannot parse must still be uploaded for backend
47
+ * recovery and diagnosis. */
48
+ function isCursorTranscriptEmpty(content) {
49
+ return content.trim().length === 0;
50
+ }
51
+ function firstString(...values) {
52
+ return values.find((value) => typeof value === "string" && value.trim().length > 0);
53
+ }
54
+ function firstNumber(...values) {
55
+ return values.find((value) => typeof value === "number" && Number.isFinite(value));
56
+ }
package/dist/hook.js CHANGED
@@ -37,20 +37,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.buildDiscoveryContext = buildDiscoveryContext;
38
38
  const fs = __importStar(require("fs"));
39
39
  const os = __importStar(require("os"));
40
- const path = __importStar(require("path"));
41
40
  const https = __importStar(require("https"));
42
41
  const http = __importStar(require("http"));
43
42
  const child_process_1 = require("child_process");
44
43
  const utils_1 = require("./utils");
45
- const limitEvent_1 = require("./limitEvent");
44
+ const limitEventReporter_1 = require("./limitEventReporter");
46
45
  const version_1 = require("./version");
47
46
  const captureLog_1 = require("./captureLog");
48
- const security_1 = require("./security");
49
- const securitySession_1 = require("./securitySession");
50
- const securityPolicy_1 = require("./securityPolicy");
51
- const securityFeature_1 = require("./securityFeature");
52
- const securityInventory_1 = require("./securityInventory");
53
47
  const captureExclusions_1 = require("./captureExclusions");
48
+ const cursorHooks_1 = require("./cursorHooks");
54
49
  // Pure builder for the discovery additionalContext. Returns null when there are
55
50
  // no matches. The suggested `codiedev pull` command carries the EXACT top key
56
51
  // (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
@@ -98,43 +93,56 @@ async function runDiscover(prompt) {
98
93
  }
99
94
  process.exit(0);
100
95
  }
96
+ function readTranscriptTailFile(transcriptPath, maxBytes = 512 * 1024) {
97
+ const size = fs.statSync(transcriptPath).size;
98
+ if (size <= maxBytes)
99
+ return fs.readFileSync(transcriptPath, "utf8");
100
+ const handle = fs.openSync(transcriptPath, "r");
101
+ try {
102
+ const buffer = Buffer.allocUnsafe(maxBytes);
103
+ const bytesRead = fs.readSync(handle, buffer, 0, maxBytes, size - maxBytes);
104
+ return buffer.subarray(0, bytesRead).toString("utf8");
105
+ }
106
+ finally {
107
+ fs.closeSync(handle);
108
+ }
109
+ }
101
110
  // StopFailure (matcher rate_limit): the dev just got blocked. Read the local
102
111
  // transcript tail for the ground-truth rate_limit line and POST a tiny event.
103
112
  // Best-effort + silent — StopFailure output is ignored by Claude Code anyway.
104
- async function runStopFailure(hookInput) {
113
+ async function readAndPostLimitEvent(hookInput, source, emitMinimalOnMiss) {
105
114
  const config = (0, utils_1.readConfig)();
106
115
  if (!config)
107
116
  process.exit(0);
108
- let event = null;
109
- let transcriptTail;
117
+ let transcript = "";
110
118
  const tp = hookInput.transcript_path;
111
119
  if (tp) {
112
120
  try {
113
- const transcript = fs.readFileSync(tp, "utf8");
114
- event = (0, limitEvent_1.parseLimitEventFromTranscript)(transcript);
115
- // Ship the tail with the event so the server can summarize "what they
116
- // were working on" right now — no waiting for the SessionEnd capture.
117
- transcriptTail = (0, limitEvent_1.tailForLimitEvent)(transcript) || undefined;
121
+ transcript = readTranscriptTailFile(tp);
118
122
  }
119
123
  catch {
120
124
  /* unreadable transcript — fall through to minimal event */
121
125
  }
122
126
  }
123
- // The hook is registered with matcher "rate_limit", so a miss still means a
124
- // real limit — emit a minimal event rather than dropping it.
125
- const payload = event ?? { kind: "other", at: Date.now(), resetText: null };
126
127
  try {
127
- await postJson(`${config.backendUrl}/api/cli/limitEvent`, {
128
- kind: payload.kind,
129
- at: payload.at,
130
- resetText: payload.resetText,
128
+ await (0, limitEventReporter_1.reportTranscriptLimitEvent)({
129
+ config,
130
+ source,
131
131
  sessionId: hookInput.session_id,
132
- transcriptTail,
133
- }, config.token);
132
+ transcript,
133
+ emitMinimalOnMiss,
134
+ });
134
135
  }
135
136
  catch {
136
137
  /* best-effort */
137
138
  }
139
+ }
140
+ async function runStopFailure(hookInput) {
141
+ await readAndPostLimitEvent(hookInput, "claude-code", true);
142
+ process.exit(0);
143
+ }
144
+ async function runCodexLimit(hookInput) {
145
+ await readAndPostLimitEvent(hookInput, "codex", false);
138
146
  process.exit(0);
139
147
  }
140
148
  // Skip-reason ping: this session is ending WITHOUT a capture, and here's why.
@@ -223,7 +231,7 @@ timeoutMs) {
223
231
  // postJson variant that returns the parsed response body — the upload-URL
224
232
  // handshake needs the { uploadUrl } the server hands back. No timeout: runs
225
233
  // only in detached uploader children, never on the shutdown clock.
226
- function postJsonForResponse(url, body, bearerToken, timeoutMs) {
234
+ function postJsonForResponse(url, body, bearerToken) {
227
235
  return new Promise((resolve, reject) => {
228
236
  const parsed = new URL(url);
229
237
  const data = JSON.stringify(body);
@@ -257,139 +265,10 @@ function postJsonForResponse(url, body, bearerToken, timeoutMs) {
257
265
  });
258
266
  });
259
267
  req.on("error", reject);
260
- if (timeoutMs) {
261
- req.setTimeout(timeoutMs, () => {
262
- req.destroy(new Error(`timeout after ${timeoutMs}ms`));
263
- });
264
- }
265
268
  req.write(data);
266
269
  req.end();
267
270
  });
268
271
  }
269
- // Local policy runs synchronously before a tool executes. Workflow telemetry
270
- // is dispatched separately and fail-open, so a detector outage or timeout can
271
- // never delay or override the deterministic endpoint decision.
272
- async function runSecurityHook(hookInput, phase, source, uploadTelemetry = true) {
273
- const config = (0, utils_1.readConfig)();
274
- if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
275
- return;
276
- const policy = (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId);
277
- const sessionId = hookInput.session_id ?? hookInput.sessionId;
278
- const sessionSignal = phase === "pre" ? (0, securitySession_1.readSessionSecuritySignal)(sessionId) : undefined;
279
- const localDecision = (0, security_1.evaluateToolSecurity)(hookInput, phase, policy, source, sessionSignal);
280
- if (phase === "post" && (0, security_1.securityInputHasSensitivePath)(hookInput)) {
281
- (0, securitySession_1.recordSensitiveReadSignal)(sessionId);
282
- }
283
- const event = (0, security_1.buildSecurityWorkflowEvent)(hookInput, phase, source);
284
- if (event) {
285
- event.localDecision = localDecision.action;
286
- event.localCategory = localDecision.category;
287
- }
288
- if (localDecision.action === "block" && phase === "pre") {
289
- (0, captureLog_1.appendCaptureLog)({
290
- stage: "security",
291
- sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
292
- outcome: "ok",
293
- detail: `phase=${phase} localDecision=block category=${localDecision.category}`,
294
- });
295
- if (uploadTelemetry)
296
- dispatchSecurityEventUpload(event);
297
- process.stdout.write(JSON.stringify({
298
- hookSpecificOutput: {
299
- hookEventName: "PreToolUse",
300
- permissionDecision: "deny",
301
- permissionDecisionReason: localDecision.reason,
302
- },
303
- }));
304
- return;
305
- }
306
- if (!event)
307
- return;
308
- if (uploadTelemetry)
309
- dispatchSecurityEventUpload(event);
310
- }
311
- function runPromptSecurityHook(hookInput, source, uploadTelemetry = true) {
312
- const config = (0, utils_1.readConfig)();
313
- if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
314
- return false;
315
- const decision = (0, security_1.evaluatePromptSecurity)(hookInput.prompt, (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId), source);
316
- if (decision.action !== "block")
317
- return false;
318
- (0, captureLog_1.appendCaptureLog)({
319
- stage: "security",
320
- sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
321
- outcome: "ok",
322
- detail: `phase=prompt localDecision=block category=${decision.category} telemetry=${uploadTelemetry ? "enabled" : "suppressed"} cwd=${hookInput.cwd ?? "unknown"}`,
323
- });
324
- if (uploadTelemetry) {
325
- dispatchSecurityEventUpload((0, security_1.buildBlockedSecurityPromptEvent)(hookInput, source, decision.category ?? "secret_in_prompt"));
326
- }
327
- process.stdout.write(JSON.stringify({
328
- decision: "block",
329
- reason: decision.reason,
330
- }));
331
- return true;
332
- }
333
- function dispatchAllowedPromptSecurityEvent(hookInput, source) {
334
- const config = (0, utils_1.readConfig)();
335
- if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
336
- return;
337
- const event = (0, security_1.buildSecurityPromptEvent)(hookInput, source, Date.now, (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId));
338
- if (event)
339
- dispatchSecurityEventUpload(event);
340
- }
341
- function dispatchSecurityEventUpload(event) {
342
- if (!event || !(0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()))
343
- return;
344
- try {
345
- const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-upload"], {
346
- detached: true,
347
- stdio: ["pipe", "ignore", "ignore"],
348
- });
349
- child.stdin?.end(JSON.stringify(event));
350
- child.unref();
351
- }
352
- catch {
353
- // Telemetry is best-effort and must never delay local enforcement.
354
- }
355
- }
356
- async function runSecurityEventUpload() {
357
- const config = (0, utils_1.readConfig)();
358
- if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
359
- return;
360
- const startedAt = Date.now();
361
- try {
362
- const raw = await readStdin();
363
- const event = JSON.parse(raw);
364
- const eventWithEndpoint = (0, security_1.withEndpointIdentity)(event, {
365
- endpointId: config.deviceId,
366
- endpointName: config.deviceName,
367
- cliVersion: version_1.CLI_VERSION,
368
- });
369
- const result = await postJsonForResponse(`${config.backendUrl}/api/cli/securityEvent`, { ...eventWithEndpoint }, config.token,
370
- // Leave room for a cold backend and durable scheduling. This uploader is
371
- // detached, so the network budget never delays the developer's prompt or
372
- // tool call. Policy refreshes use the separately signed sync endpoint.
373
- 5_000);
374
- if (result?.policy?.schemaVersion === 1) {
375
- (0, securityPolicy_1.writeEndpointSecurityPolicy)(result.policy, undefined, undefined, config.companyId);
376
- }
377
- (0, captureLog_1.appendCaptureLog)({
378
- stage: "security",
379
- sessionId: event.sessionId,
380
- outcome: "ok",
381
- detail: `phase=${event.phase} elapsedMs=${Date.now() - startedAt} verdict=${String(result?.verdict ?? "unknown")}`,
382
- });
383
- }
384
- catch (err) {
385
- (0, captureLog_1.appendCaptureLog)({
386
- stage: "security",
387
- sessionId: "upload",
388
- outcome: "ERROR",
389
- detail: `elapsedMs=${Date.now() - startedAt} ${err.message}`,
390
- });
391
- }
392
- }
393
272
  // Raw-bytes POST to a Convex storage upload URL (no auth header — the URL
394
273
  // itself is the one-time credential). Returns the parsed { storageId }.
395
274
  function postBytes(url, bytes, contentType) {
@@ -445,7 +324,7 @@ async function readStdin() {
445
324
  function resolveMode(arg) {
446
325
  if (arg === "capture-codex")
447
326
  return "codex";
448
- if (arg === "capture-cursor")
327
+ if (arg === "capture-cursor" || arg === "backup-cursor")
449
328
  return "cursor";
450
329
  if (arg === "capture-opencode" || arg === "capture-opencode-final")
451
330
  return "opencode";
@@ -513,20 +392,6 @@ async function main() {
513
392
  }
514
393
  process.exit(0);
515
394
  }
516
- // Daily component drift scan runs in a detached hook process. The parent
517
- // SessionStart path only performs a timestamp read, so agent startup never
518
- // waits for filesystem traversal or an inventory upload.
519
- if (process.argv[2] === "security-inventory-upload") {
520
- const config = (0, utils_1.readConfig)();
521
- if (config && (0, securityFeature_1.isAgentSecurityEnabled)(config)) {
522
- const inventory = (0, securityInventory_1.buildSecurityInventory)({ workspace: process.argv[3] || process.cwd() });
523
- try {
524
- await (0, securityInventory_1.uploadSecurityInventory)(inventory, config);
525
- }
526
- catch { /* retry after the daily inventory window */ }
527
- }
528
- process.exit(0);
529
- }
530
395
  // Detached backup uploader child (spawned by runBackup below). The job file
531
396
  // carries only { root, files }; the child re-reads config from disk. No stdin
532
397
  // needed, so handle it before readStdin() like the capture uploader above.
@@ -563,78 +428,51 @@ async function main() {
563
428
  }
564
429
  process.exit(0);
565
430
  }
566
- // Detached, best-effort security telemetry. Local policy decisions have
567
- // already completed in the parent hook; this child can never delay or alter
568
- // the developer's action.
569
- if (process.argv[2] === "security-upload") {
570
- await runSecurityEventUpload();
571
- process.exit(0);
572
- }
573
431
  let hookInput = {};
574
432
  try {
575
433
  const raw = await readStdin();
576
434
  if (raw.trim()) {
577
- hookInput = JSON.parse(raw);
435
+ const parsed = JSON.parse(raw);
436
+ hookInput = (process.argv[2]?.endsWith("-cursor")
437
+ ? (0, cursorHooks_1.normalizeCursorHookInput)(parsed)
438
+ : parsed);
578
439
  }
579
440
  }
580
441
  catch {
581
442
  process.exit(0);
582
443
  }
583
- // Privacy exclusions suppress every Codiedev upload, but they must never
584
- // disable local security enforcement. Security hooks run first with telemetry
585
- // disabled; all other hook modes still exit before making a network request.
586
- const captureIgnored = (0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput);
587
- const securityCommand = process.argv[2] ?? "";
588
- const securityMatch = securityCommand.match(/^security-(pre|post|failure)(?:-(codex|codie))?$/);
589
- if (securityMatch) {
590
- const source = securityMatch[2] === "codex"
591
- ? "codex"
592
- : securityMatch[2] === "codie"
593
- ? "codie-harness"
594
- : "claude-code";
595
- await runSecurityHook(hookInput, securityMatch[1], source, !captureIgnored);
444
+ // Local privacy boundary: every stdin-driven hook mode passes through this
445
+ // check BEFORE prompt discovery, limit-event tails, SessionStart pings,
446
+ // backup, self-heal, transcript reads, or detached dispatch. Ignored means
447
+ // no Codiedev request at all for this session.
448
+ if ((0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput)) {
596
449
  process.exit(0);
597
450
  }
598
451
  // UserPromptSubmit discovery path — separate from session capture.
599
- const promptCommand = process.argv[2] ?? "";
600
452
  if (process.argv[2] === "discover" ||
601
453
  process.argv[2] === "discover-codex" ||
602
454
  process.argv[2] === "discover-codie") {
603
- const source = promptCommand === "discover-codex"
604
- ? "codex"
605
- : promptCommand === "discover-codie"
606
- ? "codie-harness"
607
- : "claude-code";
608
- // Security runs first and locally. A blocked prompt is erased before it
609
- // reaches the model, so discovery must not send it to the backend.
610
- if (runPromptSecurityHook(hookInput, source, !captureIgnored))
611
- return;
612
- if (captureIgnored)
613
- return;
614
- dispatchAllowedPromptSecurityEvent(hookInput, source);
615
455
  await runDiscover(hookInput.prompt);
616
456
  return;
617
457
  }
618
- if (captureIgnored)
619
- process.exit(0);
620
458
  // StopFailure (matcher rate_limit): post the rate-limit event.
621
459
  if (process.argv[2] === "limit") {
622
460
  await runStopFailure(hookInput);
623
461
  return;
624
462
  }
463
+ if (process.argv[2] === "limit-codex") {
464
+ await runCodexLimit(hookInput);
465
+ return;
466
+ }
625
467
  // SessionStart: post the capture-coverage denominator ping. Tiny + fast.
626
- if (process.argv[2] === "session-start" || process.argv[2] === "session-start-opencode") {
627
- if ((0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()) && (0, securityInventory_1.securityInventoryDue)()) {
628
- try {
629
- const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-inventory-upload", hookInput.cwd || process.cwd()], {
630
- detached: true,
631
- stdio: "ignore",
632
- });
633
- child.unref();
634
- }
635
- catch { /* daily inventory is best-effort */ }
636
- }
637
- await runSessionStart(hookInput, process.argv[2] === "session-start-opencode" ? "opencode" : "claude-code");
468
+ if (process.argv[2] === "session-start" ||
469
+ process.argv[2] === "session-start-cursor" ||
470
+ process.argv[2] === "session-start-opencode") {
471
+ await runSessionStart(hookInput, process.argv[2] === "session-start-opencode"
472
+ ? "opencode"
473
+ : process.argv[2] === "session-start-cursor"
474
+ ? "cursor"
475
+ : "claude-code");
638
476
  return;
639
477
  }
640
478
  // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
@@ -642,7 +480,14 @@ async function main() {
642
480
  // no self-heal and could stay stripped forever while SessionStart kept
643
481
  // pinging (the Sonifi "starts arrive, captures never" loss) — re-wired on
644
482
  // their next Stop/SessionEnd. One read + one write, only when missing.
645
- (0, utils_1.ensureAllCodiedevHooksInstalled)();
483
+ if ((process.argv[2] ?? "").endsWith("-cursor")) {
484
+ (0, utils_1.ensureCursorCodiedevHooksInstalled)();
485
+ }
486
+ else {
487
+ (0, utils_1.ensureAllCodiedevHooksInstalled)();
488
+ }
489
+ if (process.argv[2] === "capture-codex")
490
+ (0, utils_1.ensureCodexLimitHookInstalled)();
646
491
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
647
492
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
648
493
  ? workspace_roots[0]
@@ -655,13 +500,13 @@ async function main() {
655
500
  // extension) where SessionEnd may never come — the silent zero-capture bug.
656
501
  // Same detached capture-finish child as SessionEnd, marked non-final so the
657
502
  // server renews the session lease instead of summarizing immediately.
658
- if (process.argv[2] === "backup") {
503
+ if (process.argv[2] === "backup" || process.argv[2] === "backup-cursor") {
659
504
  if (transcript_path) {
660
505
  try {
661
506
  (0, utils_1.dispatchCaptureFinish)({
662
507
  // "capture" = the claude-code entrypoint literal, same as the
663
508
  // SessionEnd path below (resolveMode maps it to claude-code).
664
- mode: "capture",
509
+ mode: process.argv[2] === "backup-cursor" ? "capture-cursor" : "capture",
665
510
  session_id,
666
511
  transcript_path,
667
512
  cwd,
@@ -723,8 +568,8 @@ async function main() {
723
568
  workspace_roots,
724
569
  // final:true ONLY for claude-code's SessionEnd. Codex routes its
725
570
  // per-turn Stop hook through "capture-codex" — final:true there would
726
- // close the server lease every turn. Codex/Cursor keep legacy
727
- // behaviour (final omitted).
571
+ // close the server lease every turn. Cursor SessionEnd is final:true;
572
+ // Codex remains the legacy final-omitted per-turn path.
728
573
  final: (0, utils_1.resolveCaptureFinal)(process.argv[2]),
729
574
  }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile, openSync: fs.openSync, closeSync: fs.closeSync });
730
575
  }
@@ -801,7 +646,9 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
801
646
  outcome: "skip",
802
647
  detail: reason,
803
648
  });
804
- await postSessionSkip(config, session_id, reason);
649
+ if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
650
+ await postSessionSkip(config, session_id, reason);
651
+ }
805
652
  return;
806
653
  }
807
654
  // Snapshot the RAW transcript before normalization below flattens it to
@@ -813,6 +660,9 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
813
660
  if (parserMode === "opencode") {
814
661
  transcriptContent = (0, utils_1.normalizeOpenCodeTranscript)(transcriptContent);
815
662
  }
663
+ else if (parserMode === "cursor") {
664
+ transcriptContent = (0, cursorHooks_1.normalizeCursorTranscript)(transcriptContent);
665
+ }
816
666
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
817
667
  // need the git remote of the workspace.
818
668
  let remoteUrl = null;
@@ -839,9 +689,23 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
839
689
  const matchedRepo = remoteUrl ? (0, utils_1.matchRepo)(remoteUrl, config.repos) : null;
840
690
  const stats = parserMode === "codex"
841
691
  ? (0, utils_1.parseCodexStats)(transcriptContent)
842
- : parserMode === "cursor"
843
- ? { messageCount: 0, toolCallCount: 0 }
844
- : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
692
+ : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
693
+ // Never use parsed message count here: Cursor's private JSONL shape can
694
+ // evolve independently, and parser drift must not discard a real session.
695
+ if (parserMode === "cursor" && (0, cursorHooks_1.isCursorTranscriptEmpty)(rawTranscriptContent)) {
696
+ (0, captureLog_1.appendCaptureLog)({
697
+ stage: "capture-finish",
698
+ sessionId: session_id,
699
+ outcome: "skip",
700
+ detail: "empty-cursor-transcript",
701
+ });
702
+ // A non-final stop snapshot is only a backup; reporting it as a skipped
703
+ // session would close server-side coverage while the session is alive.
704
+ if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
705
+ await postSessionSkip(config, session_id, "empty-cursor-transcript");
706
+ }
707
+ return;
708
+ }
845
709
  const transcriptBase64 = Buffer.from(transcriptContent, "utf8").toString("base64");
846
710
  // Session→merge join keys: the branch + HEAD sha the work happened on. Matched
847
711
  // server-side against a merged MR/PR's source_branch / commits for AI attribution.
@@ -934,7 +798,7 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
934
798
  // which is the "supersede" mechanism for the deferred path.
935
799
  try {
936
800
  const hookScriptPath = process.argv[1];
937
- const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
801
+ const tmpFile = (0, utils_1.captureUploadTmpFile)(session_id, decision.mode);
938
802
  if (decision.mode === "immediate") {
939
803
  (0, utils_1.dispatchCaptureDetached)({
940
804
  url: `${config.backendUrl}/api/captureSession`,
@@ -942,6 +806,10 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
942
806
  body: payload,
943
807
  scopePaths,
944
808
  }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
809
+ // Persist the immediate payload before removing the older scheduled
810
+ // snapshot. Every immediate dispatch supersedes a sleeping deferred job.
811
+ if (session_id)
812
+ (0, utils_1.cancelDeferredCaptureUpload)(session_id);
945
813
  // Retry any earlier capture jobs that failed to upload (transient network
946
814
  // blips), excluding the one we just dispatched. Best-effort, detached.
947
815
  // Skip the sweep on deferred path — the deferred child will handle uploading.
@@ -1,9 +1,12 @@
1
- export type LimitKind = "session" | "org_spend" | "other";
1
+ export type LimitKind = "session" | "user_spend" | "org_spend" | "other";
2
2
  export interface LimitEvent {
3
3
  kind: LimitKind;
4
4
  at: number;
5
5
  resetText: string | null;
6
+ reason?: string;
6
7
  }
7
8
  export declare function classifyLimitKind(text: string): LimitKind;
8
9
  export declare function parseLimitEventFromTranscript(transcript: string): LimitEvent | null;
10
+ /** Parse the structured rate-limit snapshot written to Codex rollout JSONL. */
11
+ export declare function parseCodexLimitEventFromTranscript(transcript: string): LimitEvent | null;
9
12
  export declare function tailForLimitEvent(transcript: string, maxChars?: number): string;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.classifyLimitKind = classifyLimitKind;
4
4
  exports.parseLimitEventFromTranscript = parseLimitEventFromTranscript;
5
+ exports.parseCodexLimitEventFromTranscript = parseCodexLimitEventFromTranscript;
5
6
  exports.tailForLimitEvent = tailForLimitEvent;
6
7
  function classifyLimitKind(text) {
7
8
  const t = text.toLowerCase();
@@ -50,6 +51,59 @@ function parseLimitEventFromTranscript(transcript) {
50
51
  }
51
52
  return found;
52
53
  }
54
+ function codexLimitKind(reason, spendControlReached) {
55
+ if (spendControlReached || reason.startsWith("workspace_owner_"))
56
+ return "org_spend";
57
+ if (reason.startsWith("workspace_member_"))
58
+ return "user_spend";
59
+ return reason === "rate_limit_reached" ? "session" : "other";
60
+ }
61
+ function codexResetText(rateLimits) {
62
+ const windows = [rateLimits?.primary, rateLimits?.secondary]
63
+ .filter((window) => window && typeof window === "object");
64
+ const reached = windows.filter((window) => Number(window.used_percent) >= 100);
65
+ const candidates = reached.length > 0 ? reached : windows;
66
+ const resetAt = candidates
67
+ .map((window) => Number(window.resets_at))
68
+ .filter((value) => Number.isFinite(value) && value > 0)
69
+ .sort((a, b) => a - b)[0];
70
+ return resetAt ? new Date(resetAt * 1000).toISOString() : null;
71
+ }
72
+ /** Parse the structured rate-limit snapshot written to Codex rollout JSONL. */
73
+ function parseCodexLimitEventFromTranscript(transcript) {
74
+ let found = null;
75
+ for (const line of transcript.split("\n")) {
76
+ if (!line.includes('"rate_limit_reached_type"') && !line.includes('"spend_control_reached"'))
77
+ continue;
78
+ let entry;
79
+ try {
80
+ entry = JSON.parse(line);
81
+ }
82
+ catch {
83
+ continue;
84
+ }
85
+ if (entry?.type !== "event_msg" || entry?.payload?.type !== "token_count")
86
+ continue;
87
+ const rateLimits = entry.payload.rate_limits;
88
+ if (!rateLimits || typeof rateLimits !== "object")
89
+ continue;
90
+ const reachedType = typeof rateLimits.rate_limit_reached_type === "string"
91
+ ? rateLimits.rate_limit_reached_type
92
+ : null;
93
+ const spendControlReached = rateLimits.spend_control_reached === true;
94
+ if (!reachedType && !spendControlReached)
95
+ continue;
96
+ const reason = reachedType ?? "spend_control_reached";
97
+ const at = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : NaN;
98
+ found = {
99
+ kind: codexLimitKind(reason, spendControlReached),
100
+ at: Number.isNaN(at) ? Date.now() : at,
101
+ resetText: codexResetText(rateLimits),
102
+ reason,
103
+ };
104
+ }
105
+ return found;
106
+ }
53
107
  // Tail of the raw transcript to ship with the limit event so the server can
54
108
  // summarize "what they were working on" immediately — without waiting for the
55
109
  // SessionEnd capture (which can lag hours, or drop entirely on Windows).
@@ -0,0 +1,21 @@
1
+ import { type LimitEvent } from "./limitEvent";
2
+ export type LimitEventSource = "claude-code" | "codex";
3
+ export type LimitEventPayload = LimitEvent & {
4
+ source: LimitEventSource;
5
+ sessionId?: string;
6
+ transcriptTail?: string;
7
+ };
8
+ export declare function postLimitEvent(config: {
9
+ backendUrl: string;
10
+ token: string;
11
+ }, payload: LimitEventPayload, timeoutMs?: number): Promise<void>;
12
+ export declare function reportTranscriptLimitEvent(args: {
13
+ config: {
14
+ backendUrl: string;
15
+ token: string;
16
+ };
17
+ source: LimitEventSource;
18
+ sessionId?: string;
19
+ transcript: string;
20
+ emitMinimalOnMiss?: boolean;
21
+ }): Promise<boolean>;