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.
- package/README.md +8 -1
- package/dist/captureLog.d.ts +1 -1
- package/dist/cli.js +7 -22
- package/dist/codie.js +1 -146
- package/dist/codieLauncher.d.ts +0 -5
- package/dist/codieLauncher.js +1 -22
- package/dist/codieRuntime.d.ts +1 -11
- package/dist/codieRuntime.js +3 -20
- package/dist/codieRuntimeInstall.d.ts +11 -12
- package/dist/codieRuntimeInstall.js +1 -3
- package/dist/commands/doctor.js +0 -8
- package/dist/connect.js +4 -53
- package/dist/cursorHooks.d.ts +9 -0
- package/dist/cursorHooks.js +56 -0
- package/dist/hook.js +95 -227
- package/dist/limitEvent.d.ts +4 -1
- package/dist/limitEvent.js +54 -0
- package/dist/limitEventReporter.d.ts +21 -0
- package/dist/limitEventReporter.js +91 -0
- package/dist/mcp.js +1 -93
- package/dist/repoResolver.js +1 -1
- package/dist/upgradeNudge.d.ts +0 -1
- package/dist/upgradeNudge.js +0 -1
- package/dist/utils.d.ts +10 -8
- package/dist/utils.js +174 -199
- package/package.json +5 -11
- package/dist/codieStartup.d.ts +0 -24
- package/dist/codieStartup.js +0 -29
- package/dist/commands/post.d.ts +0 -2
- package/dist/commands/post.js +0 -96
- package/dist/commands/security.d.ts +0 -1
- package/dist/commands/security.js +0 -156
- package/dist/security.d.ts +0 -97
- package/dist/security.js +0 -422
- package/dist/securityDeepScan.d.ts +0 -41
- package/dist/securityDeepScan.js +0 -248
- package/dist/securityDoctor.d.ts +0 -32
- package/dist/securityDoctor.js +0 -63
- package/dist/securityFeature.d.ts +0 -5
- package/dist/securityFeature.js +0 -7
- package/dist/securityInstall.d.ts +0 -62
- package/dist/securityInstall.js +0 -227
- package/dist/securityInventory.d.ts +0 -54
- package/dist/securityInventory.js +0 -556
- package/dist/securityPilot.d.ts +0 -19
- package/dist/securityPilot.js +0 -61
- package/dist/securityPolicy.d.ts +0 -32
- package/dist/securityPolicy.js +0 -179
- package/dist/securitySession.d.ts +0 -6
- 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
|
|
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
|
|
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
|
|
109
|
-
let transcriptTail;
|
|
117
|
+
let transcript = "";
|
|
110
118
|
const tp = hookInput.transcript_path;
|
|
111
119
|
if (tp) {
|
|
112
120
|
try {
|
|
113
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
resetText: payload.resetText,
|
|
128
|
+
await (0, limitEventReporter_1.reportTranscriptLimitEvent)({
|
|
129
|
+
config,
|
|
130
|
+
source,
|
|
131
131
|
sessionId: hookInput.session_id,
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
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" ||
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
(
|
|
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.
|
|
727
|
-
//
|
|
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
|
-
|
|
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
|
-
:
|
|
843
|
-
|
|
844
|
-
|
|
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 =
|
|
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.
|
package/dist/limitEvent.d.ts
CHANGED
|
@@ -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;
|
package/dist/limitEvent.js
CHANGED
|
@@ -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>;
|