codiedev 0.9.51 → 0.10.0-beta.1

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/README.md CHANGED
@@ -12,7 +12,7 @@ The CLI will prompt you for an API token (get one at https://codiedev.com/portal
12
12
 
13
13
  - **Claude Code** — `SessionEnd` hook in `~/.claude/settings.json`
14
14
  - **Codex** — `Stop` hook in `~/.codex/hooks.json`
15
- - **Cursor** — `sessionEnd` hook in `~/.cursor/hooks.json`
15
+ - **Cursor** — native session, prompt-security, tool-security, backup, and capture hooks in `~/.cursor/hooks.json`
16
16
  - **OpenCode / Codie** — plugin in `~/.config/opencode/plugins/codiedev.js`
17
17
 
18
18
  Installed clients are wired automatically. The OpenCode adapter is staged even
@@ -49,6 +49,14 @@ container needs its own rule unless `~/.codiedev/config.json` is persisted.
49
49
 
50
50
  Codex doesn't currently expose a `SessionEnd` event, so the hook runs on `Stop` (fires after each assistant turn). The backend dedupes by `(company, session_id)` and replaces the stored transcript on re-upload so the latest turn's snapshot wins. Summarization happens once per session; subsequent turn uploads keep the raw transcript fresh but don't re-summarize (to avoid running up LLM cost).
51
51
 
52
+ ## Cursor note
53
+
54
+ Cursor uses its native hook events for session start/end, prompt blocking,
55
+ pre/post-tool security, and stop-time backup. Cursor's `beforeSubmitPrompt`
56
+ cannot inject dynamic context today, so task-specific discovery is not claimed.
57
+ Cursor sessions keep their source
58
+ label and real transcript statistics through the shared capture pipeline.
59
+
52
60
  ## OpenCode note
53
61
 
54
62
  OpenCode exposes session lifecycle events through plugins. Codiedev snapshots
package/dist/cli.js CHANGED
@@ -29,7 +29,6 @@ const createTicket_1 = require("./commands/createTicket");
29
29
  const doctor_1 = require("./commands/doctor");
30
30
  const search_1 = require("./commands/search");
31
31
  const library_1 = require("./commands/library");
32
- const post_1 = require("./commands/post");
33
32
  const share_1 = require("./commands/share");
34
33
  const send_1 = require("./commands/send");
35
34
  const react_1 = require("./commands/react");
@@ -44,7 +43,7 @@ Codiedev CLI
44
43
  someone left off across sessions and providers.
45
44
 
46
45
  Connect:
47
- codiedev connect Link Claude Code / Codex to your org
46
+ codiedev connect Link Claude Code / Codex / Cursor to your org
48
47
  codiedev doctor Verify setup, capture, and Agent Security
49
48
  codiedev security pilot Run safe synthetic policy checks
50
49
  codiedev security inventory Inventory MCPs, skills, hooks, plugins,
@@ -105,9 +104,6 @@ Discovery:
105
104
  List artifacts in your workspace
106
105
 
107
106
  Feed:
108
- codiedev post --title "<t>" --body "<b>" [--intent share|request_review|
109
- request_expertise|link_share|rfc] [--filename <key>]
110
- [--mention <name>]... Publish to the team feed
111
107
  codiedev react <postId> <emoji> Toggle a reaction on a feed post
112
108
 
113
109
  Privacy:
@@ -135,7 +131,7 @@ Other artifact types (rare — use the matching prefix):
135
131
 
136
132
  Skills (slash commands):
137
133
  Add 'name:' + 'description:' YAML frontmatter to any pushed file
138
- and it shows up as /<name> in Claude Code / Codex. Filename
134
+ and it shows up as /<name> in Claude Code / Codex / Cursor. Filename
139
135
  prefix doesn't matter — frontmatter is the signal.
140
136
 
141
137
  Examples:
@@ -172,6 +168,8 @@ codiedev connect — link your agent CLI to Codiedev
172
168
  - ~/.claude/CLAUDE.md instructions (teaches Claude the commands)
173
169
  - ~/.codex/hooks.json hook (same, for Codex)
174
170
  - ~/.codex/AGENTS.md instructions (same, for Codex)
171
+ - ~/.cursor/hooks.json hooks (session, prompt, security, capture)
172
+ - ~/.cursor/rules/codiedev.mdc (teaches Cursor the commands)
175
173
 
176
174
  Re-run anytime to swap accounts or refresh the instruction block.
177
175
 
@@ -374,7 +372,7 @@ Default path:
374
372
  # body...
375
373
  3. codiedev push portal-design.md
376
374
 
377
- It's now /portal-design in Claude Code / Codex.
375
+ It's now /portal-design in Claude Code / Codex / Cursor.
378
376
 
379
377
  Filename prefix is irrelevant for skills — frontmatter is the only
380
378
  signal. The stored artifact type still follows the filename rule
@@ -392,7 +390,7 @@ email notifications when pinged.
392
390
 
393
391
  ## Agent-native
394
392
 
395
- If you use Claude Code or Codex, the agent already knows the commands —
393
+ If you use Claude Code, Codex, or Cursor, the agent already knows the commands —
396
394
  'codiedev connect' installs instructions into your user-level config.
397
395
  Just say things like:
398
396
 
@@ -513,9 +511,6 @@ async function main() {
513
511
  case "ls":
514
512
  await (0, library_1.runLibrary)(rest);
515
513
  return;
516
- case "post":
517
- await (0, post_1.runPost)(rest);
518
- return;
519
514
  case "share":
520
515
  await (0, share_1.runShare)(rest);
521
516
  return;
@@ -4,6 +4,7 @@ export interface CodieStartupConfig extends AgentSecurityEntitlement {
4
4
  backendUrl?: string;
5
5
  token?: string;
6
6
  companyId?: string;
7
+ deviceId?: string;
7
8
  }
8
9
  export type CodiePolicyRefreshResult = {
9
10
  status: "disabled";
@@ -21,4 +22,5 @@ export declare function refreshCodieSecurityPolicy(config: CodieStartupConfig, s
21
22
  backendUrl: string;
22
23
  token: string;
23
24
  companyId: string;
25
+ deviceId?: string;
24
26
  }, source?: AgentSurface) => Promise<EndpointSecurityPolicy>): Promise<CodiePolicyRefreshResult>;
@@ -17,6 +17,7 @@ async function refreshCodieSecurityPolicy(config, syncPolicy = securityPolicy_1.
17
17
  backendUrl: config.backendUrl,
18
18
  token: config.token,
19
19
  companyId: config.companyId,
20
+ ...(config.deviceId ? { deviceId: config.deviceId } : {}),
20
21
  }, "codie-harness");
21
22
  return { status: "synced", policy };
22
23
  }
package/dist/connect.js CHANGED
@@ -291,6 +291,7 @@ async function runConnect(args = []) {
291
291
  const securityConfigPaths = [
292
292
  path.join(home, ".claude", "settings.json"),
293
293
  path.join(home, ".codex", "hooks.json"),
294
+ path.join(home, ".cursor", "hooks.json"),
294
295
  path.join(home, ".config", "opencode", "plugins", "codiedev.js"),
295
296
  path.join(home, ".codex", "config.toml"),
296
297
  ];
@@ -327,7 +328,7 @@ async function runConnect(args = []) {
327
328
  runInstall("codex-instructions", utils_1.installCodexInstructions, "Codex agent instructions (~/.codex/AGENTS.md)", "codex-instructions");
328
329
  }
329
330
  if (hasCursor) {
330
- runInstall("cursor-hook", utils_1.installCursorHook, "Cursor sessionEnd (capture + auto-backup) hooks (~/.cursor/hooks.json)", "cursor-hook");
331
+ runInstall("cursor-hook", () => (0, utils_1.installCursorHook)(agentSecurityEnabled), "Cursor sessionStart + prompt security + stop backup + sessionEnd capture hooks (~/.cursor/hooks.json)", "cursor-hook");
331
332
  runInstall("cursor-instructions", utils_1.installCursorInstructions, "Cursor agent instructions (~/.cursor/rules/codiedev.mdc)", "cursor-instructions");
332
333
  }
333
334
  // Always stage the adapter. It is dormant until OpenCode starts, which lets
@@ -345,8 +346,10 @@ async function runConnect(args = []) {
345
346
  targets.push({ surface: "claude-code", configPath: securityConfigPaths[0] });
346
347
  if (installedTargets.includes("codex-hook"))
347
348
  targets.push({ surface: "codex", configPath: securityConfigPaths[1] });
349
+ if (installedTargets.includes("cursor-hook"))
350
+ targets.push({ surface: "cursor", configPath: securityConfigPaths[2] });
348
351
  if (installedTargets.includes("opencode-capture"))
349
- targets.push({ surface: "codie-harness", configPath: securityConfigPaths[2] });
352
+ targets.push({ surface: "codie-harness", configPath: securityConfigPaths[3] });
350
353
  (0, securityInstall_1.writeSecurityInstallManifest)({ hookScriptPath, snapshot: securitySnapshot, targets });
351
354
  }
352
355
  }
@@ -0,0 +1,23 @@
1
+ /** Cursor's native hook payload is close to Claude Code's, but its stable
2
+ * conversation identifier and several tool fields use different names. Keep
3
+ * the translation at the process boundary so the shared capture/security
4
+ * pipeline never has to guess which surface produced an event. */
5
+ export declare function normalizeCursorHookInput<T extends Record<string, unknown>>(input: T): T & Record<string, unknown>;
6
+ /** Cursor stores JSONL message roles in `role`; the shared capture pipeline
7
+ * uses Claude's top-level `type` discriminator. Preserve all original fields
8
+ * and add only that discriminator so the existing parsers see real messages. */
9
+ export declare function normalizeCursorTranscript(content: string): string;
10
+ /** Cursor may change its private JSONL record shape independently of the hook
11
+ * contract. Only a byte-empty snapshot is safe to discard; a non-empty file
12
+ * that the current normalizer cannot parse must still be uploaded for backend
13
+ * recovery and diagnosis. */
14
+ export declare function isCursorTranscriptEmpty(content: string): boolean;
15
+ export declare function cursorPreToolBlockResponse(reason: string): {
16
+ permission: "deny";
17
+ user_message: string;
18
+ agent_message: string;
19
+ };
20
+ export declare function cursorPromptBlockResponse(reason: string): {
21
+ continue: false;
22
+ user_message: string;
23
+ };
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeCursorHookInput = normalizeCursorHookInput;
4
+ exports.normalizeCursorTranscript = normalizeCursorTranscript;
5
+ exports.isCursorTranscriptEmpty = isCursorTranscriptEmpty;
6
+ exports.cursorPreToolBlockResponse = cursorPreToolBlockResponse;
7
+ exports.cursorPromptBlockResponse = cursorPromptBlockResponse;
8
+ /** Cursor's native hook payload is close to Claude Code's, but its stable
9
+ * conversation identifier and several tool fields use different names. Keep
10
+ * the translation at the process boundary so the shared capture/security
11
+ * pipeline never has to guess which surface produced an event. */
12
+ function normalizeCursorHookInput(input) {
13
+ const sessionId = firstString(input.session_id, input.sessionId, input.conversation_id);
14
+ const roots = Array.isArray(input.workspace_roots)
15
+ ? input.workspace_roots.filter((root) => typeof root === "string")
16
+ : [];
17
+ const cwd = firstString(input.cwd, roots[0]);
18
+ const duration = firstNumber(input.duration_ms, input.durationMs, input.duration);
19
+ return {
20
+ ...input,
21
+ ...(sessionId ? { session_id: sessionId } : {}),
22
+ ...(cwd ? { cwd } : {}),
23
+ ...(input.tool_response === undefined && input.toolResponse === undefined && input.tool_output !== undefined
24
+ ? { tool_response: input.tool_output }
25
+ : {}),
26
+ ...(input.error === undefined && input.error_message !== undefined
27
+ ? { error: input.error_message }
28
+ : {}),
29
+ ...(duration !== undefined ? { duration_ms: duration } : {}),
30
+ };
31
+ }
32
+ /** Cursor stores JSONL message roles in `role`; the shared capture pipeline
33
+ * uses Claude's top-level `type` discriminator. Preserve all original fields
34
+ * and add only that discriminator so the existing parsers see real messages. */
35
+ function normalizeCursorTranscript(content) {
36
+ return content
37
+ .split("\n")
38
+ .map((line) => {
39
+ if (!line.trim())
40
+ return line;
41
+ try {
42
+ const entry = JSON.parse(line);
43
+ if ((entry.role === "user" || entry.role === "assistant") &&
44
+ entry.type === undefined) {
45
+ return JSON.stringify({ ...entry, type: entry.role });
46
+ }
47
+ }
48
+ catch {
49
+ // Preserve malformed/non-JSON lines; tolerant downstream parsers skip
50
+ // anything they cannot consume.
51
+ }
52
+ return line;
53
+ })
54
+ .join("\n");
55
+ }
56
+ /** Cursor may change its private JSONL record shape independently of the hook
57
+ * contract. Only a byte-empty snapshot is safe to discard; a non-empty file
58
+ * that the current normalizer cannot parse must still be uploaded for backend
59
+ * recovery and diagnosis. */
60
+ function isCursorTranscriptEmpty(content) {
61
+ return content.trim().length === 0;
62
+ }
63
+ function firstString(...values) {
64
+ return values.find((value) => typeof value === "string" && value.trim().length > 0);
65
+ }
66
+ function firstNumber(...values) {
67
+ return values.find((value) => typeof value === "number" && Number.isFinite(value));
68
+ }
69
+ function cursorPreToolBlockResponse(reason) {
70
+ return {
71
+ permission: "deny",
72
+ user_message: reason,
73
+ agent_message: reason,
74
+ };
75
+ }
76
+ function cursorPromptBlockResponse(reason) {
77
+ return {
78
+ continue: false,
79
+ user_message: reason,
80
+ };
81
+ }
package/dist/hook.js CHANGED
@@ -37,12 +37,11 @@ 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
47
  const security_1 = require("./security");
@@ -51,6 +50,7 @@ const securityPolicy_1 = require("./securityPolicy");
51
50
  const securityFeature_1 = require("./securityFeature");
52
51
  const securityInventory_1 = require("./securityInventory");
53
52
  const captureExclusions_1 = require("./captureExclusions");
53
+ const cursorHooks_1 = require("./cursorHooks");
54
54
  // Pure builder for the discovery additionalContext. Returns null when there are
55
55
  // no matches. The suggested `codiedev pull` command carries the EXACT top key
56
56
  // (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
@@ -98,43 +98,58 @@ async function runDiscover(prompt) {
98
98
  }
99
99
  process.exit(0);
100
100
  }
101
+ function readTranscriptTailFile(transcriptPath, maxBytes = 512 * 1024) {
102
+ const size = fs.statSync(transcriptPath).size;
103
+ if (size <= maxBytes)
104
+ return fs.readFileSync(transcriptPath, "utf8");
105
+ const handle = fs.openSync(transcriptPath, "r");
106
+ try {
107
+ const buffer = Buffer.allocUnsafe(maxBytes);
108
+ const bytesRead = fs.readSync(handle, buffer, 0, maxBytes, size - maxBytes);
109
+ return buffer.subarray(0, bytesRead).toString("utf8");
110
+ }
111
+ finally {
112
+ fs.closeSync(handle);
113
+ }
114
+ }
101
115
  // StopFailure (matcher rate_limit): the dev just got blocked. Read the local
102
116
  // transcript tail for the ground-truth rate_limit line and POST a tiny event.
103
117
  // Best-effort + silent — StopFailure output is ignored by Claude Code anyway.
104
- async function runStopFailure(hookInput) {
118
+ async function readAndPostLimitEvent(hookInput, source, emitMinimalOnMiss) {
105
119
  const config = (0, utils_1.readConfig)();
106
120
  if (!config)
107
121
  process.exit(0);
108
- let event = null;
109
- let transcriptTail;
122
+ let transcript = "";
110
123
  const tp = hookInput.transcript_path;
111
124
  if (tp) {
112
125
  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;
126
+ // Codex invokes Stop after every turn. Bound this read so long-running
127
+ // sessions do not repeatedly load their entire rollout into memory.
128
+ transcript = readTranscriptTailFile(tp);
118
129
  }
119
130
  catch {
120
131
  /* unreadable transcript — fall through to minimal event */
121
132
  }
122
133
  }
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
134
  try {
127
- await postJson(`${config.backendUrl}/api/cli/limitEvent`, {
128
- kind: payload.kind,
129
- at: payload.at,
130
- resetText: payload.resetText,
135
+ await (0, limitEventReporter_1.reportTranscriptLimitEvent)({
136
+ config,
137
+ source,
131
138
  sessionId: hookInput.session_id,
132
- transcriptTail,
133
- }, config.token);
139
+ transcript,
140
+ emitMinimalOnMiss,
141
+ });
134
142
  }
135
143
  catch {
136
144
  /* best-effort */
137
145
  }
146
+ }
147
+ async function runStopFailure(hookInput) {
148
+ await readAndPostLimitEvent(hookInput, "claude-code", true);
149
+ process.exit(0);
150
+ }
151
+ async function runCodexLimit(hookInput) {
152
+ await readAndPostLimitEvent(hookInput, "codex", false);
138
153
  process.exit(0);
139
154
  }
140
155
  // Skip-reason ping: this session is ending WITHOUT a capture, and here's why.
@@ -294,13 +309,16 @@ async function runSecurityHook(hookInput, phase, source, uploadTelemetry = true)
294
309
  });
295
310
  if (uploadTelemetry)
296
311
  dispatchSecurityEventUpload(event);
297
- process.stdout.write(JSON.stringify({
298
- hookSpecificOutput: {
299
- hookEventName: "PreToolUse",
300
- permissionDecision: "deny",
301
- permissionDecisionReason: localDecision.reason,
302
- },
303
- }));
312
+ const response = source === "cursor"
313
+ ? (0, cursorHooks_1.cursorPreToolBlockResponse)(localDecision.reason ?? "Codie blocked this action")
314
+ : {
315
+ hookSpecificOutput: {
316
+ hookEventName: "PreToolUse",
317
+ permissionDecision: "deny",
318
+ permissionDecisionReason: localDecision.reason,
319
+ },
320
+ };
321
+ process.stdout.write(JSON.stringify(response));
304
322
  return;
305
323
  }
306
324
  if (!event)
@@ -324,10 +342,10 @@ function runPromptSecurityHook(hookInput, source, uploadTelemetry = true) {
324
342
  if (uploadTelemetry) {
325
343
  dispatchSecurityEventUpload((0, security_1.buildBlockedSecurityPromptEvent)(hookInput, source, decision.category ?? "secret_in_prompt"));
326
344
  }
327
- process.stdout.write(JSON.stringify({
328
- decision: "block",
329
- reason: decision.reason,
330
- }));
345
+ const response = source === "cursor"
346
+ ? (0, cursorHooks_1.cursorPromptBlockResponse)(decision.reason ?? "Codie blocked this prompt")
347
+ : { decision: "block", reason: decision.reason };
348
+ process.stdout.write(JSON.stringify(response));
331
349
  return true;
332
350
  }
333
351
  function dispatchAllowedPromptSecurityEvent(hookInput, source) {
@@ -361,10 +379,13 @@ async function runSecurityEventUpload() {
361
379
  try {
362
380
  const raw = await readStdin();
363
381
  const event = JSON.parse(raw);
382
+ const cachedPolicy = (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId);
364
383
  const eventWithEndpoint = (0, security_1.withEndpointIdentity)(event, {
365
384
  endpointId: config.deviceId,
366
385
  endpointName: config.deviceName,
367
386
  cliVersion: version_1.CLI_VERSION,
387
+ securityRuntimeVersion: security_1.SECURITY_RUNTIME_VERSION,
388
+ securityPolicyVersion: cachedPolicy?.policyVersion,
368
389
  });
369
390
  const result = await postJsonForResponse(`${config.backendUrl}/api/cli/securityEvent`, { ...eventWithEndpoint }, config.token,
370
391
  // Leave room for a cold backend and durable scheduling. This uploader is
@@ -445,7 +466,7 @@ async function readStdin() {
445
466
  function resolveMode(arg) {
446
467
  if (arg === "capture-codex")
447
468
  return "codex";
448
- if (arg === "capture-cursor")
469
+ if (arg === "capture-cursor" || arg === "backup-cursor")
449
470
  return "cursor";
450
471
  if (arg === "capture-opencode" || arg === "capture-opencode-final")
451
472
  return "opencode";
@@ -574,7 +595,10 @@ async function main() {
574
595
  try {
575
596
  const raw = await readStdin();
576
597
  if (raw.trim()) {
577
- hookInput = JSON.parse(raw);
598
+ const parsed = JSON.parse(raw);
599
+ hookInput = (process.argv[2]?.endsWith("-cursor")
600
+ ? (0, cursorHooks_1.normalizeCursorHookInput)(parsed)
601
+ : parsed);
578
602
  }
579
603
  }
580
604
  catch {
@@ -585,18 +609,28 @@ async function main() {
585
609
  // disabled; all other hook modes still exit before making a network request.
586
610
  const captureIgnored = (0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput);
587
611
  const securityCommand = process.argv[2] ?? "";
588
- const securityMatch = securityCommand.match(/^security-(pre|post|failure)(?:-(codex|codie))?$/);
612
+ const securityMatch = securityCommand.match(/^security-(pre|post|failure)(?:-(codex|cursor|codie))?$/);
589
613
  if (securityMatch) {
590
614
  const source = securityMatch[2] === "codex"
591
615
  ? "codex"
592
- : securityMatch[2] === "codie"
593
- ? "codie-harness"
594
- : "claude-code";
616
+ : securityMatch[2] === "cursor"
617
+ ? "cursor"
618
+ : securityMatch[2] === "codie"
619
+ ? "codie-harness"
620
+ : "claude-code";
595
621
  await runSecurityHook(hookInput, securityMatch[1], source, !captureIgnored);
596
622
  process.exit(0);
597
623
  }
598
624
  // UserPromptSubmit discovery path — separate from session capture.
599
625
  const promptCommand = process.argv[2] ?? "";
626
+ if (promptCommand === "security-prompt-cursor") {
627
+ if (runPromptSecurityHook(hookInput, "cursor", !captureIgnored))
628
+ return;
629
+ if (captureIgnored)
630
+ return;
631
+ dispatchAllowedPromptSecurityEvent(hookInput, "cursor");
632
+ process.exit(0);
633
+ }
600
634
  if (process.argv[2] === "discover" ||
601
635
  process.argv[2] === "discover-codex" ||
602
636
  process.argv[2] === "discover-codie") {
@@ -622,8 +656,14 @@ async function main() {
622
656
  await runStopFailure(hookInput);
623
657
  return;
624
658
  }
659
+ if (process.argv[2] === "limit-codex") {
660
+ await runCodexLimit(hookInput);
661
+ return;
662
+ }
625
663
  // SessionStart: post the capture-coverage denominator ping. Tiny + fast.
626
- if (process.argv[2] === "session-start" || process.argv[2] === "session-start-opencode") {
664
+ if (process.argv[2] === "session-start" ||
665
+ process.argv[2] === "session-start-cursor" ||
666
+ process.argv[2] === "session-start-opencode") {
627
667
  if ((0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()) && (0, securityInventory_1.securityInventoryDue)()) {
628
668
  try {
629
669
  const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-inventory-upload", hookInput.cwd || process.cwd()], {
@@ -634,7 +674,11 @@ async function main() {
634
674
  }
635
675
  catch { /* daily inventory is best-effort */ }
636
676
  }
637
- await runSessionStart(hookInput, process.argv[2] === "session-start-opencode" ? "opencode" : "claude-code");
677
+ await runSessionStart(hookInput, process.argv[2] === "session-start-opencode"
678
+ ? "opencode"
679
+ : process.argv[2] === "session-start-cursor"
680
+ ? "cursor"
681
+ : "claude-code");
638
682
  return;
639
683
  }
640
684
  // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
@@ -642,7 +686,14 @@ async function main() {
642
686
  // no self-heal and could stay stripped forever while SessionStart kept
643
687
  // pinging (the Sonifi "starts arrive, captures never" loss) — re-wired on
644
688
  // their next Stop/SessionEnd. One read + one write, only when missing.
645
- (0, utils_1.ensureAllCodiedevHooksInstalled)();
689
+ if ((process.argv[2] ?? "").endsWith("-cursor")) {
690
+ (0, utils_1.ensureCursorCodiedevHooksInstalled)();
691
+ }
692
+ else {
693
+ (0, utils_1.ensureAllCodiedevHooksInstalled)();
694
+ }
695
+ if (process.argv[2] === "capture-codex")
696
+ (0, utils_1.ensureCodexLimitHookInstalled)();
646
697
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
647
698
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
648
699
  ? workspace_roots[0]
@@ -655,13 +706,11 @@ async function main() {
655
706
  // extension) where SessionEnd may never come — the silent zero-capture bug.
656
707
  // Same detached capture-finish child as SessionEnd, marked non-final so the
657
708
  // server renews the session lease instead of summarizing immediately.
658
- if (process.argv[2] === "backup") {
709
+ if (process.argv[2] === "backup" || process.argv[2] === "backup-cursor") {
659
710
  if (transcript_path) {
660
711
  try {
661
712
  (0, utils_1.dispatchCaptureFinish)({
662
- // "capture" = the claude-code entrypoint literal, same as the
663
- // SessionEnd path below (resolveMode maps it to claude-code).
664
- mode: "capture",
713
+ mode: process.argv[2] === "backup-cursor" ? "capture-cursor" : "capture",
665
714
  session_id,
666
715
  transcript_path,
667
716
  cwd,
@@ -721,10 +770,9 @@ async function main() {
721
770
  transcript_path,
722
771
  cwd,
723
772
  workspace_roots,
724
- // final:true ONLY for claude-code's SessionEnd. Codex routes its
725
- // 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).
773
+ // final:true for real SessionEnd signals (Claude Code and Cursor).
774
+ // Codex routes its per-turn Stop hook through "capture-codex" —
775
+ // final:true there would close the server lease every turn.
728
776
  final: (0, utils_1.resolveCaptureFinal)(process.argv[2]),
729
777
  }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile, openSync: fs.openSync, closeSync: fs.closeSync });
730
778
  }
@@ -801,7 +849,9 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
801
849
  outcome: "skip",
802
850
  detail: reason,
803
851
  });
804
- await postSessionSkip(config, session_id, reason);
852
+ if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
853
+ await postSessionSkip(config, session_id, reason);
854
+ }
805
855
  return;
806
856
  }
807
857
  // Snapshot the RAW transcript before normalization below flattens it to
@@ -813,6 +863,9 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
813
863
  if (parserMode === "opencode") {
814
864
  transcriptContent = (0, utils_1.normalizeOpenCodeTranscript)(transcriptContent);
815
865
  }
866
+ else if (parserMode === "cursor") {
867
+ transcriptContent = (0, cursorHooks_1.normalizeCursorTranscript)(transcriptContent);
868
+ }
816
869
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
817
870
  // need the git remote of the workspace.
818
871
  let remoteUrl = null;
@@ -839,9 +892,26 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
839
892
  const matchedRepo = remoteUrl ? (0, utils_1.matchRepo)(remoteUrl, config.repos) : null;
840
893
  const stats = parserMode === "codex"
841
894
  ? (0, utils_1.parseCodexStats)(transcriptContent)
842
- : parserMode === "cursor"
843
- ? { messageCount: 0, toolCallCount: 0 }
844
- : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
895
+ : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
896
+ // Cursor can emit duplicate lifecycle notifications while one transcript
897
+ // path is still byte-empty. Never use parsed message count here: Cursor's
898
+ // private JSONL shape can evolve independently, and parser drift must not
899
+ // silently discard a real non-empty session.
900
+ if (parserMode === "cursor" && (0, cursorHooks_1.isCursorTranscriptEmpty)(rawTranscriptContent)) {
901
+ (0, captureLog_1.appendCaptureLog)({
902
+ stage: "capture-finish",
903
+ sessionId: session_id,
904
+ outcome: "skip",
905
+ detail: "empty-cursor-transcript",
906
+ });
907
+ // A non-final stop snapshot is only a backup; reporting it as a skipped
908
+ // session would close/remove server-side start coverage while the session
909
+ // is still alive. The real SessionEnd remains responsible for a skip ping.
910
+ if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
911
+ await postSessionSkip(config, session_id, "empty-cursor-transcript");
912
+ }
913
+ return;
914
+ }
845
915
  const transcriptBase64 = Buffer.from(transcriptContent, "utf8").toString("base64");
846
916
  // Session→merge join keys: the branch + HEAD sha the work happened on. Matched
847
917
  // server-side against a merged MR/PR's source_branch / commits for AI attribution.
@@ -934,7 +1004,7 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
934
1004
  // which is the "supersede" mechanism for the deferred path.
935
1005
  try {
936
1006
  const hookScriptPath = process.argv[1];
937
- const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
1007
+ const tmpFile = (0, utils_1.captureUploadTmpFile)(session_id, decision.mode);
938
1008
  if (decision.mode === "immediate") {
939
1009
  (0, utils_1.dispatchCaptureDetached)({
940
1010
  url: `${config.backendUrl}/api/captureSession`,
@@ -942,6 +1012,11 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
942
1012
  body: payload,
943
1013
  scopePaths,
944
1014
  }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
1015
+ // The immediate payload is durable before removing the older scheduled
1016
+ // snapshot. Do this for every immediate dispatch, not only SessionEnd:
1017
+ // first/outside-window snapshots also supersede a sleeping deferred job.
1018
+ if (session_id)
1019
+ (0, utils_1.cancelDeferredCaptureUpload)(session_id);
945
1020
  // Retry any earlier capture jobs that failed to upload (transient network
946
1021
  // blips), excluding the one we just dispatched. Best-effort, detached.
947
1022
  // Skip the sweep on deferred path — the deferred child will handle uploading.
@@ -1,9 +1,16 @@
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
+ /**
11
+ * Codex writes a structured RateLimitSnapshot on token_count events. A normal
12
+ * turn has `rate_limit_reached_type: null`; a blocked turn names the exact
13
+ * boundary. Reading that field avoids locale-dependent terminal copy.
14
+ */
15
+ export declare function parseCodexLimitEventFromTranscript(transcript: string): LimitEvent | null;
9
16
  export declare function tailForLimitEvent(transcript: string, maxChars?: number): string;