codiedev 0.9.52 → 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.
Files changed (48) hide show
  1. package/README.md +9 -1
  2. package/dist/captureLog.d.ts +1 -1
  3. package/dist/cli.js +17 -12
  4. package/dist/codie.js +146 -1
  5. package/dist/codieLauncher.d.ts +5 -0
  6. package/dist/codieLauncher.js +22 -1
  7. package/dist/codieRuntime.d.ts +11 -1
  8. package/dist/codieRuntime.js +20 -3
  9. package/dist/codieRuntimeInstall.d.ts +12 -11
  10. package/dist/codieRuntimeInstall.js +3 -1
  11. package/dist/codieStartup.d.ts +26 -0
  12. package/dist/codieStartup.js +30 -0
  13. package/dist/commands/doctor.js +8 -0
  14. package/dist/commands/security.d.ts +1 -0
  15. package/dist/commands/security.js +156 -0
  16. package/dist/connect.js +56 -4
  17. package/dist/cursorHooks.d.ts +23 -0
  18. package/dist/cursorHooks.js +81 -0
  19. package/dist/hook.js +272 -26
  20. package/dist/limitEvent.d.ts +5 -1
  21. package/dist/limitEvent.js +5 -1
  22. package/dist/mcp.js +1 -93
  23. package/dist/repoResolver.js +10 -4
  24. package/dist/security.d.ts +102 -0
  25. package/dist/security.js +437 -0
  26. package/dist/securityDeepScan.d.ts +41 -0
  27. package/dist/securityDeepScan.js +248 -0
  28. package/dist/securityDoctor.d.ts +32 -0
  29. package/dist/securityDoctor.js +63 -0
  30. package/dist/securityFeature.d.ts +5 -0
  31. package/dist/securityFeature.js +7 -0
  32. package/dist/securityInstall.d.ts +63 -0
  33. package/dist/securityInstall.js +239 -0
  34. package/dist/securityInventory.d.ts +54 -0
  35. package/dist/securityInventory.js +556 -0
  36. package/dist/securityPilot.d.ts +19 -0
  37. package/dist/securityPilot.js +61 -0
  38. package/dist/securityPolicy.d.ts +33 -0
  39. package/dist/securityPolicy.js +188 -0
  40. package/dist/securitySession.d.ts +6 -0
  41. package/dist/securitySession.js +72 -0
  42. package/dist/upgradeNudge.d.ts +1 -0
  43. package/dist/upgradeNudge.js +1 -0
  44. package/dist/utils.d.ts +16 -6
  45. package/dist/utils.js +311 -70
  46. package/package.json +14 -3
  47. package/dist/commands/post.d.ts +0 -2
  48. package/dist/commands/post.js +0 -96
package/dist/utils.js CHANGED
@@ -64,6 +64,7 @@ exports.openCodeInstalled = openCodeInstalled;
64
64
  exports.installOpenCodeCapturePlugin = installOpenCodeCapturePlugin;
65
65
  exports.vscodeCopilotInstalled = vscodeCopilotInstalled;
66
66
  exports.upsertCodiedevHook = upsertCodiedevHook;
67
+ exports.removeCodiedevHook = removeCodiedevHook;
67
68
  exports.installHook = installHook;
68
69
  exports.ensureDiscoveryHookInstalled = ensureDiscoveryHookInstalled;
69
70
  exports.ensureBackupHookInstalled = ensureBackupHookInstalled;
@@ -84,6 +85,7 @@ exports.installCodexMcp = installCodexMcp;
84
85
  exports.installCodexHook = installCodexHook;
85
86
  exports.ensureCodexLimitHookInstalled = ensureCodexLimitHookInstalled;
86
87
  exports.installCursorHook = installCursorHook;
88
+ exports.ensureCursorCodiedevHooksInstalled = ensureCursorCodiedevHooksInstalled;
87
89
  exports.parseClaudeSteering = parseClaudeSteering;
88
90
  exports.parseCodexSteering = parseCodexSteering;
89
91
  exports.parseSteering = parseSteering;
@@ -99,6 +101,8 @@ exports.dispatchDeferredUpload = dispatchDeferredUpload;
99
101
  exports.sweepPendingCaptures = sweepPendingCaptures;
100
102
  exports.resolveCaptureFinal = resolveCaptureFinal;
101
103
  exports.finishInputTmpFile = finishInputTmpFile;
104
+ exports.captureUploadTmpFile = captureUploadTmpFile;
105
+ exports.cancelDeferredCaptureUpload = cancelDeferredCaptureUpload;
102
106
  exports.decideDispatch = decideDispatch;
103
107
  exports.readSessionUploadState = readSessionUploadState;
104
108
  exports.writeSessionUploadState = writeSessionUploadState;
@@ -109,6 +113,7 @@ exports.readLastCapture = readLastCapture;
109
113
  exports.runCaptureUpload = runCaptureUpload;
110
114
  exports.runDeferredUpload = runDeferredUpload;
111
115
  exports.transcriptReadSkipReason = transcriptReadSkipReason;
116
+ exports.shouldReportCaptureSkip = shouldReportCaptureSkip;
112
117
  const fs = __importStar(require("fs"));
113
118
  const path = __importStar(require("path"));
114
119
  const os = __importStar(require("os"));
@@ -852,7 +857,7 @@ function buildCapturePayload(input) {
852
857
  sessionStartedAt: input.sessionStartedAt ?? undefined,
853
858
  interruptCount: input.interruptCount,
854
859
  rejectionCount: input.rejectionCount,
855
- // final is only emitted when explicitly set (not for Codex/Cursor).
860
+ // final is only emitted when explicitly set (omitted for Codex Stop).
856
861
  final: input.final,
857
862
  costUsd: input.costUsd,
858
863
  };
@@ -1052,10 +1057,15 @@ function openCodeInstalled() {
1052
1057
  /** Install a dormant global OpenCode adapter. Installing it unconditionally
1053
1058
  * keeps `codiedev connect` as the only setup step even when Codie/OpenCode is
1054
1059
  * installed later. OpenCode loads this file from its documented plugin dir. */
1055
- function installOpenCodeCapturePlugin() {
1060
+ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1056
1061
  const start = resolveHookInvocation("session-start-opencode");
1057
1062
  const capture = resolveHookInvocation("capture-opencode");
1058
1063
  const finish = resolveHookInvocation("capture-opencode-final");
1064
+ const backup = resolveHookInvocation("backup");
1065
+ const prompt = resolveHookInvocation("discover-codie");
1066
+ const securityPre = resolveHookInvocation("security-pre-codie");
1067
+ const securityPost = resolveHookInvocation("security-post-codie");
1068
+ const securityFailure = resolveHookInvocation("security-failure-codie");
1059
1069
  const source = `// Managed by codiedev connect. Safe to remove and reinstall.\n` +
1060
1070
  `import { spawn, spawnSync } from "node:child_process";\n` +
1061
1071
  `import { mkdir, writeFile } from "node:fs/promises";\n` +
@@ -1064,8 +1074,15 @@ function installOpenCodeCapturePlugin() {
1064
1074
  `const START = ${JSON.stringify(start)};\n` +
1065
1075
  `const CAPTURE = ${JSON.stringify(capture)};\n` +
1066
1076
  `const FINISH = ${JSON.stringify(finish)};\n` +
1077
+ `const BACKUP = ${JSON.stringify(backup)};\n` +
1078
+ `const PROMPT = ${JSON.stringify(prompt)};\n` +
1079
+ `const SECURITY_PRE = ${JSON.stringify(securityPre)};\n` +
1080
+ `const SECURITY_POST = ${JSON.stringify(securityPost)};\n` +
1081
+ `const SECURITY_FAILURE = ${JSON.stringify(securityFailure)};\n` +
1082
+ `const AGENT_SECURITY_ENABLED = ${JSON.stringify(agentSecurityEnabled)};\n` +
1067
1083
  `const fingerprints = new Map();\n\n` +
1068
1084
  `const transcriptPaths = new Map();\n\n` +
1085
+ `const sessionDirectories = new Map();\n\n` +
1069
1086
  `function sessionId(event) {\n` +
1070
1087
  ` return event?.properties?.sessionID ?? event?.properties?.sessionId ?? event?.properties?.info?.id ?? null;\n` +
1071
1088
  `}\n\n` +
@@ -1079,19 +1096,74 @@ function installOpenCodeCapturePlugin() {
1079
1096
  `function dispatchSync(invocation, payload) {\n` +
1080
1097
  ` try { spawnSync(invocation.command, invocation.args, { input: JSON.stringify(payload), stdio: ["pipe", "ignore", "ignore"], timeout: 1500 }); } catch {}\n` +
1081
1098
  `}\n\n` +
1099
+ `function invokeSync(invocation, payload) {\n` +
1100
+ ` try {\n` +
1101
+ ` const result = spawnSync(invocation.command, invocation.args, { input: JSON.stringify(payload), encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], timeout: 1500 });\n` +
1102
+ ` if (result.error) return null;\n` +
1103
+ ` return JSON.parse(result.stdout || "{}");\n` +
1104
+ ` } catch { return null; }\n` +
1105
+ `}\n\n` +
1082
1106
  `export const CodiedevCapture = async ({ client, directory, worktree }) => {\n` +
1107
+ ` const enforce = async (invocation, payload) => {\n` +
1108
+ ` const decision = invokeSync(invocation, payload);\n` +
1109
+ ` const denied = decision?.decision === "block" || decision?.hookSpecificOutput?.permissionDecision === "deny";\n` +
1110
+ ` const reason = denied ? (decision?.reason || decision?.hookSpecificOutput?.permissionDecisionReason || "Codie security policy blocked this action") : null;\n` +
1111
+ ` if (!reason) return decision;\n` +
1112
+ ` try { await client.tui.showToast({ title: "Codie Security", message: reason, variant: "error", duration: 10000 }); } catch {}\n` +
1113
+ ` if (process.env.CODIEDEV_SECURITY_BLOCK_OUTPUT === "marker") {\n` +
1114
+ ` process.stderr.write("__CODIEDEV_SECURITY_BLOCK__" + JSON.stringify({ reason }) + "\\n");\n` +
1115
+ ` process.exit(1);\n` +
1116
+ ` } else {\n` +
1117
+ ` process.stderr.write("\\nCodie Security blocked this action:\\n" + reason + "\\n");\n` +
1118
+ ` }\n` +
1119
+ ` throw new Error(reason);\n` +
1120
+ ` };\n` +
1121
+ ` const resolveCwd = async (id, hint) => {\n` +
1122
+ ` if (typeof hint === "string" && hint && hint !== "/") { sessionDirectories.set(id, hint); return hint; }\n` +
1123
+ ` const remembered = sessionDirectories.get(id);\n` +
1124
+ ` if (remembered) return remembered;\n` +
1125
+ ` try {\n` +
1126
+ ` const response = await client.session.get({ path: { id } });\n` +
1127
+ ` const session = response?.data ?? response;\n` +
1128
+ ` if (typeof session?.directory === "string" && session.directory) { sessionDirectories.set(id, session.directory); return session.directory; }\n` +
1129
+ ` } catch {}\n` +
1130
+ ` const fallback = worktree || directory || process.cwd();\n` +
1131
+ ` sessionDirectories.set(id, fallback);\n` +
1132
+ ` return fallback;\n` +
1133
+ ` };\n` +
1083
1134
  ` const finalizeAll = () => {\n` +
1084
1135
  ` for (const [id, transcriptPath] of transcriptPaths) {\n` +
1085
- ` const cwd = worktree || directory || process.cwd();\n` +
1136
+ ` const cwd = sessionDirectories.get(id) || worktree || directory || process.cwd();\n` +
1086
1137
  ` dispatchSync(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
1087
1138
  ` }\n` +
1088
1139
  ` };\n` +
1089
1140
  ` process.once("exit", finalizeAll);\n` +
1090
1141
  ` return ({\n` +
1142
+ ` "chat.message": async (input, output) => {\n` +
1143
+ ` const id = input?.sessionID ?? input?.sessionId ?? "unknown";\n` +
1144
+ ` const cwd = await resolveCwd(id);\n` +
1145
+ ` const prompt = Array.isArray(output?.parts) ? output.parts.filter((part) => part?.type === "text").map((part) => part.text || "").join("\\n") : "";\n` +
1146
+ ` const decision = await enforce(PROMPT, { session_id: id, cwd, prompt });\n` +
1147
+ ` const context = decision?.hookSpecificOutput?.additionalContext;\n` +
1148
+ ` if (context && Array.isArray(output?.parts)) output.parts.push({ type: "text", text: context });\n` +
1149
+ ` },\n` +
1150
+ ` "tool.execute.before": async (input, output) => {\n` +
1151
+ ` if (!AGENT_SECURITY_ENABLED) return;\n` +
1152
+ ` const id = input?.sessionID ?? input?.sessionId ?? "unknown";\n` +
1153
+ ` const cwd = await resolveCwd(id);\n` +
1154
+ ` await enforce(SECURITY_PRE, { sessionId: id, cwd, toolName: input?.tool ?? "unknown", toolUseId: input?.callID, toolInput: output?.args ?? output });\n` +
1155
+ ` },\n` +
1156
+ ` "tool.execute.after": async (input, output) => {\n` +
1157
+ ` if (!AGENT_SECURITY_ENABLED) return;\n` +
1158
+ ` const id = input?.sessionID ?? input?.sessionId ?? "unknown";\n` +
1159
+ ` const cwd = await resolveCwd(id);\n` +
1160
+ ` const payload = { sessionId: id, cwd, toolName: input?.tool ?? "unknown", toolUseId: input?.callID, toolInput: input?.args, toolResponse: output };\n` +
1161
+ ` dispatch(output?.error ? SECURITY_FAILURE : SECURITY_POST, payload);\n` +
1162
+ ` },\n` +
1091
1163
  ` event: async ({ event }) => {\n` +
1092
1164
  ` const id = sessionId(event);\n` +
1093
1165
  ` if (!id) return;\n` +
1094
- ` const cwd = worktree || directory || process.cwd();\n` +
1166
+ ` const cwd = await resolveCwd(id, event?.properties?.info?.directory);\n` +
1095
1167
  ` if (event.type === "session.created") {\n` +
1096
1168
  ` dispatch(START, { session_id: id, cwd, workspace_roots: [cwd] });\n` +
1097
1169
  ` return;\n` +
@@ -1100,6 +1172,7 @@ function installOpenCodeCapturePlugin() {
1100
1172
  ` const transcriptPath = transcriptPaths.get(id);\n` +
1101
1173
  ` if (transcriptPath) dispatch(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
1102
1174
  ` transcriptPaths.delete(id);\n` +
1175
+ ` sessionDirectories.delete(id);\n` +
1103
1176
  ` fingerprints.delete(id);\n` +
1104
1177
  ` return;\n` +
1105
1178
  ` }\n` +
@@ -1119,6 +1192,7 @@ function installOpenCodeCapturePlugin() {
1119
1192
  ` await writeFile(transcriptPath, JSON.stringify(list), { mode: 0o600 });\n` +
1120
1193
  ` transcriptPaths.set(id, transcriptPath);\n` +
1121
1194
  ` dispatch(CAPTURE, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
1195
+ ` dispatch(BACKUP, { session_id: id, cwd, workspace_roots: [cwd] });\n` +
1122
1196
  ` } catch {}\n` +
1123
1197
  ` },\n` +
1124
1198
  ` });\n` +
@@ -1144,7 +1218,7 @@ function vscodeCopilotInstalled() {
1144
1218
  return false;
1145
1219
  }
1146
1220
  }
1147
- // Idempotently put a single codiedev hook (matcher ".*", running our hook.js
1221
+ // Idempotently put a single codiedev hook (matcher ".*" by default, running our hook.js
1148
1222
  // with `subcommand`) into the given hook event, stripping any prior codiedev
1149
1223
  // entries first so connect re-runs / upgrades refresh the resolved binary path.
1150
1224
  // Mutates `hooks` in place. Exported for unit testing.
@@ -1176,12 +1250,41 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
1176
1250
  return !(0, detection_1.isCodiedevHookCommand)(hook.matcher);
1177
1251
  });
1178
1252
  filtered.push({
1179
- matcher,
1253
+ ...(matcher === null ? {} : { matcher }),
1180
1254
  hooks: [{ type: "command", command: resolveHookCommand(subcommand, preservedNodePath) }],
1181
1255
  });
1182
1256
  hooks[eventName] = filtered;
1183
1257
  }
1184
- function installHook() {
1258
+ /** Remove Codiedev's entries for one event while preserving foreign hooks. */
1259
+ function removeCodiedevHook(hooks, eventName) {
1260
+ const entries = hooks[eventName];
1261
+ if (!Array.isArray(entries))
1262
+ return false;
1263
+ let changed = false;
1264
+ const filtered = entries.flatMap((entry) => {
1265
+ const record = entry;
1266
+ const inner = record.hooks;
1267
+ if (!Array.isArray(inner))
1268
+ return [record];
1269
+ const remaining = inner.filter((hook) => {
1270
+ const remove = (0, detection_1.isCodiedevHookCommand)(hook.command);
1271
+ if (remove)
1272
+ changed = true;
1273
+ return !remove;
1274
+ });
1275
+ if (remaining.length === 0)
1276
+ return [];
1277
+ return [{ ...record, hooks: remaining }];
1278
+ });
1279
+ if (!changed)
1280
+ return false;
1281
+ if (filtered.length === 0)
1282
+ delete hooks[eventName];
1283
+ else
1284
+ hooks[eventName] = filtered;
1285
+ return true;
1286
+ }
1287
+ function installHook(agentSecurityEnabled = true) {
1185
1288
  let settings = {};
1186
1289
  try {
1187
1290
  if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
@@ -1198,6 +1301,18 @@ function installHook() {
1198
1301
  const hooks = settings.hooks;
1199
1302
  // SessionEnd → capture (upload the finished session for the team library).
1200
1303
  upsertCodiedevHook(hooks, "SessionEnd", "capture");
1304
+ // Tool boundaries → ADR shadow detection. PreToolUse is the eventual
1305
+ // enforcement point; alert mode always returns allow.
1306
+ if (agentSecurityEnabled) {
1307
+ upsertCodiedevHook(hooks, "PreToolUse", "security-pre");
1308
+ upsertCodiedevHook(hooks, "PostToolUse", "security-post");
1309
+ upsertCodiedevHook(hooks, "PostToolUseFailure", "security-failure");
1310
+ }
1311
+ else {
1312
+ removeCodiedevHook(hooks, "PreToolUse");
1313
+ removeCodiedevHook(hooks, "PostToolUse");
1314
+ removeCodiedevHook(hooks, "PostToolUseFailure");
1315
+ }
1201
1316
  // UserPromptSubmit → discover (surface a matching team skill for the task).
1202
1317
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover");
1203
1318
  // Stop → backup (auto-back-up changed artifact files to your private library).
@@ -1299,7 +1414,7 @@ function ensureLimitHookInstalled() {
1299
1414
  // shrinks that window and removes the intra-process version entirely.
1300
1415
  // The per-hook ensure* functions below are kept for tests/back-compat but are
1301
1416
  // no longer called from the hook entrypoint.
1302
- function ensureAllCodiedevHooksInstalled() {
1417
+ function ensureAllCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.agentSecurityEnabled === true) {
1303
1418
  try {
1304
1419
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
1305
1420
  return; // not a Claude Code user
@@ -1312,8 +1427,17 @@ function ensureAllCodiedevHooksInstalled() {
1312
1427
  ["StopFailure", "limit", "rate_limit"],
1313
1428
  ["SessionStart", "session-start"],
1314
1429
  ];
1430
+ if (agentSecurityEnabled) {
1431
+ wanted.splice(1, 0, ["PreToolUse", "security-pre"], ["PostToolUse", "security-post"], ["PostToolUseFailure", "security-failure"]);
1432
+ }
1433
+ let changed = false;
1434
+ if (!agentSecurityEnabled) {
1435
+ for (const eventName of ["PreToolUse", "PostToolUse", "PostToolUseFailure"]) {
1436
+ changed = removeCodiedevHook(hooks, eventName) || changed;
1437
+ }
1438
+ }
1315
1439
  const missing = wanted.filter(([eventName]) => !hasCodiedevHook(hooks, eventName));
1316
- if (missing.length === 0)
1440
+ if (missing.length === 0 && !changed)
1317
1441
  return; // steady state: no write, no churn
1318
1442
  settings.hooks = hooks;
1319
1443
  for (const [eventName, subcommand, matcher] of missing) {
@@ -1392,7 +1516,6 @@ thought, use the \`codiedev\` CLI via Bash:**
1392
1516
  | "note that X is a follow-up" / "remember X" | \`codiedev note "<text>"\` |
1393
1517
  | "promote the extracted spec" | \`codiedev promote <artifact-id>\` |
1394
1518
  | "create a ticket" / "scope this into tickets" / "ticket this for me" | \`codiedev create-ticket [optional-inline-prompt]\` |
1395
- | "post this to the team" / "publish to the feed" / "share with the team" | \`codiedev post --title "<t>" --body "<b>" [--intent <intent>] [--filename <key>] [--mention <name>]\` |
1396
1519
  | "share with a teammate" / "give someone access" (no notification) | \`codiedev share <key> <name> [--role read\\|edit]\` |
1397
1520
  | "send this to a teammate" / "ask someone to look at this" | \`codiedev send <key> <name> ["<msg>"]\` |
1398
1521
  | "find something about X" / "has anyone solved Y?" | \`codiedev search "<query>" [--limit N]\` |
@@ -1426,31 +1549,6 @@ read it back to the user so they can confirm.
1426
1549
  **Teammate names:** first name usually works (\`codiedev ping <name> ...\`).
1427
1550
  If ambiguous, the CLI returns candidates — retry with the full email.
1428
1551
 
1429
- **Feed posts (broad team reach):** use \`codiedev post\` when the user
1430
- wants visibility beyond one teammate — e.g., announcing a new skill,
1431
- asking the team who knows about X, proposing a change. Always pass
1432
- \`--intent\` to match the request: \`share\` (here's a skill),
1433
- \`request_review\`, \`request_expertise\` (stuck, who knows this),
1434
- \`link_share\` (fyi), or \`rfc\` (proposing a change). Pass \`--filename\`
1435
- to link a specific artifact and \`--mention <name>\` (repeatable) to
1436
- tag teammates.
1437
-
1438
- **Default feed-post shape.** When the user explicitly says "push/share/post
1439
- to the feed", default to this shape unless they ask for something else:
1440
- - **Title** ≤10 words: *what + why now*. Imperative or declarative — never
1441
- "Check this out".
1442
- - **Body** — three short lines, grounded in the current session and the
1443
- artifact:
1444
- - **Why this exists** — 1–2 sentences from session context (the problem
1445
- or insight that produced the artifact).
1446
- - **What's in it** — 1 sentence pulled from the artifact's frontmatter
1447
- \`description:\` (skills) or first paragraph (specs). Don't restate
1448
- the whole file — the card preview carries the detail.
1449
- - **Ask** — 1 sentence. *Use it / review it / ping me with thoughts.*
1450
- - **Filename**: always attach so the card preview renders.
1451
- This is the default. If the user wants a one-liner or a different
1452
- structure, follow their lead.
1453
-
1454
1552
  **Search before pushing.** For any "save this as …" request, run
1455
1553
  \`codiedev search "<query>"\` first. If prior art exists, pull it and
1456
1554
  iterate rather than duplicating.
@@ -1754,7 +1852,7 @@ function installCodexMcp() {
1754
1852
  // Codex doesn't have a SessionEnd event yet — Stop fires after every turn.
1755
1853
  // The backend dedupes on (companyId, sessionId) and replaces the stored
1756
1854
  // transcript on re-upload, so the latest turn's snapshot wins.
1757
- function installCodexHook() {
1855
+ function installCodexHook(agentSecurityEnabled = true) {
1758
1856
  let hooksFile = {};
1759
1857
  try {
1760
1858
  if (fs.existsSync(CODEX_HOOKS_PATH)) {
@@ -1769,9 +1867,8 @@ function installCodexHook() {
1769
1867
  hooksFile.hooks = {};
1770
1868
  }
1771
1869
  const hooks = hooksFile.hooks;
1772
- if (!hooks.Stop) {
1870
+ if (!hooks.Stop)
1773
1871
  hooks.Stop = [];
1774
- }
1775
1872
  const existing = hooks.Stop;
1776
1873
  const filtered = existing.filter((hook) => {
1777
1874
  const inner = hook.hooks;
@@ -1779,6 +1876,9 @@ function installCodexHook() {
1779
1876
  return true;
1780
1877
  return !inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command));
1781
1878
  });
1879
+ // Codex has no StopFailure matcher. Its Stop transcript includes a
1880
+ // structured rate-limit snapshot, so this tiny hook reports only when that
1881
+ // snapshot says a boundary was reached.
1782
1882
  filtered.push({
1783
1883
  hooks: [
1784
1884
  {
@@ -1808,6 +1908,24 @@ function installCodexHook() {
1808
1908
  ],
1809
1909
  });
1810
1910
  hooks.Stop = filtered;
1911
+ // Codex uses the same Claude-style matcher-group shape. Keep these as
1912
+ // separate entries because Codex hook coverage is still evolving and a
1913
+ // malformed/unavailable post hook must not interfere with PreToolUse.
1914
+ if (agentSecurityEnabled) {
1915
+ upsertCodiedevHook(hooks, "PreToolUse", "security-pre-codex");
1916
+ upsertCodiedevHook(hooks, "PostToolUse", "security-post-codex");
1917
+ upsertCodiedevHook(hooks, "PostToolUseFailure", "security-failure-codex");
1918
+ }
1919
+ else {
1920
+ removeCodiedevHook(hooks, "PreToolUse");
1921
+ removeCodiedevHook(hooks, "PostToolUse");
1922
+ removeCodiedevHook(hooks, "PostToolUseFailure");
1923
+ }
1924
+ // Prompt guard + skill discovery. The guard runs locally before any prompt
1925
+ // content can reach either the model or Codiedev's discovery endpoint.
1926
+ // Codex models UserPromptSubmit as a non-matcher event (the trust-key model
1927
+ // intentionally excludes it from MATCHER_EVENTS), so omit the matcher key.
1928
+ upsertCodiedevHook(hooks, "UserPromptSubmit", "discover-codex", null);
1811
1929
  if (!fs.existsSync(CODEX_DIR)) {
1812
1930
  fs.mkdirSync(CODEX_DIR, { recursive: true });
1813
1931
  }
@@ -1815,7 +1933,7 @@ function installCodexHook() {
1815
1933
  (0, codexHookTrust_1.writeCodexHookTrust)(CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, hooksFile);
1816
1934
  }
1817
1935
  // Existing Codex users already have capture-codex on Stop. After a CLI
1818
- // upgrade, that hook repairs the new limit reporter without requiring connect.
1936
+ // upgrade, that hook repairs newer lifecycle hooks without requiring connect.
1819
1937
  function ensureCodexLimitHookInstalled() {
1820
1938
  try {
1821
1939
  if (!fs.existsSync(CODEX_HOOKS_PATH))
@@ -1823,16 +1941,31 @@ function ensureCodexLimitHookInstalled() {
1823
1941
  const hooksFile = JSON.parse(fs.readFileSync(CODEX_HOOKS_PATH, "utf8"));
1824
1942
  const hooks = (hooksFile.hooks ?? {});
1825
1943
  const stop = Array.isArray(hooks.Stop) ? hooks.Stop : [];
1826
- const present = stop.some((group) => {
1827
- const inner = group.hooks;
1828
- return Array.isArray(inner) && inner.some((hook) => typeof hook.command === "string" && /(?:^|\s)limit-codex(?:\s|$)/.test(hook.command));
1944
+ const hasCommand = (entries, subcommand) => entries.some((group) => {
1945
+ const record = group;
1946
+ const flat = record.command;
1947
+ if (typeof flat === "string" && flat.trim().endsWith(subcommand))
1948
+ return true;
1949
+ const inner = record.hooks;
1950
+ return Array.isArray(inner) && inner.some((hook) => typeof hook.command === "string" && hook.command.trim().endsWith(subcommand));
1829
1951
  });
1830
- if (present)
1952
+ let changed = false;
1953
+ if (!hasCommand(stop, "limit-codex")) {
1954
+ stop.unshift({
1955
+ hooks: [{ type: "command", command: resolveHookCommand("limit-codex"), timeout: 10 }],
1956
+ });
1957
+ hooks.Stop = stop;
1958
+ changed = true;
1959
+ }
1960
+ const promptEntries = Array.isArray(hooks.UserPromptSubmit)
1961
+ ? hooks.UserPromptSubmit
1962
+ : [];
1963
+ if (!hasCommand(promptEntries, "discover-codex")) {
1964
+ upsertCodiedevHook(hooks, "UserPromptSubmit", "discover-codex", null);
1965
+ changed = true;
1966
+ }
1967
+ if (!changed)
1831
1968
  return;
1832
- stop.unshift({
1833
- hooks: [{ type: "command", command: resolveHookCommand("limit-codex"), timeout: 10 }],
1834
- });
1835
- hooks.Stop = stop;
1836
1969
  hooksFile.hooks = hooks;
1837
1970
  fs.writeFileSync(CODEX_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
1838
1971
  // Trust keys include each hook's positional index. Adding the new hook at
@@ -1847,7 +1980,7 @@ function ensureCodexLimitHookInstalled() {
1847
1980
  // `sessionEnd` fires when a conversation closes (completed, aborted, error,
1848
1981
  // window_close, user_close); the base stdin payload includes transcript_path
1849
1982
  // and session_id which `codiedev-hook capture-cursor` reads to upload.
1850
- function installCursorHook() {
1983
+ function installCursorHook(agentSecurityEnabled = true) {
1851
1984
  if (!fs.existsSync(CURSOR_DIR)) {
1852
1985
  fs.mkdirSync(CURSOR_DIR, { recursive: true });
1853
1986
  }
@@ -1868,25 +2001,89 @@ function installCursorHook() {
1868
2001
  hooksFile.hooks = {};
1869
2002
  }
1870
2003
  const hooks = hooksFile.hooks;
1871
- if (!hooks.sessionEnd) {
1872
- hooks.sessionEnd = [];
2004
+ const replaceManaged = (eventName, subcommands) => {
2005
+ const existing = Array.isArray(hooks[eventName])
2006
+ ? hooks[eventName]
2007
+ : [];
2008
+ const preserved = existing.filter((hook) => !(0, detection_1.isCodiedevHookCommand)(hook.command));
2009
+ const managed = subcommands.map((subcommand) => ({
2010
+ command: resolveHookCommand(subcommand),
2011
+ type: "command",
2012
+ timeout: 30,
2013
+ }));
2014
+ if (preserved.length + managed.length === 0)
2015
+ delete hooks[eventName];
2016
+ else
2017
+ hooks[eventName] = [...preserved, ...managed];
2018
+ };
2019
+ // Cursor-native lifecycle names and flat hook entries. Capture only at the
2020
+ // real IDE session boundary; backup at every completed agent turn. The
2021
+ // Cursor-specific backup entrypoint keeps that non-final snapshot labeled
2022
+ // and normalized as Cursor rather than accidentally treating it as Claude.
2023
+ replaceManaged("sessionStart", ["session-start-cursor"]);
2024
+ replaceManaged("sessionEnd", ["capture-cursor"]);
2025
+ replaceManaged("stop", ["backup-cursor"]);
2026
+ if (agentSecurityEnabled) {
2027
+ // Cursor supports blocking here, but not dynamic context injection.
2028
+ replaceManaged("beforeSubmitPrompt", ["security-prompt-cursor"]);
2029
+ replaceManaged("preToolUse", ["security-pre-cursor"]);
2030
+ replaceManaged("postToolUse", ["security-post-cursor"]);
2031
+ replaceManaged("postToolUseFailure", ["security-failure-cursor"]);
2032
+ }
2033
+ else {
2034
+ replaceManaged("beforeSubmitPrompt", []);
2035
+ replaceManaged("preToolUse", []);
2036
+ replaceManaged("postToolUse", []);
2037
+ replaceManaged("postToolUseFailure", []);
1873
2038
  }
1874
- const existing = hooks.sessionEnd;
1875
- const filtered = existing.filter((h) => !(0, detection_1.isCodiedevHookCommand)(h.command));
1876
- filtered.push({
1877
- command: resolveHookCommand("capture-cursor"),
1878
- type: "command",
1879
- timeout: 30,
1880
- });
1881
- // sessionEnd → backup: auto-back-up changed artifact files to the private library.
1882
- filtered.push({
1883
- command: resolveHookCommand("backup"),
1884
- type: "command",
1885
- timeout: 30,
1886
- });
1887
- hooks.sessionEnd = filtered;
1888
2039
  fs.writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
1889
2040
  }
2041
+ /** Repair Cursor's complete flat hook set after upgrades or configuration
2042
+ * drift. This is deliberately separate from Claude Code's nested-hook healer:
2043
+ * the schemas and event names differ, and mixing them risks corrupting both. */
2044
+ function ensureCursorCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.agentSecurityEnabled === true) {
2045
+ try {
2046
+ if (!fs.existsSync(CURSOR_HOOKS_PATH))
2047
+ return;
2048
+ const parsed = JSON.parse(fs.readFileSync(CURSOR_HOOKS_PATH, "utf8"));
2049
+ const hooks = parsed.hooks ?? {};
2050
+ const has = (eventName, subcommand) => {
2051
+ const entries = hooks[eventName];
2052
+ return Array.isArray(entries) && entries.some((entry) => {
2053
+ const command = entry.command;
2054
+ return typeof command === "string"
2055
+ && (0, detection_1.isCodiedevHookCommand)(command)
2056
+ && command.trim().endsWith(subcommand);
2057
+ });
2058
+ };
2059
+ const required = [
2060
+ ["sessionStart", "session-start-cursor"],
2061
+ ["sessionEnd", "capture-cursor"],
2062
+ ["stop", "backup-cursor"],
2063
+ ...(agentSecurityEnabled
2064
+ ? [
2065
+ ["beforeSubmitPrompt", "security-prompt-cursor"],
2066
+ ["preToolUse", "security-pre-cursor"],
2067
+ ["postToolUse", "security-post-cursor"],
2068
+ ["postToolUseFailure", "security-failure-cursor"],
2069
+ ]
2070
+ : []),
2071
+ ];
2072
+ const hasStaleSecurity = !agentSecurityEnabled && [
2073
+ "beforeSubmitPrompt", "preToolUse", "postToolUse", "postToolUseFailure",
2074
+ ].some((eventName) => {
2075
+ const entries = hooks[eventName];
2076
+ return Array.isArray(entries) && entries.some((entry) => (0, detection_1.isCodiedevHookCommand)(entry.command));
2077
+ });
2078
+ if (required.every(([eventName, subcommand]) => has(eventName, subcommand)) && !hasStaleSecurity) {
2079
+ return;
2080
+ }
2081
+ installCursorHook(agentSecurityEnabled);
2082
+ }
2083
+ catch {
2084
+ // Best-effort — a malformed third-party config must never block capture.
2085
+ }
2086
+ }
1890
2087
  function parseClaudeSteering(content) {
1891
2088
  let interrupts = 0;
1892
2089
  let rejections = 0;
@@ -2497,13 +2694,13 @@ exports.CAPTURE_THROTTLE_MS = 10 * 60 * 1000;
2497
2694
  // retry storms.
2498
2695
  exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS + 2 * 60 * 1000;
2499
2696
  // Map the hook's capture argv to the payload's `final` flag. EXPLICIT
2500
- // ALLOWLIST: only claude-code's SessionEnd entrypoints ("capture", or the bare
2501
- // hook with no subcommand) are a true final signal. capture-opencode/
2697
+ // ALLOWLIST: Claude Code and Cursor SessionEnd entrypoints are true final
2698
+ // signals. capture-opencode/
2502
2699
  // capture-desktop (per-turn snapshot) and capture-opencode-final/
2503
2700
  // capture-desktop-final (real session end) get an explicit false/true.
2504
2701
  // Everything else — capture-codex (Codex's Stop fires per-turn; final:true
2505
- // would close the server-side session lease every turn), capture-cursor, and
2506
- // any FUTURE mode (e.g. a capture-windsurf added to resolveMode but not
2702
+ // would close the server-side session lease every turn), and any FUTURE mode
2703
+ // (e.g. a capture-windsurf added to resolveMode but not
2507
2704
  // audited here) — gets undefined, i.e. legacy server behaviour. Defaulting
2508
2705
  // unknowns to true would silently re-introduce the per-turn lease-close bug
2509
2706
  // for every new agent.
@@ -2512,7 +2709,9 @@ function resolveCaptureFinal(argv2) {
2512
2709
  return false;
2513
2710
  if (argv2 === "capture-opencode-final" || argv2 === "capture-desktop-final")
2514
2711
  return true;
2515
- return argv2 === "capture" || argv2 === undefined ? true : undefined;
2712
+ return argv2 === "capture" || argv2 === "capture-cursor" || argv2 === undefined
2713
+ ? true
2714
+ : undefined;
2516
2715
  }
2517
2716
  // Build a UNIQUE finish-input tmpFile name per dispatch. Both the Stop hook and
2518
2717
  // SessionEnd dispatch capture-finish children, and the child unlinks its input
@@ -2525,7 +2724,35 @@ function resolveCaptureFinal(argv2) {
2525
2724
  // negligible volume (one tiny JSON per crashed child), though note Windows
2526
2725
  // %TEMP% is not auto-cleaned — acceptable.
2527
2726
  function finishInputTmpFile(sessionId) {
2528
- return path.join(os.tmpdir(), `codiedev-finish-${sessionId ?? process.pid}-${process.pid}-${Date.now()}.json`);
2727
+ const safeId = String(sessionId ?? process.pid).replace(/[^A-Za-z0-9._-]/g, "_");
2728
+ return path.join(os.tmpdir(), `codiedev-finish-${safeId}-${process.pid}-${Date.now()}.json`);
2729
+ }
2730
+ // Immediate uploads must also own unique job files. A Codie/OpenCode runtime
2731
+ // emits idle + final captures almost simultaneously on process exit; when both
2732
+ // children shared codiedev-capture-<sid>.json, their uploader grandchildren
2733
+ // raced to read/unlink the same file and could lose the final handoff entirely.
2734
+ // Deferred uploads intentionally keep the stable per-session name because
2735
+ // overwriting it is how newer turns supersede older scheduled snapshots.
2736
+ function captureUploadTmpFile(sessionId, mode, pid = process.pid, now = Date.now()) {
2737
+ const id = String(sessionId ?? pid).replace(/[^A-Za-z0-9._-]/g, "_");
2738
+ return path.join(os.tmpdir(), mode === "immediate"
2739
+ ? `codiedev-capture-${id}-${pid}-${now}.json`
2740
+ : `codiedev-capture-${id}.json`);
2741
+ }
2742
+ // Best-effort cancellation for a scheduled per-turn upload when a real final
2743
+ // capture arrives. Deferred jobs deliberately share one canonical filename;
2744
+ // removing it makes every sleeping uploader lose its later claim-by-rename.
2745
+ // The backend's capturedAt guard covers the narrow race where a child claimed
2746
+ // the file immediately before this unlink.
2747
+ function cancelDeferredCaptureUpload(sessionId, deps = fs) {
2748
+ try {
2749
+ deps.unlinkSync(captureUploadTmpFile(sessionId, "deferred"));
2750
+ return true;
2751
+ }
2752
+ catch {
2753
+ // Missing means there was nothing pending (or another process won).
2754
+ return false;
2755
+ }
2529
2756
  }
2530
2757
  // Decide whether to upload immediately or defer until the throttle window expires.
2531
2758
  // final=true → always immediate (SessionEnd: close the session now)
@@ -2690,6 +2917,14 @@ async function runCaptureUpload(jobFile, deps) {
2690
2917
  const { storageId } = await deps.uploadBytes(uploadUrl, Buffer.from(transcript, "base64"), "application/json");
2691
2918
  body = { ...body, transcriptStorageId: storageId };
2692
2919
  delete body.transcript;
2920
+ // Persist the storage id before the metadata POST. Previously a failed
2921
+ // captureSession request retried the entire direct upload, creating one
2922
+ // unreferenced raw transcript blob per attempt. Once the PUT succeeds,
2923
+ // every retry must reuse that exact blob.
2924
+ job.body = body;
2925
+ if (deps.writeFileSync) {
2926
+ deps.writeFileSync(jobFile, JSON.stringify(job), { mode: 0o600 });
2927
+ }
2693
2928
  }
2694
2929
  await deps.post(job.url, body, job.token);
2695
2930
  // success — drop a "last session captured" breadcrumb (before removing the
@@ -2842,3 +3077,9 @@ function transcriptReadSkipReason(err) {
2842
3077
  return "no-transcript";
2843
3078
  return `transcript-unreadable:${code || "unknown"}`;
2844
3079
  }
3080
+ // Per-turn snapshots are backups, not session endings. Reporting their local
3081
+ // read/empty condition as a session skip would hide or close coverage while the
3082
+ // agent is still running; only final/legacy capture paths emit the skip ping.
3083
+ function shouldReportCaptureSkip(final) {
3084
+ return final !== false;
3085
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.52",
3
+ "version": "0.10.0-beta.1",
4
4
  "description": "Connect Claude Code, Codex, Cursor, OpenCode, or VS Code Copilot to Codiedev for team session capture and artifact collaboration — and run the governed Codie harness",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",
@@ -12,7 +12,12 @@
12
12
  "dev": "tsc --watch",
13
13
  "prepublishOnly": "tsc",
14
14
  "test": "vitest run",
15
- "test:functional": "vitest run src/__tests__/limitEvent.functional.test.ts",
15
+ "test:functional": "vitest run src/__tests__/limitEvent.functional.test.ts src/__tests__/harnessParity.functional.test.ts src/__tests__/cursorParity.functional.test.ts",
16
+ "test:harness-parity": "vitest run src/__tests__/harnessParity.functional.test.ts src/__tests__/cursorParity.functional.test.ts",
17
+ "test:harness-binaries": "node scripts/checkHarnessBinaries.mjs",
18
+ "test:security-beta": "vitest run src/__tests__/security.test.ts src/__tests__/securityCommandEntitlement.test.ts src/__tests__/securityDeepScan.test.ts src/__tests__/securityDoctor.test.ts src/__tests__/securityEntitlementInstall.test.ts src/__tests__/securityFeature.test.ts src/__tests__/securityInstall.test.ts src/__tests__/securityInventory.test.ts src/__tests__/securityPilot.test.ts src/__tests__/securityPolicy.test.ts src/__tests__/securitySession.test.ts src/__tests__/codexHookTrust.test.ts src/__tests__/codieSecurityAdapterWiring.test.ts src/__tests__/limitEvent.functional.test.ts && tsc && node dist/cli.js security pilot --json",
19
+ "test:security-pilot": "vitest run src/__tests__/security.test.ts src/__tests__/securityInstall.test.ts src/__tests__/securityPilot.test.ts src/__tests__/codieSecurityAdapterWiring.test.ts && tsc && node dist/cli.js security pilot --json",
20
+ "test:opencode-attribution": "vitest run src/__tests__/repoResolver.test.ts src/__tests__/openCodeCapture.test.ts src/__tests__/openCodePluginGitRefs.test.ts src/__tests__/capturePayloadFinal.test.ts src/__tests__/captureDirectUpload.test.ts src/__tests__/resolveCaptureFinal.test.ts && tsc",
16
21
  "test:watch": "vitest"
17
22
  },
18
23
  "files": [
@@ -36,12 +41,18 @@
36
41
  "session-capture"
37
42
  ],
38
43
  "homepage": "https://codiedev.com",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/Signal-and-Code/codiedev.git",
47
+ "directory": "packages/codiedev-cli"
48
+ },
39
49
  "license": "MIT",
40
50
  "engines": {
41
51
  "node": ">=18"
42
52
  },
43
53
  "publishConfig": {
44
- "access": "public"
54
+ "access": "public",
55
+ "provenance": true
45
56
  },
46
57
  "devDependencies": {
47
58
  "@types/node": "^20.0.0",
@@ -1,2 +0,0 @@
1
- import type { CodiedevConfig } from "../utils";
2
- export declare function runPost(args: string[], configOverride?: CodiedevConfig): Promise<void>;