codiedev 0.10.0-beta.1 → 0.10.0-beta.10

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 (155) hide show
  1. package/README.md +11 -5
  2. package/assets/opencode-plugin.mjs +6 -0
  3. package/assets/session-key.mjs +48 -0
  4. package/assets/tui-brand-plugin.tsx +73 -0
  5. package/dist/backup/cache.js +1 -5
  6. package/dist/backup/match.js +0 -6
  7. package/dist/backup/run.js +3 -35
  8. package/dist/captureExclusions.js +0 -12
  9. package/dist/captureLog.js +0 -28
  10. package/dist/captureReadiness.js +0 -58
  11. package/dist/captureSignals.js +184 -0
  12. package/dist/cli.js +135 -72
  13. package/dist/cliSession.js +456 -0
  14. package/dist/codexHookTrust.js +125 -6
  15. package/dist/codexLimitScan.js +359 -0
  16. package/dist/codie.js +77 -130
  17. package/dist/codieBrandInstall.js +80 -0
  18. package/dist/codieLauncher.js +76 -79
  19. package/dist/codieRuntime.js +19 -23
  20. package/dist/codieRuntimeInstall.js +108 -23
  21. package/dist/codieUsage.js +181 -0
  22. package/dist/commands/agentTemplates.generated.js +136 -0
  23. package/dist/commands/agents.js +1088 -0
  24. package/dist/commands/agentsSdk.generated.js +7 -0
  25. package/dist/commands/ask.js +0 -3
  26. package/dist/commands/capture.js +0 -2
  27. package/dist/commands/createTicket.js +0 -10
  28. package/dist/commands/doctor.js +53 -120
  29. package/dist/commands/evals.js +187 -0
  30. package/dist/commands/inbox.js +0 -2
  31. package/dist/commands/library.js +0 -3
  32. package/dist/commands/pack.js +573 -0
  33. package/dist/commands/ping.js +0 -2
  34. package/dist/commands/pull.js +6 -10
  35. package/dist/commands/push.js +2 -12
  36. package/dist/commands/search.js +0 -4
  37. package/dist/commands/send.js +0 -5
  38. package/dist/commands/share.js +0 -4
  39. package/dist/commands/shared.js +0 -35
  40. package/dist/commands/traces.js +344 -0
  41. package/dist/commands/tracesLabel.js +348 -0
  42. package/dist/configDir.js +18 -11
  43. package/dist/connect.js +55 -118
  44. package/dist/connectFlow.js +35 -9
  45. package/dist/copilotHooks.js +391 -0
  46. package/dist/copilotInstallation.js +64 -0
  47. package/dist/cursorHooks.js +38 -30
  48. package/dist/cursorLifecycle.js +109 -0
  49. package/dist/detection.js +0 -30
  50. package/dist/harnessDirs.js +77 -0
  51. package/dist/hook.js +256 -445
  52. package/dist/inboxFormat.js +0 -24
  53. package/dist/launchContext.js +150 -0
  54. package/dist/launcher.js +168 -0
  55. package/dist/limitEvent.js +44 -13
  56. package/dist/limitEventReporter.js +1 -1
  57. package/dist/managedPi.js +256 -0
  58. package/dist/mcp.js +5 -384
  59. package/dist/nativeSteering.js +24 -0
  60. package/dist/packs/codie.js +182 -0
  61. package/dist/packs/harnesses.js +411 -0
  62. package/dist/packs/hookGate.js +256 -0
  63. package/dist/packs/install.js +286 -0
  64. package/dist/packs/manifest.js +126 -0
  65. package/dist/packs/paths.js +81 -0
  66. package/dist/packs/shim.js +214 -0
  67. package/dist/packs/store.js +155 -0
  68. package/dist/packs/sync.js +170 -0
  69. package/dist/piHooks.js +216 -0
  70. package/dist/piRuntime.js +120 -0
  71. package/dist/repoResolver.js +0 -19
  72. package/dist/secret.js +0 -3
  73. package/dist/selfUpdate.js +376 -0
  74. package/dist/skillMarker.js +23 -0
  75. package/dist/subagents.js +135 -0
  76. package/dist/teamSkillContext.js +10 -0
  77. package/dist/transcriptModels.js +205 -0
  78. package/dist/upgradeNudge.js +0 -30
  79. package/dist/utils.js +502 -828
  80. package/dist/version.js +0 -10
  81. package/dist/versionCheck.js +0 -33
  82. package/package.json +9 -10
  83. package/dist/backup/cache.d.ts +0 -14
  84. package/dist/backup/match.d.ts +0 -2
  85. package/dist/backup/run.d.ts +0 -25
  86. package/dist/captureExclusions.d.ts +0 -30
  87. package/dist/captureLog.d.ts +0 -20
  88. package/dist/captureReadiness.d.ts +0 -65
  89. package/dist/cli.d.ts +0 -16
  90. package/dist/codexHookTrust.d.ts +0 -34
  91. package/dist/codie.d.ts +0 -2
  92. package/dist/codieLauncher.d.ts +0 -53
  93. package/dist/codieRuntime.d.ts +0 -29
  94. package/dist/codieRuntimeInstall.d.ts +0 -28
  95. package/dist/codieStartup.d.ts +0 -26
  96. package/dist/codieStartup.js +0 -30
  97. package/dist/commands/ask.d.ts +0 -1
  98. package/dist/commands/capture.d.ts +0 -14
  99. package/dist/commands/createTicket.d.ts +0 -27
  100. package/dist/commands/delete.d.ts +0 -1
  101. package/dist/commands/doctor.d.ts +0 -1
  102. package/dist/commands/inbox.d.ts +0 -3
  103. package/dist/commands/library.d.ts +0 -2
  104. package/dist/commands/note.d.ts +0 -1
  105. package/dist/commands/ping.d.ts +0 -1
  106. package/dist/commands/promote.d.ts +0 -1
  107. package/dist/commands/promote.js +0 -53
  108. package/dist/commands/pull.d.ts +0 -2
  109. package/dist/commands/push.d.ts +0 -33
  110. package/dist/commands/react.d.ts +0 -2
  111. package/dist/commands/react.js +0 -40
  112. package/dist/commands/reverseTicket.d.ts +0 -2
  113. package/dist/commands/reverseTicket.js +0 -477
  114. package/dist/commands/search.d.ts +0 -2
  115. package/dist/commands/security.d.ts +0 -1
  116. package/dist/commands/security.js +0 -156
  117. package/dist/commands/send.d.ts +0 -2
  118. package/dist/commands/share.d.ts +0 -2
  119. package/dist/commands/shared.d.ts +0 -64
  120. package/dist/configDir.d.ts +0 -1
  121. package/dist/connect.d.ts +0 -1
  122. package/dist/connectFlow.d.ts +0 -13
  123. package/dist/cursorHooks.d.ts +0 -23
  124. package/dist/detection.d.ts +0 -30
  125. package/dist/hook.d.ts +0 -6
  126. package/dist/inboxFormat.d.ts +0 -38
  127. package/dist/limitEvent.d.ts +0 -16
  128. package/dist/limitEventReporter.d.ts +0 -21
  129. package/dist/mcp.d.ts +0 -12
  130. package/dist/prUrl.d.ts +0 -9
  131. package/dist/prUrl.js +0 -33
  132. package/dist/repoResolver.d.ts +0 -17
  133. package/dist/secret.d.ts +0 -1
  134. package/dist/security.d.ts +0 -102
  135. package/dist/security.js +0 -437
  136. package/dist/securityDeepScan.d.ts +0 -41
  137. package/dist/securityDeepScan.js +0 -248
  138. package/dist/securityDoctor.d.ts +0 -32
  139. package/dist/securityDoctor.js +0 -63
  140. package/dist/securityFeature.d.ts +0 -5
  141. package/dist/securityFeature.js +0 -7
  142. package/dist/securityInstall.d.ts +0 -63
  143. package/dist/securityInstall.js +0 -239
  144. package/dist/securityInventory.d.ts +0 -54
  145. package/dist/securityInventory.js +0 -556
  146. package/dist/securityPilot.d.ts +0 -19
  147. package/dist/securityPilot.js +0 -61
  148. package/dist/securityPolicy.d.ts +0 -33
  149. package/dist/securityPolicy.js +0 -188
  150. package/dist/securitySession.d.ts +0 -6
  151. package/dist/securitySession.js +0 -72
  152. package/dist/upgradeNudge.d.ts +0 -25
  153. package/dist/utils.d.ts +0 -342
  154. package/dist/version.d.ts +0 -1
  155. package/dist/versionCheck.d.ts +0 -68
package/dist/utils.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DIRECT_UPLOAD_THRESHOLD_CHARS = exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.FINISH_ORPHAN_AGE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = exports.CONFIG_PATH = exports.codiedevStateDir = void 0;
36
+ exports.DIRECT_UPLOAD_THRESHOLD_CHARS = exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.FINISH_ORPHAN_AGE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = exports.PI_EXTENSION_SUBCOMMANDS = exports.CONFIG_PATH = exports.codiedevStateDir = exports.piExtensionPath = exports.inspectPiExtension = void 0;
37
37
  exports.readConfig = readConfig;
38
38
  exports.writeConfig = writeConfig;
39
39
  exports.writeConfigAtPath = writeConfigAtPath;
@@ -53,6 +53,7 @@ exports.parseAuthoredBranches = parseAuthoredBranches;
53
53
  exports.parseAuthoredShas = parseAuthoredShas;
54
54
  exports.getGitBranch = getGitBranch;
55
55
  exports.getGitHeadSha = getGitHeadSha;
56
+ exports.stripRepoUrlCredentials = stripRepoUrlCredentials;
56
57
  exports.buildCapturePayload = buildCapturePayload;
57
58
  exports.hashToken = hashToken;
58
59
  exports.extractHookNodePath = extractHookNodePath;
@@ -60,8 +61,14 @@ exports.resetStableNodeCacheForTests = resetStableNodeCacheForTests;
60
61
  exports.claudeCodeInstalled = claudeCodeInstalled;
61
62
  exports.codexInstalled = codexInstalled;
62
63
  exports.cursorInstalled = cursorInstalled;
64
+ exports.copilotInstalled = copilotInstalled;
63
65
  exports.openCodeInstalled = openCodeInstalled;
64
66
  exports.installOpenCodeCapturePlugin = installOpenCodeCapturePlugin;
67
+ exports.ensureOpenCodePluginCurrent = ensureOpenCodePluginCurrent;
68
+ exports.piInstalled = piInstalled;
69
+ exports.piExtensionSource = piExtensionSource;
70
+ exports.installPiExtension = installPiExtension;
71
+ exports.ensurePiExtensionInstalled = ensurePiExtensionInstalled;
65
72
  exports.vscodeCopilotInstalled = vscodeCopilotInstalled;
66
73
  exports.upsertCodiedevHook = upsertCodiedevHook;
67
74
  exports.removeCodiedevHook = removeCodiedevHook;
@@ -70,10 +77,18 @@ exports.ensureDiscoveryHookInstalled = ensureDiscoveryHookInstalled;
70
77
  exports.ensureBackupHookInstalled = ensureBackupHookInstalled;
71
78
  exports.ensureLimitHookInstalled = ensureLimitHookInstalled;
72
79
  exports.ensureAllCodiedevHooksInstalled = ensureAllCodiedevHooksInstalled;
80
+ exports.hasDirectHookPath = hasDirectHookPath;
81
+ exports.migrateHooksToLauncher = migrateHooksToLauncher;
73
82
  exports.ensureSessionStartHookInstalled = ensureSessionStartHookInstalled;
74
83
  exports.installClaudeCodeInstructions = installClaudeCodeInstructions;
75
84
  exports.installCodexInstructions = installCodexInstructions;
85
+ exports.copilotInstructionsPath = copilotInstructionsPath;
86
+ exports.piInstructionsPath = piInstructionsPath;
87
+ exports.installPiInstructions = installPiInstructions;
88
+ exports.installCopilotInstructions = installCopilotInstructions;
76
89
  exports.installCursorInstructions = installCursorInstructions;
90
+ exports.installedInstructionsTargets = installedInstructionsTargets;
91
+ exports.refreshInstalledInstructions = refreshInstalledInstructions;
77
92
  exports.installClaudeCodeMcp = installClaudeCodeMcp;
78
93
  exports.installCursorMcp = installCursorMcp;
79
94
  exports.installVSCodeMcp = installVSCodeMcp;
@@ -85,6 +100,9 @@ exports.installCodexMcp = installCodexMcp;
85
100
  exports.installCodexHook = installCodexHook;
86
101
  exports.ensureCodexLimitHookInstalled = ensureCodexLimitHookInstalled;
87
102
  exports.installCursorHook = installCursorHook;
103
+ exports.copilotHooksPath = copilotHooksPath;
104
+ exports.installCopilotHook = installCopilotHook;
105
+ exports.ensureCopilotCodiedevHooksInstalled = ensureCopilotCodiedevHooksInstalled;
88
106
  exports.ensureCursorCodiedevHooksInstalled = ensureCursorCodiedevHooksInstalled;
89
107
  exports.parseClaudeSteering = parseClaudeSteering;
90
108
  exports.parseCodexSteering = parseCodexSteering;
@@ -113,8 +131,10 @@ exports.readLastCapture = readLastCapture;
113
131
  exports.runCaptureUpload = runCaptureUpload;
114
132
  exports.runDeferredUpload = runDeferredUpload;
115
133
  exports.transcriptReadSkipReason = transcriptReadSkipReason;
134
+ exports.isCodexRolloutPath = isCodexRolloutPath;
116
135
  exports.shouldReportCaptureSkip = shouldReportCaptureSkip;
117
136
  const fs = __importStar(require("fs"));
137
+ const transcriptModels_1 = require("./transcriptModels");
118
138
  const path = __importStar(require("path"));
119
139
  const os = __importStar(require("os"));
120
140
  const child_process_1 = require("child_process");
@@ -123,6 +143,16 @@ const captureLog_1 = require("./captureLog");
123
143
  const configDir_1 = require("./configDir");
124
144
  Object.defineProperty(exports, "codiedevStateDir", { enumerable: true, get: function () { return configDir_1.codiedevStateDir; } });
125
145
  const codexHookTrust_1 = require("./codexHookTrust");
146
+ const launcher_1 = require("./launcher");
147
+ const teamSkillContext_1 = require("./teamSkillContext");
148
+ const nativeSteering_1 = require("./nativeSteering");
149
+ const copilotHooks_1 = require("./copilotHooks");
150
+ const harnessDirs_1 = require("./harnessDirs");
151
+ const piHooks_1 = require("./piHooks");
152
+ var piHooks_2 = require("./piHooks");
153
+ Object.defineProperty(exports, "inspectPiExtension", { enumerable: true, get: function () { return piHooks_2.inspectPiExtension; } });
154
+ Object.defineProperty(exports, "piExtensionPath", { enumerable: true, get: function () { return piHooks_2.piExtensionPath; } });
155
+ const cliSession_1 = require("./cliSession");
126
156
  const CONFIG_DIR = (0, configDir_1.codiedevStateDir)();
127
157
  exports.CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
128
158
  function readConfig() {
@@ -139,21 +169,12 @@ function readConfig() {
139
169
  function writeConfig(config) {
140
170
  writeConfigAtPath(config, CONFIG_DIR, exports.CONFIG_PATH);
141
171
  }
142
- /** Exported for permission-focused tests; normal callers use writeConfig. */
143
172
  function writeConfigAtPath(config, configDir, configPath) {
144
173
  if (!fs.existsSync(configDir)) {
145
174
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
146
175
  }
147
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
148
- encoding: "utf8",
149
- mode: 0o600,
150
- });
151
- // writeFile's mode does not replace permissions on a pre-existing file.
152
- fs.chmodSync(configPath, 0o600);
176
+ (0, cliSession_1.writeConfigAtomic)(configPath, config);
153
177
  }
154
- // Parse a remote URL out of git-config INI text WITHOUT invoking git. Prefers
155
- // the "origin" remote; falls back to the first remote found. Returns null if
156
- // there are no remote sections.
157
178
  function parseGitRemoteFromConfig(configText) {
158
179
  const remotes = {};
159
180
  let current = null;
@@ -165,8 +186,6 @@ function parseGitRemoteFromConfig(configText) {
165
186
  continue;
166
187
  }
167
188
  if (line.startsWith("[")) {
168
- // Any other section header ends the current remote context, so a
169
- // `url =` under e.g. [core] or [some-section] is never mistaken for one.
170
189
  current = null;
171
190
  continue;
172
191
  }
@@ -182,13 +201,8 @@ function parseGitRemoteFromConfig(configText) {
182
201
  const first = Object.values(remotes)[0];
183
202
  return first ?? null;
184
203
  }
185
- // Locate the git directory for `startDir`, walking up parent directories.
186
- // Handles a `.git` directory (normal repo), a `.git` FILE containing
187
- // `gitdir: <path>` (submodules, some devcontainer/worktree layouts), and stops
188
- // at the filesystem root. Returns the resolved git dir path, or null.
189
204
  function resolveGitDir(startDir) {
190
205
  let dir = path.resolve(startDir);
191
- // eslint-disable-next-line no-constant-condition
192
206
  while (true) {
193
207
  const dotGit = path.join(dir, ".git");
194
208
  try {
@@ -204,7 +218,6 @@ function resolveGitDir(startDir) {
204
218
  }
205
219
  }
206
220
  catch {
207
- // No .git here — keep walking up.
208
221
  }
209
222
  const parent = path.dirname(dir);
210
223
  if (parent === dir)
@@ -212,9 +225,6 @@ function resolveGitDir(startDir) {
212
225
  dir = parent;
213
226
  }
214
227
  }
215
- // Ordered list of config files that may hold the remotes for a git dir. Linked
216
- // worktrees keep their remotes in the COMMON (main) git dir, pointed at by a
217
- // `commondir` file, so we check that first, then the git dir's own config.
218
228
  function gitConfigCandidates(gitDir) {
219
229
  const candidates = [];
220
230
  try {
@@ -226,15 +236,10 @@ function gitConfigCandidates(gitDir) {
226
236
  }
227
237
  }
228
238
  catch {
229
- // ignore — fall through to the plain config below
230
239
  }
231
240
  candidates.push(path.join(gitDir, "config"));
232
241
  return candidates;
233
242
  }
234
- // Resolve a repo's remote URL by reading .git/config directly — no `git`
235
- // binary required. This is the primary path so lean container images (Alpine
236
- // devcontainers without git) still capture sessions instead of silently
237
- // dropping them.
238
243
  function readGitRemoteFromConfig(startDir) {
239
244
  const gitDir = resolveGitDir(startDir);
240
245
  if (!gitDir)
@@ -246,20 +251,14 @@ function readGitRemoteFromConfig(startDir) {
246
251
  return url;
247
252
  }
248
253
  catch {
249
- // try the next candidate
250
254
  }
251
255
  }
252
256
  return null;
253
257
  }
254
258
  function getGitRemoteUrl(cwd) {
255
- // Primary: read .git/config directly. Works even when the `git` binary is
256
- // absent (lean Alpine/devcontainer images), which previously made the
257
- // SessionEnd hook silently drop every session.
258
259
  const fromConfig = readGitRemoteFromConfig(cwd);
259
260
  if (fromConfig)
260
261
  return fromConfig;
261
- // Fallback: ask git itself, for setups the direct read doesn't cover
262
- // (insteadOf rewrites, includeIf'd remotes, unusual gitdir indirection).
263
262
  try {
264
263
  const url = (0, child_process_1.execSync)("git remote get-url origin", {
265
264
  cwd,
@@ -273,13 +272,6 @@ function getGitRemoteUrl(cwd) {
273
272
  return null;
274
273
  }
275
274
  }
276
- // Resolve the remote that tags the session's repo. The workspace root is the
277
- // primary source, but when the session was launched at a multi-repo WORKSPACE
278
- // root — a parent folder holding several cloned repos with `.devcontainer/` at
279
- // the top, which is not itself a git repo — the root has no remote, and tagging
280
- // used to silently produce repo-UNMATCHED sessions. Fall back to the worktrees
281
- // the session actually wrote to (resolveCaptureDirs order: most-recent first),
282
- // so the session tags the repo the work happened in.
283
275
  function resolveCaptureRemoteUrl(workspaceRoot, captureDirs, getRemote = getGitRemoteUrl) {
284
276
  const fromRoot = getRemote(workspaceRoot);
285
277
  if (fromRoot)
@@ -292,19 +284,14 @@ function resolveCaptureRemoteUrl(workspaceRoot, captureDirs, getRemote = getGitR
292
284
  return null;
293
285
  }
294
286
  function normalizeRepoUrl(url) {
295
- // Normalize SSH (git@github.com:org/repo.git) and HTTPS (https://github.com/org/repo.git)
296
- // to a comparable form: github.com/org/repo
297
287
  let normalized = url.trim();
298
- // Strip trailing .git
299
288
  if (normalized.endsWith(".git")) {
300
289
  normalized = normalized.slice(0, -4);
301
290
  }
302
- // Handle SSH format: git@github.com:org/repo → github.com/org/repo
303
291
  const sshMatch = normalized.match(/^git@([^:]+):(.+)$/);
304
292
  if (sshMatch) {
305
293
  return `${sshMatch[1]}/${sshMatch[2]}`;
306
294
  }
307
- // Handle HTTPS format: https://github.com/org/repo → github.com/org/repo
308
295
  const httpsMatch = normalized.match(/^https?:\/\/(.+)$/);
309
296
  if (httpsMatch) {
310
297
  return httpsMatch[1];
@@ -316,11 +303,6 @@ function matchRepo(remoteUrl, repos) {
316
303
  for (const repo of repos) {
317
304
  const normalizedUrl = normalizeRepoUrl(repo.url);
318
305
  const normalizedName = repo.fullName.toLowerCase();
319
- // Match the REMOTE against this repo: exact normalized URL, or the remote
320
- // ending with this repo's full path (`owner/.../name`). Both are anchored to
321
- // `normalizedRemote` — never compare a repo to ITSELF (the old third clause
322
- // `normalizedUrl.endsWith('/'+name)` was remote-independent and returned the
323
- // first repo for every session, collapsing all repos for multi-repo orgs).
324
306
  if (normalizedRemote === normalizedUrl ||
325
307
  normalizedRemote.endsWith(`/${normalizedName}`)) {
326
308
  return repo;
@@ -328,38 +310,11 @@ function matchRepo(remoteUrl, repos) {
328
310
  }
329
311
  return null;
330
312
  }
331
- // Resolve the directory to capture git branch/headSha from.
332
- //
333
- // The session→merge join keys must describe the work that becomes the PR. But
334
- // agents do that work in a `git worktree` (.claude/worktrees/<name> or a
335
- // sibling dir) on a feature branch, while the session's LAUNCH dir
336
- // (workspaceRoot) stays on main/detached — a feature branch checked out in a
337
- // worktree cannot also be checked out in the launch dir. Capturing from the
338
- // launch dir therefore recorded main/null, matching no PR (velocity starved).
339
- //
340
- // Fix: scan the transcript for the cwd the agent actually worked in — the
341
- // most-recent one that is a SEPARATE worktree (its resolved gitdir differs from
342
- // the launch root's; a worktree's gitdir is .git/worktrees/<name>, while the
343
- // root and its subdirs share .git). Fall back to the launch root when no
344
- // separate worktree was used (work done directly in the repo).
345
313
  function resolveCaptureDir(transcriptContent, workspaceRoot) {
346
314
  return resolveCaptureDirs(transcriptContent, workspaceRoot)[0] ?? workspaceRoot;
347
315
  }
348
- // Plural: EVERY separate worktree the session touched, most-recent first. One
349
- // session can produce multiple PRs (work across several worktrees, each on its
350
- // own feature branch); capturing every worktree's branch+sha lets all of those
351
- // PRs attribute. Falls back to [workspaceRoot] when no separate worktree was
352
- // used (work done directly in the repo).
353
316
  function resolveCaptureDirs(transcriptContent, workspaceRoot) {
354
317
  const rootGitDir = resolveGitDir(workspaceRoot);
355
- // Collect the folders the session WROTE to (not merely referenced), in order:
356
- // - the session cwd
357
- // - `git -C <path>` of MUTATING git commands (commit/push/add/...) — NOT
358
- // read-only ones (log/status/diff), which would falsely credit a worktree
359
- // the session only inspected
360
- // - the dir of files edited via Edit/Write/NotebookEdit — NOT Read/Grep/Glob
361
- // Reading/inspecting another worktree must not attribute its PRs to this
362
- // session (that also corrupts the even cost-split).
363
318
  const candidates = [];
364
319
  const add = (p) => {
365
320
  if (!p)
@@ -374,7 +329,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
374
329
  entry = JSON.parse(line);
375
330
  }
376
331
  catch {
377
- // partial/non-JSON line — best-effort: only mutating git -C, never files.
378
332
  for (const d of mutatingGitCDirs(line))
379
333
  add(d);
380
334
  continue;
@@ -397,10 +351,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
397
351
  }
398
352
  }
399
353
  }
400
- // Walk most-recent first; one entry per distinct worktree gitdir (a worktree's
401
- // gitdir is .git/worktrees/<name>; the root and its subdirs share .git, so
402
- // they're excluded as not-separate). Normalize each to its canonical worktree
403
- // root so a file path / subdir resolves to the worktree dir itself.
404
354
  const out = [];
405
355
  const seenGitDirs = new Set();
406
356
  for (let i = candidates.length - 1; i >= 0; i--) {
@@ -414,10 +364,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
414
364
  }
415
365
  return out.length > 0 ? out : [workspaceRoot];
416
366
  }
417
- // Extract the `-C <path>` targets of MUTATING git commands in a shell string.
418
- // Splits on command separators and only takes a sub-command's -C path when that
419
- // sub-command runs a state-changing git verb — so `git -C x log` is ignored but
420
- // `git -C x commit`/`push`/`add` count.
421
367
  const MUTATING_GIT = /\b(?:commit|push|add|checkout|switch|merge|rebase|reset|restore|rm|mv|stash|apply|cherry-pick|am|tag|worktree)\b/;
422
368
  function mutatingGitCDirs(command) {
423
369
  const out = [];
@@ -431,8 +377,6 @@ function mutatingGitCDirs(command) {
431
377
  }
432
378
  return out;
433
379
  }
434
- // A linked worktree's gitdir (`.git/worktrees/<name>`) holds a `gitdir` file
435
- // pointing at the worktree's own `.git` file; its parent is the worktree root.
436
380
  function worktreeRootFromGitDir(gitDir) {
437
381
  try {
438
382
  const p = fs.readFileSync(path.join(gitDir, "gitdir"), "utf8").trim();
@@ -442,10 +386,6 @@ function worktreeRootFromGitDir(gitDir) {
442
386
  return null;
443
387
  }
444
388
  }
445
- // Turn the worktrees a session touched into the upload join keys: dedup'd
446
- // branch + headSha arrays (so one session → many PRs all attribute), plus the
447
- // primary single values (first entry) for back-compat with servers that read
448
- // only branch/headSha. getBranch/getSha are injectable for testing.
449
389
  function collectCaptureKeys(dirs, getBranch = getGitBranch, getSha = getGitHeadSha) {
450
390
  const branches = [];
451
391
  const headShas = [];
@@ -478,19 +418,12 @@ function collectCaptureGitRefs(dirs, getRemote = getGitRemoteUrl, getBranch = ge
478
418
  }
479
419
  return out;
480
420
  }
481
- // Exact PR/MR URLs are the strongest provenance the transcript can provide.
482
- // Both `gh pr create` and `glab mr create` print one, and the OpenCode capture
483
- // is normalized through the same parser as Claude Code. A direct URL survives
484
- // branch deletion, squash/rebase, cherry-picks, and SessionEnd happening after
485
- // the PR was opened.
486
421
  function parsePullRequestUrls(transcriptContent) {
487
422
  const out = [];
488
423
  const createToolIds = new Set();
489
424
  const isCreateCommand = (command) => {
490
425
  if (typeof command !== "string")
491
426
  return false;
492
- // A combined `gh pr view || gh pr create` result is ambiguous: when the PR
493
- // already exists, the VIEW half prints its URL and CREATE never runs.
494
427
  if (/\b(?:gh\s+pr\s+(?:view|list|status)|glab\s+mr\s+(?:view|list))\b/i.test(command)) {
495
428
  return false;
496
429
  }
@@ -514,7 +447,6 @@ function parsePullRequestUrls(transcriptContent) {
514
447
  out.push(canonical);
515
448
  }
516
449
  catch {
517
- // Malformed tool output is ignored; capture remains best-effort.
518
450
  }
519
451
  }
520
452
  };
@@ -552,7 +484,6 @@ function parsePullRequestUrls(transcriptContent) {
552
484
  }
553
485
  }
554
486
  }
555
- // Codex function calls carry arguments as serialized JSON.
556
487
  if (entry?.type === "response_item"
557
488
  && entry?.payload?.type === "function_call"
558
489
  && typeof entry.payload.call_id === "string") {
@@ -561,7 +492,7 @@ function parsePullRequestUrls(transcriptContent) {
561
492
  try {
562
493
  args = JSON.parse(args);
563
494
  }
564
- catch { /* leave as string */ }
495
+ catch { }
565
496
  }
566
497
  if (isCreateCommand(args?.cmd ?? args?.command ?? args)) {
567
498
  createToolIds.add(entry.payload.call_id);
@@ -569,13 +500,8 @@ function parsePullRequestUrls(transcriptContent) {
569
500
  }
570
501
  }
571
502
  catch {
572
- // Never regex the raw line: prompts and assistant prose can contain a PR
573
- // URL, but only actual tool output is provenance.
574
503
  }
575
504
  }
576
- // Second pass: a URL is proof only when it came from the result of the
577
- // specific gh/glab CREATE call above. `gh pr view/list` also prints URLs and
578
- // must never claim somebody else's already-existing PR for this session.
579
505
  for (const entry of entries) {
580
506
  const content = entry?.message?.content;
581
507
  if (Array.isArray(content)) {
@@ -597,17 +523,11 @@ function parsePullRequestUrls(transcriptContent) {
597
523
  }
598
524
  return out;
599
525
  }
600
- // Pull the commit SHAs the agent actually authored/pushed straight out of the
601
- // transcript's git command OUTPUT — independent of end-of-session filesystem
602
- // state. Catches: commit output "[branch 4c4e790]", push ranges
603
- // "abc1234..def5678", and bare 40-hex (rev-parse). This is the signal that
604
- // rescues attribution when the worktree's HEAD is stale or the worktree is gone
605
- // at SessionEnd (pruned worktree, submodule pointer bump).
606
526
  function extractShaTokens(transcriptContent) {
607
527
  const out = new Set();
608
- const commitRe = /\[[^\]\n]*?\b([0-9a-f]{7,40})\]/g; // [branch <sha>]
609
- const rangeRe = /\b([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})\b/g; // <sha>..<sha>
610
- const fullRe = /\b([0-9a-f]{40})\b/g; // bare full sha (rev-parse)
528
+ const commitRe = /\[[^\]\n]*?\b([0-9a-f]{7,40})\]/g;
529
+ const rangeRe = /\b([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})\b/g;
530
+ const fullRe = /\b([0-9a-f]{40})\b/g;
611
531
  let m;
612
532
  while ((m = commitRe.exec(transcriptContent)))
613
533
  out.add(m[1].toLowerCase());
@@ -619,17 +539,6 @@ function extractShaTokens(transcriptContent) {
619
539
  out.add(m[1].toLowerCase());
620
540
  return [...out];
621
541
  }
622
- // Pull the branch NAMES the agent actually committed/pushed to, straight out of
623
- // the transcript's git OUTPUT — the branch companion to extractShaTokens. This
624
- // is the rescue for the dominant capture miss: the agent works in a
625
- // `.claude/worktrees/<name>` worktree on branch `worktree-<name>`, commits/pushes
626
- // /PRs, then the worktree is REMOVED before SessionEnd. resolveCaptureDirs then
627
- // can't resolve the (deleted) worktree's git dir and falls back to the parent
628
- // dir, capturing the PARENT's branch — so the PR's sourceBranch matches no
629
- // captured session. The branch name survives in the commit line `[branch sha]`
630
- // and the push ref `<local> -> <remote>`, both of which prove authorship (a
631
- // read-only `git log` emits neither), so this stays mutation-scoped like the
632
- // SHA parse. Pure string parse — no filesystem, no server change.
633
542
  function parseAuthoredBranches(transcriptContent) {
634
543
  const out = [];
635
544
  const add = (b) => {
@@ -642,25 +551,12 @@ function parseAuthoredBranches(transcriptContent) {
642
551
  while ((m = re.exec(text)))
643
552
  add(m[1]);
644
553
  };
645
- // From git OUTPUT (proof the work shipped on that branch):
646
- // commit: `[<branch> [(root-commit) ]<sha>]`. `[^\]\s]+` rejects "detached
647
- // HEAD" (the space breaks the token before the sha can match).
648
- // push: `<sha>..<sha> <local> -> <remote>` (updated) / `[new branch]
649
- // <local> -> <remote>` (created). Anchor on the push-ref shape so prose
650
- // arrows ("a -> b") don't match; capture the remote (= the PR
651
- // sourceBranch). `[^\s"]+` stops at the JSON string boundary.
652
554
  const commitRe = /\[([^\]\s]+)(?:\s+\(root-commit\))?\s+[0-9a-f]{7,40}\]/g;
653
555
  const pushRe = /(?:[0-9a-f]{7,40}\.\.[0-9a-f]{7,40}|\[new branch\])\s+\S+\s+->\s+([^\s"]+)/g;
654
- // From CREATE commands (rescues work committed/pushed entirely OUTSIDE the
655
- // session): only `checkout -b`/`switch -c` (branch CREATION — not plain
656
- // checkout/switch navigation), and only from real Bash tool_use inputs, never
657
- // prose/output, so a suggested command in assistant text can't false-match.
658
556
  const createRe = /\bgit\s+(?:checkout\s+-b|switch\s+-c)\s+([^\s"'&;|]+)/g;
659
557
  for (const line of transcriptContent.split("\n")) {
660
558
  if (!line.trim())
661
559
  continue;
662
- // commit/push output: scan the raw serialized line (output lives in string
663
- // fields); robust to non-JSON / partial lines.
664
560
  scan(line, commitRe);
665
561
  scan(line, pushRe);
666
562
  let entry;
@@ -681,11 +577,6 @@ function parseAuthoredBranches(transcriptContent) {
681
577
  }
682
578
  return out;
683
579
  }
684
- // Resolve the extracted tokens to FULL 40-char SHAs via the repo's shared object
685
- // store (a worktree's commits live in the main repo's .git/objects, so they
686
- // resolve even after the worktree dir is removed). Full SHAs pass through; short
687
- // ones are resolved; unresolvable ones are dropped (a short sha can't exact-
688
- // match the worker's full commitShas). Keeps the server-side match exact.
689
580
  function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas) => defaultResolveFullShaBatch(shas, repoRoot)) {
690
581
  const out = new Set();
691
582
  const shorts = new Set();
@@ -704,14 +595,6 @@ function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas
704
595
  }
705
596
  return [...out];
706
597
  }
707
- // Resolve many short SHAs to full in ONE git spawn via `git cat-file
708
- // --batch-check` (reads requests on stdin, emits one result line per request,
709
- // in order). The previous one-`git rev-parse`-per-sha approach cost N spawns;
710
- // on Windows (~50-150ms/spawn) a long session's transcript made the detached
711
- // capture child run for tens of seconds (measured 28s for 40 shas on a real
712
- // windows-latest runner) — fragile if the child is torn down before it
713
- // finishes. This is ~constant-time regardless of sha count. Missing objects
714
- // yield a "<req> missing" line and are skipped. Never throws.
715
598
  function defaultResolveFullShaBatch(shas, repoRoot) {
716
599
  const out = new Map();
717
600
  if (shas.length === 0)
@@ -724,8 +607,6 @@ function defaultResolveFullShaBatch(shas, repoRoot) {
724
607
  stdio: ["pipe", "pipe", "pipe"],
725
608
  }).toString();
726
609
  const lines = res.split("\n");
727
- // One output line per input line, in order: "<full-sha> commit <size>" when
728
- // resolved; "<request> missing" / "<request> ambiguous" otherwise.
729
610
  shas.forEach((sha, i) => {
730
611
  const parts = (lines[i] ?? "").trim().split(/\s+/);
731
612
  if (parts.length >= 2 && parts[1] === "commit" && /^[0-9a-f]{40}$/.test(parts[0])) {
@@ -734,14 +615,9 @@ function defaultResolveFullShaBatch(shas, repoRoot) {
734
615
  });
735
616
  }
736
617
  catch {
737
- // best-effort — a git failure just means no shas resolve
738
618
  }
739
619
  return out;
740
620
  }
741
- // Current branch via direct .git/HEAD read (no git binary required, same
742
- // robustness as getGitRemoteUrl). Returns null on detached HEAD or when
743
- // unresolvable. This is the session→merge join key: a captured session's
744
- // branch is matched against a merged MR/PR's source_branch.
745
621
  function getGitBranch(cwd) {
746
622
  const gitDir = resolveGitDir(cwd);
747
623
  if (gitDir) {
@@ -750,10 +626,8 @@ function getGitBranch(cwd) {
750
626
  const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
751
627
  if (m)
752
628
  return m[1].trim();
753
- // raw sha => detached HEAD, no branch
754
629
  }
755
630
  catch {
756
- // fall through to git
757
631
  }
758
632
  }
759
633
  try {
@@ -769,8 +643,6 @@ function getGitBranch(cwd) {
769
643
  return null;
770
644
  }
771
645
  }
772
- // Current commit SHA (HEAD), direct-read first then git fallback. Exact join
773
- // key — survives branch renames that branch-matching would miss.
774
646
  function getGitHeadSha(cwd) {
775
647
  const gitDir = resolveGitDir(cwd);
776
648
  if (gitDir) {
@@ -789,7 +661,6 @@ function getGitHeadSha(cwd) {
789
661
  return sha.toLowerCase();
790
662
  }
791
663
  catch {
792
- /* try packed-refs */
793
664
  }
794
665
  try {
795
666
  const packed = fs.readFileSync(path.join(baseDir, "packed-refs"), "utf8");
@@ -803,13 +674,11 @@ function getGitHeadSha(cwd) {
803
674
  }
804
675
  }
805
676
  catch {
806
- /* ignore */
807
677
  }
808
678
  }
809
679
  }
810
680
  }
811
681
  catch {
812
- // fall through to git
813
682
  }
814
683
  }
815
684
  try {
@@ -825,19 +694,22 @@ function getGitHeadSha(cwd) {
825
694
  return null;
826
695
  }
827
696
  }
697
+ function stripRepoUrlCredentials(url) {
698
+ return url.replace(/^(\s*[a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/?#]*@/, "$1");
699
+ }
828
700
  function buildCapturePayload(input) {
829
701
  return {
830
702
  sessionId: input.sessionId,
831
- repoUrl: input.remoteUrl ?? "",
703
+ repoUrl: stripRepoUrlCredentials(input.remoteUrl ?? ""),
832
704
  repoId: input.matchedRepoId,
833
705
  companyId: input.companyId,
834
706
  branch: input.branch ?? "",
835
707
  headSha: input.headSha ?? "",
836
- // Arrays omitted when empty so older servers (which ignore unknown keys)
837
- // see no change; newer servers index the session under every key.
838
708
  branches: input.branches && input.branches.length ? input.branches : undefined,
839
709
  headShas: input.headShas && input.headShas.length ? input.headShas : undefined,
840
- gitRefs: input.gitRefs && input.gitRefs.length ? input.gitRefs : undefined,
710
+ gitRefs: input.gitRefs && input.gitRefs.length
711
+ ? input.gitRefs.map((ref) => ({ ...ref, repoUrl: stripRepoUrlCredentials(ref.repoUrl) }))
712
+ : undefined,
841
713
  pullRequestUrls: input.pullRequestUrls && input.pullRequestUrls.length
842
714
  ? input.pullRequestUrls
843
715
  : undefined,
@@ -846,18 +718,17 @@ function buildCapturePayload(input) {
846
718
  toolCallCount: input.toolCallCount,
847
719
  capturedAt: input.capturedAt,
848
720
  source: input.source,
849
- // New capture signals (cost / velocity / steering). Optional — omitted
850
- // (undefined) keys drop out of the JSON body, so older servers ignore them.
851
721
  inputTokens: input.inputTokens,
852
722
  outputTokens: input.outputTokens,
853
723
  cacheTokens: input.cacheTokens,
854
724
  cacheReadTokens: input.cacheReadTokens,
855
725
  cacheCreationTokens: input.cacheCreationTokens,
856
726
  model: input.model ?? undefined,
727
+ modelUsage: input.modelUsage?.length ? input.modelUsage : undefined,
728
+ captureSignals: input.captureSignals,
857
729
  sessionStartedAt: input.sessionStartedAt ?? undefined,
858
730
  interruptCount: input.interruptCount,
859
731
  rejectionCount: input.rejectionCount,
860
- // final is only emitted when explicitly set (omitted for Codex Stop).
861
732
  final: input.final,
862
733
  costUsd: input.costUsd,
863
734
  };
@@ -871,21 +742,18 @@ function hashToken(token) {
871
742
  }
872
743
  return hash.toString(36);
873
744
  }
874
- const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
875
- const CLAUDE_DIR = path.join(os.homedir(), ".claude");
876
- const CODEX_HOOKS_PATH = path.join(os.homedir(), ".codex", "hooks.json");
877
- const CODEX_DIR = path.join(os.homedir(), ".codex");
745
+ const CLAUDE_SETTINGS_PATH = path.join((0, harnessDirs_1.claudeConfigDir)(), "settings.json");
746
+ const CLAUDE_DIR = (0, harnessDirs_1.claudeConfigDir)();
747
+ const CODEX_HOOKS_PATH = path.join((0, harnessDirs_1.codexHome)(), "hooks.json");
748
+ const CODEX_DIR = (0, harnessDirs_1.codexHome)();
878
749
  const CURSOR_DIR = path.join(os.homedir(), ".cursor");
879
- const OPENCODE_DIR = path.join(os.homedir(), ".config", "opencode");
750
+ const OPENCODE_DIR = (0, harnessDirs_1.openCodeConfigDir)();
880
751
  const OPENCODE_PLUGIN_DIR = path.join(OPENCODE_DIR, "plugins");
881
752
  const OPENCODE_PLUGIN_PATH = path.join(OPENCODE_PLUGIN_DIR, "codiedev.js");
882
753
  const CURSOR_HOOKS_PATH = path.join(CURSOR_DIR, "hooks.json");
883
754
  const CURSOR_MCP_PATH = path.join(CURSOR_DIR, "mcp.json");
884
755
  const CURSOR_RULES_DIR = path.join(CURSOR_DIR, "rules");
885
756
  const CURSOR_RULES_PATH = path.join(CURSOR_RULES_DIR, "codiedev.mdc");
886
- // VS Code Copilot Chat: detected by the github.copilot* extension dir, then
887
- // MCP wired via VS Code's user-scope mcp.json (top-level key is "servers",
888
- // not "mcpServers" like Claude/Cursor).
889
757
  const VSCODE_EXTENSIONS_DIR = path.join(os.homedir(), ".vscode", "extensions");
890
758
  const VSCODE_USER_DIR = (() => {
891
759
  const home = os.homedir();
@@ -899,40 +767,17 @@ const VSCODE_USER_DIR = (() => {
899
767
  return path.join(home, ".config", "Code", "User");
900
768
  })();
901
769
  const VSCODE_USER_MCP_PATH = path.join(VSCODE_USER_DIR, "mcp.json");
902
- // GUI-launched agents (Cursor.app, future JetBrains plugins) don't source the
903
- // user's shell rc, so nvm-managed `npx` and `node` aren't on PATH. Resolve the
904
- // absolute path to the current node binary and our hook.js at install time and
905
- // write that into hook configs so execution doesn't depend on the spawned
906
- // shell's PATH. Falls back to `npx` if we can't resolve dist/hook.js.
907
770
  function shellQuote(s) {
908
771
  return `'${s.replace(/'/g, `'\\''`)}'`;
909
772
  }
910
- // Pull the `<node>` prefix out of a codiedev hook command we previously wrote.
911
- // Commands take the form `'<node>' '<hookScript>' <subcommand>` (each path
912
- // single-quoted by shellQuote). Returns undefined for the legacy
913
- // `npx codiedev-hook <subcommand>` form (no explicit node path) or if the
914
- // command doesn't start with a quoted token. Exported for unit testing.
915
773
  function extractHookNodePath(cmd) {
916
774
  if (!cmd)
917
775
  return undefined;
918
- // Match the first single-quoted token, honoring shellQuote's `'\''` escape.
919
776
  const m = cmd.match(/^'((?:[^']|'\\'')*)'/);
920
777
  if (!m)
921
778
  return undefined;
922
779
  return m[1].replace(/'\\''/g, "'");
923
780
  }
924
- // `process.execPath` is the *resolved* path to the running node binary, so under
925
- // Homebrew it's the version-specific Cellar path (e.g.
926
- // /opt/homebrew/Cellar/node/26.4.0/bin/node). Baking that into the hook means a
927
- // later `brew upgrade node` moves the Cellar dir and the hook path 404s. Prefer
928
- // a stable alias for the *same* binary:
929
- // 1. A node path from a hook we previously wrote that still resolves — usually
930
- // a symlink the user deliberately pinned (e.g. /opt/homebrew/bin/node). A
931
- // re-`connect`/self-heal then honors it instead of clobbering it.
932
- // 2. Otherwise, `node` as found on PATH when it points at the binary we're
933
- // running (the Homebrew symlink, an nvm `current` alias, etc.).
934
- // 3. Last resort: the resolved `process.execPath`. Always valid right now, and
935
- // still an absolute path a GUI-launched agent can execute without PATH.
936
781
  function realpathOrSelf(p) {
937
782
  try {
938
783
  return fs.realpathSync(p);
@@ -941,13 +786,7 @@ function realpathOrSelf(p) {
941
786
  return p;
942
787
  }
943
788
  }
944
- // A stable `node` alias on PATH (Homebrew symlink, nvm `current`, …) that points
945
- // at the very binary we're running, or undefined if none. `installHook` writes
946
- // five hooks per run, so memoize: `command -v node` is a synchronous subprocess
947
- // spawn and its result can't change within a process. `null` = "looked, none".
948
789
  let cachedStableNodeOnPath;
949
- // Test-only: the memo lives for the process lifetime, so suites that stage
950
- // their own PATH/symlink fixtures must clear it between tests.
951
790
  function resetStableNodeCacheForTests() {
952
791
  cachedStableNodeOnPath = undefined;
953
792
  }
@@ -968,30 +807,21 @@ function stableNodeOnPath() {
968
807
  }
969
808
  }
970
809
  catch {
971
- // No node on PATH (e.g. GUI-launched agent) — fall through.
972
810
  }
973
811
  cachedStableNodeOnPath = null;
974
812
  return undefined;
975
813
  }
976
814
  function resolveHookNodePath(preferredNodePath) {
977
815
  const execReal = realpathOrSelf(process.execPath);
978
- // 1. Honor a still-working node path from the existing hook — but only when
979
- // it's a *different* string than the resolved realpath. If the existing
980
- // path already IS `process.execPath` it's the version-specific realpath we
981
- // want to upgrade away from (that's the Homebrew bug), so fall through to
982
- // hunt for a stable alias in step 2.
983
816
  if (preferredNodePath &&
984
817
  preferredNodePath !== process.execPath &&
985
818
  fs.existsSync(preferredNodePath) &&
986
819
  realpathOrSelf(preferredNodePath) === execReal) {
987
820
  return preferredNodePath;
988
821
  }
989
- // 2. Prefer a stable `node` on PATH over the version-specific realpath.
990
822
  const onPath = stableNodeOnPath();
991
823
  if (onPath)
992
824
  return onPath;
993
- // 3. Last resort: the resolved absolute path. Always valid right now, and
994
- // still an absolute path a GUI-launched agent can execute without PATH.
995
825
  return process.execPath;
996
826
  }
997
827
  function resolveHookInvocation(subcommand, preferredNodePath) {
@@ -1003,12 +833,14 @@ function resolveHookInvocation(subcommand, preferredNodePath) {
1003
833
  const hookScript = path.join(distDir, "hook.js");
1004
834
  if (fs.existsSync(hookScript)) {
1005
835
  const nodePath = resolveHookNodePath(preferredNodePath);
1006
- return { command: nodePath, args: [hookScript, subcommand] };
836
+ const launcher = (0, launcher_1.ensureLauncher)(hookScript);
837
+ if (launcher || (0, launcher_1.stableHookScript)(hookScript)) {
838
+ return { command: nodePath, args: [launcher ?? hookScript, subcommand] };
839
+ }
1007
840
  }
1008
841
  }
1009
842
  }
1010
843
  catch {
1011
- // Fall through to npx fallback.
1012
844
  }
1013
845
  return {
1014
846
  command: process.platform === "win32" ? "npx.cmd" : "npx",
@@ -1020,8 +852,6 @@ function resolveHookCommand(subcommand, preferredNodePath) {
1020
852
  const argsBeforeSubcommand = invocation.args.slice(0, -1).map(shellQuote);
1021
853
  return [shellQuote(invocation.command), ...argsBeforeSubcommand, subcommand].join(" ");
1022
854
  }
1023
- // `isCodiedevHookCommand` is defined in `./detection` so it's testable
1024
- // without mocking fs/path. Re-imported below; do not re-define here.
1025
855
  function claudeBinOnPath() {
1026
856
  try {
1027
857
  (0, child_process_1.execSync)("command -v claude", { stdio: ["pipe", "pipe", "pipe"] });
@@ -1043,6 +873,19 @@ function codexInstalled() {
1043
873
  function cursorInstalled() {
1044
874
  return fs.existsSync(CURSOR_DIR);
1045
875
  }
876
+ function copilotInstalled() {
877
+ if (fs.existsSync((0, copilotHooks_1.copilotHome)()))
878
+ return true;
879
+ if (vscodeCopilotInstalled())
880
+ return true;
881
+ try {
882
+ (0, child_process_1.execSync)(process.platform === "win32" ? "where copilot" : "command -v copilot", { stdio: ["pipe", "pipe", "pipe"] });
883
+ return true;
884
+ }
885
+ catch {
886
+ return false;
887
+ }
888
+ }
1046
889
  function openCodeInstalled() {
1047
890
  if (fs.existsSync(OPENCODE_DIR))
1048
891
  return true;
@@ -1054,32 +897,24 @@ function openCodeInstalled() {
1054
897
  return false;
1055
898
  }
1056
899
  }
1057
- /** Install a dormant global OpenCode adapter. Installing it unconditionally
1058
- * keeps `codiedev connect` as the only setup step even when Codie/OpenCode is
1059
- * installed later. OpenCode loads this file from its documented plugin dir. */
1060
- function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
900
+ function installOpenCodeCapturePlugin() {
1061
901
  const start = resolveHookInvocation("session-start-opencode");
1062
902
  const capture = resolveHookInvocation("capture-opencode");
1063
903
  const finish = resolveHookInvocation("capture-opencode-final");
1064
904
  const backup = resolveHookInvocation("backup");
1065
905
  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");
1069
906
  const source = `// Managed by codiedev connect. Safe to remove and reinstall.\n` +
1070
907
  `import { spawn, spawnSync } from "node:child_process";\n` +
1071
- `import { mkdir, writeFile } from "node:fs/promises";\n` +
908
+ `import { mkdirSync, writeFileSync, renameSync } from "node:fs";\n` +
1072
909
  `import os from "node:os";\n` +
1073
910
  `import path from "node:path";\n\n` +
1074
911
  `const START = ${JSON.stringify(start)};\n` +
1075
- `const CAPTURE = ${JSON.stringify(capture)};\n` +
1076
912
  `const FINISH = ${JSON.stringify(finish)};\n` +
1077
913
  `const BACKUP = ${JSON.stringify(backup)};\n` +
1078
914
  `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` +
915
+ `// How this Codie process was started (run / serve / tui). OpenCode records\n` +
916
+ `// nothing about it, so the plugin reads its own argv; see launchContext.ts.\n` +
917
+ `const LAUNCH = (() => { const sub = process.argv.slice(2).find((a) => ["run", "serve", "acp", "web", "attach"].includes(a)); return sub ? "codie:" + sub : "codie:tui"; })();\n` +
1083
918
  `const fingerprints = new Map();\n\n` +
1084
919
  `const transcriptPaths = new Map();\n\n` +
1085
920
  `const sessionDirectories = new Map();\n\n` +
@@ -1104,20 +939,6 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1104
939
  ` } catch { return null; }\n` +
1105
940
  `}\n\n` +
1106
941
  `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
942
  ` const resolveCwd = async (id, hint) => {\n` +
1122
943
  ` if (typeof hint === "string" && hint && hint !== "/") { sessionDirectories.set(id, hint); return hint; }\n` +
1123
944
  ` const remembered = sessionDirectories.get(id);\n` +
@@ -1134,7 +955,7 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1134
955
  ` const finalizeAll = () => {\n` +
1135
956
  ` for (const [id, transcriptPath] of transcriptPaths) {\n` +
1136
957
  ` const cwd = sessionDirectories.get(id) || worktree || directory || process.cwd();\n` +
1137
- ` dispatchSync(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
958
+ ` dispatchSync(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1138
959
  ` }\n` +
1139
960
  ` };\n` +
1140
961
  ` process.once("exit", finalizeAll);\n` +
@@ -1143,34 +964,25 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1143
964
  ` const id = input?.sessionID ?? input?.sessionId ?? "unknown";\n` +
1144
965
  ` const cwd = await resolveCwd(id);\n` +
1145
966
  ` 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` +
967
+ ` const decision = invokeSync(PROMPT, { session_id: id, cwd, prompt });\n` +
1147
968
  ` 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` +
969
+ ` if (context && Array.isArray(output?.parts)) {\n` +
970
+ ` // Preserve the native part IDs required by current engine schemas.\n` +
971
+ ` const part = output.parts.find((part) => part?.type === "text" && typeof part.text === "string");\n` +
972
+ ` if (part) part.text += "\\n\\n" + context;\n` +
973
+ ` }\n` +
1162
974
  ` },\n` +
1163
975
  ` event: async ({ event }) => {\n` +
1164
976
  ` const id = sessionId(event);\n` +
1165
977
  ` if (!id) return;\n` +
1166
978
  ` const cwd = await resolveCwd(id, event?.properties?.info?.directory);\n` +
1167
979
  ` if (event.type === "session.created") {\n` +
1168
- ` dispatch(START, { session_id: id, cwd, workspace_roots: [cwd] });\n` +
980
+ ` dispatch(START, { session_id: id, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1169
981
  ` return;\n` +
1170
982
  ` }\n` +
1171
983
  ` if (event.type === "session.deleted") {\n` +
1172
984
  ` const transcriptPath = transcriptPaths.get(id);\n` +
1173
- ` if (transcriptPath) dispatch(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
985
+ ` if (transcriptPath) dispatch(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1174
986
  ` transcriptPaths.delete(id);\n` +
1175
987
  ` sessionDirectories.delete(id);\n` +
1176
988
  ` fingerprints.delete(id);\n` +
@@ -1184,15 +996,19 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1184
996
  ` const last = list.at(-1);\n` +
1185
997
  ` const fingerprint = String(list.length) + ":" + String(last?.info?.id ?? last?.info?.time?.completed ?? "");\n` +
1186
998
  ` if (fingerprints.get(id) === fingerprint) return;\n` +
1187
- ` fingerprints.set(id, fingerprint);\n` +
1188
999
  ` const captureDir = path.join(os.homedir(), ".codiedev", "opencode-captures");\n` +
1189
- ` await mkdir(captureDir, { recursive: true, mode: 0o700 });\n` +
1000
+ ` mkdirSync(captureDir, { recursive: true, mode: 0o700 });\n` +
1190
1001
  ` const safeId = String(id).replace(/[^a-zA-Z0-9._-]/g, "_");\n` +
1191
1002
  ` const transcriptPath = path.join(captureDir, safeId + ".json");\n` +
1192
- ` await writeFile(transcriptPath, JSON.stringify(list), { mode: 0o600 });\n` +
1003
+ ` // The engine may exit as soon as it emits idle. Publish the complete\n` +
1004
+ ` // transcript synchronously so exit finalization never sees a partial file.\n` +
1005
+ ` const temporaryPath = transcriptPath + "." + process.pid + ".tmp";\n` +
1006
+ ` writeFileSync(temporaryPath, JSON.stringify(list), { mode: 0o600 });\n` +
1007
+ ` renameSync(temporaryPath, transcriptPath);\n` +
1193
1008
  ` transcriptPaths.set(id, transcriptPath);\n` +
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` +
1009
+ ` fingerprints.set(id, fingerprint);\n` +
1010
+ ` dispatch(CAPTURE, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1011
+ ` dispatch(BACKUP, { session_id: id, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1196
1012
  ` } catch {}\n` +
1197
1013
  ` },\n` +
1198
1014
  ` });\n` +
@@ -1201,11 +1017,140 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1201
1017
  fs.writeFileSync(OPENCODE_PLUGIN_PATH, source, { encoding: "utf8", mode: 0o600 });
1202
1018
  fs.chmodSync(OPENCODE_PLUGIN_PATH, 0o600);
1203
1019
  }
1204
- /**
1205
- * VS Code with the GitHub Copilot Chat extension installed. The extension
1206
- * directory pattern is `~/.vscode/extensions/github.copilot-chat-*`. We treat
1207
- * the presence of any `github.copilot*` directory as the Copilot fingerprint.
1208
- */
1020
+ function ensureOpenCodePluginCurrent() {
1021
+ try {
1022
+ if (!fs.existsSync(OPENCODE_PLUGIN_PATH))
1023
+ return;
1024
+ if (!fs.readFileSync(OPENCODE_PLUGIN_PATH, "utf8").includes("SECURITY_PRE"))
1025
+ return;
1026
+ installOpenCodeCapturePlugin();
1027
+ }
1028
+ catch { }
1029
+ }
1030
+ function piInstalled() {
1031
+ if (fs.existsSync((0, harnessDirs_1.piAgentDir)()))
1032
+ return true;
1033
+ try {
1034
+ (0, child_process_1.execSync)(process.platform === "win32" ? "where pi" : "command -v pi", { stdio: ["pipe", "pipe", "pipe"] });
1035
+ return true;
1036
+ }
1037
+ catch {
1038
+ return false;
1039
+ }
1040
+ }
1041
+ exports.PI_EXTENSION_SUBCOMMANDS = [
1042
+ "session-start-pi", "backup-pi", "capture-pi-final", "discover-pi",
1043
+ ];
1044
+ function piExtensionSource() {
1045
+ const [start, backup, finish, prompt] = exports.PI_EXTENSION_SUBCOMMANDS.map((subcommand) => resolveHookInvocation(subcommand));
1046
+ return `// Managed by codiedev connect. Safe to remove; \`codiedev connect\` reinstalls it.\n` +
1047
+ `import { spawn, spawnSync } from "node:child_process";\n\n` +
1048
+ `const START = ${JSON.stringify(start)};\n` +
1049
+ `const FINISH = ${JSON.stringify(finish)};\n` +
1050
+ `const BACKUP = ${JSON.stringify(backup)};\n` +
1051
+ `const PROMPT = ${JSON.stringify(prompt)};\n` +
1052
+ `const CONTEXT_TYPE = ${JSON.stringify(piHooks_1.PI_CONTEXT_CUSTOM_TYPE)};\n` +
1053
+ `const piLaunchFromArgv = ${piHooks_1.piLaunchFromArgv.toString()};\n` +
1054
+ `const LAUNCH = piLaunchFromArgv(process.argv.slice(2));\n\n` +
1055
+ `function dispatch(invocation, payload) {\n` +
1056
+ ` try {\n` +
1057
+ ` const child = spawn(invocation.command, invocation.args, { detached: true, stdio: ["pipe", "ignore", "ignore"] });\n` +
1058
+ ` child.stdin.end(JSON.stringify(payload));\n` +
1059
+ ` child.unref();\n` +
1060
+ ` } catch {}\n` +
1061
+ `}\n\n` +
1062
+ `function dispatchSync(invocation, payload) {\n` +
1063
+ ` try { spawnSync(invocation.command, invocation.args, { input: JSON.stringify(payload), stdio: ["pipe", "ignore", "ignore"], timeout: 1500 }); } catch {}\n` +
1064
+ `}\n\n` +
1065
+ `function invokeSync(invocation, payload) {\n` +
1066
+ ` try {\n` +
1067
+ ` const result = spawnSync(invocation.command, invocation.args, { input: JSON.stringify(payload), encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], timeout: 1500 });\n` +
1068
+ ` if (result.error) return null;\n` +
1069
+ ` return JSON.parse(result.stdout || "{}");\n` +
1070
+ ` } catch { return null; }\n` +
1071
+ `}\n\n` +
1072
+ `export default function codiedev(pi) {\n` +
1073
+ ` let session = null;\n` +
1074
+ ` let pendingContext = null;\n` +
1075
+ ` let finalized = false;\n` +
1076
+ ` const current = (ctx) => {\n` +
1077
+ ` const sm = ctx?.sessionManager;\n` +
1078
+ ` const id = sm?.getSessionId?.() ?? session?.id;\n` +
1079
+ ` const file = sm?.getSessionFile?.() ?? session?.file;\n` +
1080
+ ` const cwd = ctx?.cwd || session?.cwd || process.cwd();\n` +
1081
+ ` session = { id, file, cwd };\n` +
1082
+ ` return session;\n` +
1083
+ ` };\n` +
1084
+ ` const payload = (s, extra) => ({ session_id: s.id, transcript_path: s.file, cwd: s.cwd, workspace_roots: [s.cwd], launch: LAUNCH, ...extra });\n` +
1085
+ ` const finalize = () => {\n` +
1086
+ ` // No file (pi --no-session) still finalizes: the hook reports a skip, so\n` +
1087
+ ` // the start ping is not left open.\n` +
1088
+ ` if (finalized || !session?.id) return;\n` +
1089
+ ` finalized = true;\n` +
1090
+ ` dispatchSync(FINISH, payload(session));\n` +
1091
+ ` };\n` +
1092
+ ` // Print mode can exit without a shutdown event; the session is still over.\n` +
1093
+ ` // pi rebuilds extensions on every /new, /resume and /fork, so register one\n` +
1094
+ ` // exit hook per process and point it at the newest instance.\n` +
1095
+ ` globalThis.__codiedevPiFinalize = finalize;\n` +
1096
+ ` if (!globalThis.__codiedevPiExitHook) {\n` +
1097
+ ` globalThis.__codiedevPiExitHook = true;\n` +
1098
+ ` process.once("exit", () => globalThis.__codiedevPiFinalize?.());\n` +
1099
+ ` }\n\n` +
1100
+ ` pi.on("session_start", async (event, ctx) => {\n` +
1101
+ ` finalized = false;\n` +
1102
+ ` if (event?.reason === "reload") { current(ctx); return; }\n` +
1103
+ ` const s = current(ctx);\n` +
1104
+ ` // pi can announce one new session twice across an extension rebind.\n` +
1105
+ ` if (!s.id || globalThis.__codiedevPiStarted === s.id) return;\n` +
1106
+ ` globalThis.__codiedevPiStarted = s.id;\n` +
1107
+ ` dispatch(START, payload(s));\n` +
1108
+ ` });\n\n` +
1109
+ ` pi.on("input", async (event, ctx) => {\n` +
1110
+ ` if (event?.source === "extension") return { action: "continue" };\n` +
1111
+ ` const s = current(ctx);\n` +
1112
+ ` const decision = invokeSync(PROMPT, { session_id: s.id, cwd: s.cwd, prompt: event?.text ?? "" });\n` +
1113
+ ` pendingContext = decision?.hookSpecificOutput?.additionalContext || null;\n` +
1114
+ ` return { action: "continue" };\n` +
1115
+ ` });\n\n` +
1116
+ ` pi.on("before_agent_start", async () => {\n` +
1117
+ ` if (!pendingContext) return;\n` +
1118
+ ` const content = pendingContext;\n` +
1119
+ ` pendingContext = null;\n` +
1120
+ ` return { message: { customType: CONTEXT_TYPE, content, display: false } };\n` +
1121
+ ` });\n\n` +
1122
+ ` pi.on("agent_end", async (_event, ctx) => {\n` +
1123
+ ` const s = current(ctx);\n` +
1124
+ ` if (!s.id || !s.file) return;\n` +
1125
+ ` // Interim capture + artifact backup, like every harness's Stop hook. pi\n` +
1126
+ ` // retries provider errors itself; agent_end comes after the last try, so\n` +
1127
+ ` // the capture reports a hit limit once per run, not once per retry.\n` +
1128
+ ` dispatch(BACKUP, payload(s));\n` +
1129
+ ` });\n\n` +
1130
+ ` pi.on("session_shutdown", async (event, ctx) => {\n` +
1131
+ ` // A reload keeps the same session running.\n` +
1132
+ ` if (event?.reason === "reload") return;\n` +
1133
+ ` current(ctx);\n` +
1134
+ ` finalize();\n` +
1135
+ ` });\n` +
1136
+ `}\n`;
1137
+ }
1138
+ function installPiExtension() {
1139
+ const target = (0, piHooks_1.piExtensionPath)();
1140
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
1141
+ const tmp = `${target}.${process.pid}.tmp`;
1142
+ fs.writeFileSync(tmp, piExtensionSource(), { encoding: "utf8", mode: 0o600 });
1143
+ fs.renameSync(tmp, target);
1144
+ }
1145
+ function ensurePiExtensionInstalled() {
1146
+ try {
1147
+ const file = (0, piHooks_1.piExtensionPath)();
1148
+ if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === piExtensionSource())
1149
+ return;
1150
+ installPiExtension();
1151
+ }
1152
+ catch { }
1153
+ }
1209
1154
  function vscodeCopilotInstalled() {
1210
1155
  if (!fs.existsSync(VSCODE_EXTENSIONS_DIR))
1211
1156
  return false;
@@ -1218,19 +1163,10 @@ function vscodeCopilotInstalled() {
1218
1163
  return false;
1219
1164
  }
1220
1165
  }
1221
- // Idempotently put a single codiedev hook (matcher ".*" by default, running our hook.js
1222
- // with `subcommand`) into the given hook event, stripping any prior codiedev
1223
- // entries first so connect re-runs / upgrades refresh the resolved binary path.
1224
- // Mutates `hooks` in place. Exported for unit testing.
1225
1166
  function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
1226
1167
  if (!hooks[eventName])
1227
1168
  hooks[eventName] = [];
1228
1169
  const existing = hooks[eventName];
1229
- // Before stripping the old codiedev entry, remember the node path it used so a
1230
- // re-run/upgrade can honor a stable symlink the user pinned instead of
1231
- // overwriting it with the version-specific `process.execPath` (see
1232
- // resolveHookNodePath). Done in an explicit scan rather than as a side effect
1233
- // of the filter predicate below, which stays a pure predicate.
1234
1170
  let preservedNodePath;
1235
1171
  for (const hook of existing) {
1236
1172
  const inner = hook.hooks;
@@ -1255,7 +1191,6 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
1255
1191
  });
1256
1192
  hooks[eventName] = filtered;
1257
1193
  }
1258
- /** Remove Codiedev's entries for one event while preserving foreign hooks. */
1259
1194
  function removeCodiedevHook(hooks, eventName) {
1260
1195
  const entries = hooks[eventName];
1261
1196
  if (!Array.isArray(entries))
@@ -1284,7 +1219,8 @@ function removeCodiedevHook(hooks, eventName) {
1284
1219
  hooks[eventName] = filtered;
1285
1220
  return true;
1286
1221
  }
1287
- function installHook(agentSecurityEnabled = true) {
1222
+ const RETIRED_SECURITY_TOOL_EVENTS = ["PreToolUse", "PostToolUse", "PostToolUseFailure"];
1223
+ function installHook() {
1288
1224
  let settings = {};
1289
1225
  try {
1290
1226
  if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
@@ -1293,48 +1229,27 @@ function installHook(agentSecurityEnabled = true) {
1293
1229
  }
1294
1230
  }
1295
1231
  catch {
1296
- // Start with empty settings if file doesn't exist or is invalid
1297
1232
  }
1298
1233
  if (!settings.hooks) {
1299
1234
  settings.hooks = {};
1300
1235
  }
1301
1236
  const hooks = settings.hooks;
1302
- // SessionEnd → capture (upload the finished session for the team library).
1303
1237
  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
- }
1316
- // UserPromptSubmit → discover (surface a matching team skill for the task).
1238
+ for (const eventName of RETIRED_SECURITY_TOOL_EVENTS)
1239
+ removeCodiedevHook(hooks, eventName);
1317
1240
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover");
1318
- // Stop → backup (auto-back-up changed artifact files to your private library).
1319
1241
  upsertCodiedevHook(hooks, "Stop", "backup");
1320
- // StopFailure (matcher rate_limit) → limit (post rate-limit event to the team).
1321
1242
  upsertCodiedevHook(hooks, "StopFailure", "limit", "rate_limit");
1322
- // SessionStart → session-start (capture-coverage denominator: record that a
1323
- // session opened, independent of whether it later captures).
1324
1243
  upsertCodiedevHook(hooks, "SessionStart", "session-start");
1325
1244
  if (!fs.existsSync(CLAUDE_DIR)) {
1326
1245
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
1327
1246
  }
1328
1247
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1329
1248
  }
1330
- // Self-heal: ensure the discovery (UserPromptSubmit) hook is present without a
1331
- // full `connect` re-run. Called from the capture hook on SessionEnd, so a user
1332
- // who upgrades the CLI gets the new hook wired on their next session. Only
1333
- // writes when missing, to avoid churning settings.json every run.
1334
1249
  function ensureDiscoveryHookInstalled() {
1335
1250
  try {
1336
1251
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
1337
- return; // not a Claude Code user
1252
+ return;
1338
1253
  const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8");
1339
1254
  const settings = JSON.parse(raw);
1340
1255
  const hooks = (settings.hooks ?? {});
@@ -1351,13 +1266,8 @@ function ensureDiscoveryHookInstalled() {
1351
1266
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1352
1267
  }
1353
1268
  catch {
1354
- // Best-effort — never block a session on self-heal.
1355
1269
  }
1356
1270
  }
1357
- // Self-heal: ensure the Stop → backup hook is present without a full `connect`
1358
- // re-run, mirroring ensureDiscoveryHookInstalled. Called from the capture hook
1359
- // so an upgraded-but-not-reconnected user gets auto-backup on their next
1360
- // session. Only writes when missing, to avoid churning settings.json every run.
1361
1271
  function ensureBackupHookInstalled() {
1362
1272
  try {
1363
1273
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1375,12 +1285,8 @@ function ensureBackupHookInstalled() {
1375
1285
  upsertCodiedevHook(hooks, "Stop", "backup");
1376
1286
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1377
1287
  }
1378
- catch { /* best-effort */ }
1288
+ catch { }
1379
1289
  }
1380
- // Self-heal: ensure the StopFailure (matcher rate_limit) → limit hook is
1381
- // present without a full `connect` re-run, mirroring ensureBackupHookInstalled.
1382
- // Called from the capture hook so an upgraded-but-not-reconnected user gets the
1383
- // limit-event hook wired on their next session. Only writes when missing.
1384
1290
  function ensureLimitHookInstalled() {
1385
1291
  try {
1386
1292
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1398,26 +1304,12 @@ function ensureLimitHookInstalled() {
1398
1304
  upsertCodiedevHook(hooks, "StopFailure", "limit", "rate_limit");
1399
1305
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1400
1306
  }
1401
- catch { /* best-effort */ }
1402
- }
1403
- // Self-heal ALL codiedev hooks in one read → one write. This replaces calling
1404
- // the four per-hook ensure* functions in sequence, and fixes two real losses
1405
- // seen in the field (Sonifi, 2026-07):
1406
- // 1. The SessionEnd → capture hook — the one that actually uploads sessions
1407
- // — had NO self-heal at all: it was written once at `connect` and never
1408
- // repaired. Anything that stripped it (IT-managed dotfiles baseline, the
1409
- // race below) left SessionStart pinging forever while captures silently
1410
- // stopped — the exact "start pings arrive, captures never" signature.
1411
- // 2. Each per-hook ensure* did its own read-modify-write of settings.json
1412
- // with no locking; two concurrent Claude Code sessions repairing
1413
- // different hooks could lose each other's writes. One read + one write
1414
- // shrinks that window and removes the intra-process version entirely.
1415
- // The per-hook ensure* functions below are kept for tests/back-compat but are
1416
- // no longer called from the hook entrypoint.
1417
- function ensureAllCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.agentSecurityEnabled === true) {
1307
+ catch { }
1308
+ }
1309
+ function ensureAllCodiedevHooksInstalled() {
1418
1310
  try {
1419
1311
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
1420
- return; // not a Claude Code user
1312
+ return;
1421
1313
  const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
1422
1314
  const hooks = (settings.hooks ?? {});
1423
1315
  const wanted = [
@@ -1427,18 +1319,13 @@ function ensureAllCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.ag
1427
1319
  ["StopFailure", "limit", "rate_limit"],
1428
1320
  ["SessionStart", "session-start"],
1429
1321
  ];
1430
- if (agentSecurityEnabled) {
1431
- wanted.splice(1, 0, ["PreToolUse", "security-pre"], ["PostToolUse", "security-post"], ["PostToolUseFailure", "security-failure"]);
1432
- }
1433
1322
  let changed = false;
1434
- if (!agentSecurityEnabled) {
1435
- for (const eventName of ["PreToolUse", "PostToolUse", "PostToolUseFailure"]) {
1436
- changed = removeCodiedevHook(hooks, eventName) || changed;
1437
- }
1323
+ for (const eventName of RETIRED_SECURITY_TOOL_EVENTS) {
1324
+ changed = removeCodiedevHook(hooks, eventName) || changed;
1438
1325
  }
1439
1326
  const missing = wanted.filter(([eventName]) => !hasCodiedevHook(hooks, eventName));
1440
1327
  if (missing.length === 0 && !changed)
1441
- return; // steady state: no write, no churn
1328
+ return;
1442
1329
  settings.hooks = hooks;
1443
1330
  for (const [eventName, subcommand, matcher] of missing) {
1444
1331
  upsertCodiedevHook(hooks, eventName, subcommand, matcher);
@@ -1446,16 +1333,39 @@ function ensureAllCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.ag
1446
1333
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1447
1334
  }
1448
1335
  catch {
1449
- // Best-effort — never block a session on self-heal.
1450
1336
  }
1451
1337
  }
1338
+ const DIRECT_HOOK_PATH = /codiedev[^"'\s/\\]*[\\/]+dist[\\/]+hook\.js/;
1339
+ function hasDirectHookPath(configText) {
1340
+ return DIRECT_HOOK_PATH.test(configText);
1341
+ }
1342
+ function migrateHooksToLauncher() {
1343
+ const probe = resolveHookInvocation("session-start");
1344
+ if (!(0, launcher_1.usesLauncher)(probe.args[0]))
1345
+ return [];
1346
+ const targets = [
1347
+ ["claude-code", CLAUDE_SETTINGS_PATH, () => installHook()],
1348
+ ["codex", CODEX_HOOKS_PATH, () => installCodexHook()],
1349
+ ["cursor", CURSOR_HOOKS_PATH, () => installCursorHook()],
1350
+ ["copilot", copilotHooksPath(), () => installCopilotHook()],
1351
+ ["opencode", OPENCODE_PLUGIN_PATH, () => installOpenCodeCapturePlugin()],
1352
+ ["pi", (0, piHooks_1.piExtensionPath)(), () => installPiExtension()],
1353
+ ];
1354
+ const migrated = [];
1355
+ for (const [name, file, install] of targets) {
1356
+ try {
1357
+ if (!fs.existsSync(file) || !hasDirectHookPath(fs.readFileSync(file, "utf8")))
1358
+ continue;
1359
+ install();
1360
+ migrated.push(name);
1361
+ }
1362
+ catch {
1363
+ }
1364
+ }
1365
+ return migrated;
1366
+ }
1452
1367
  function hasCodiedevHook(hooks, eventName) {
1453
1368
  const entries = hooks[eventName] ?? [];
1454
- // A malformed (non-array) event value would make `.some` throw and — under
1455
- // ensureAll's single try/catch — abort EVERY heal, including the capture
1456
- // heal. Report "present" instead: leave the foreign/corrupt key alone and
1457
- // keep healing the other events (the per-hook fault isolation the old
1458
- // ensure* functions had).
1459
1369
  if (!Array.isArray(entries))
1460
1370
  return true;
1461
1371
  return entries.some((hook) => {
@@ -1464,10 +1374,6 @@ function hasCodiedevHook(hooks, eventName) {
1464
1374
  inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command)));
1465
1375
  });
1466
1376
  }
1467
- // Self-heal: ensure the SessionStart → session-start hook is present without a
1468
- // full `connect` re-run, mirroring ensureLimitHookInstalled. Called from the
1469
- // capture hook so an upgraded-but-not-reconnected user starts emitting the
1470
- // capture-coverage denominator on their next session. Only writes when missing.
1471
1377
  function ensureSessionStartHookInstalled() {
1472
1378
  try {
1473
1379
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1485,16 +1391,12 @@ function ensureSessionStartHookInstalled() {
1485
1391
  upsertCodiedevHook(hooks, "SessionStart", "session-start");
1486
1392
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1487
1393
  }
1488
- catch { /* best-effort */ }
1394
+ catch { }
1489
1395
  }
1490
- // ─────────────────────────────────────────────────────────────────────────────
1491
- // MCP server installation — writes the codiedev MCP server config into each
1492
- // agent CLI's expected location so "npx codiedev connect" wires it up once.
1493
- // ─────────────────────────────────────────────────────────────────────────────
1494
- const CLAUDE_USER_CONFIG_PATH = path.join(os.homedir(), ".claude.json");
1495
- const CLAUDE_USER_INSTRUCTIONS_PATH = path.join(os.homedir(), ".claude", "CLAUDE.md");
1496
- const CODEX_USER_INSTRUCTIONS_PATH = path.join(os.homedir(), ".codex", "AGENTS.md");
1497
- const CODEX_CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
1396
+ const CLAUDE_USER_CONFIG_PATH = (0, harnessDirs_1.claudeUserConfigPath)();
1397
+ const CLAUDE_USER_INSTRUCTIONS_PATH = path.join((0, harnessDirs_1.claudeConfigDir)(), "CLAUDE.md");
1398
+ const CODEX_USER_INSTRUCTIONS_PATH = path.join((0, harnessDirs_1.codexHome)(), "AGENTS.md");
1399
+ const CODEX_CONFIG_PATH = path.join((0, harnessDirs_1.codexHome)(), "config.toml");
1498
1400
  const CODIEDEV_INSTRUCTIONS_BEGIN = "<!-- codiedev-cli:begin -->";
1499
1401
  const CODIEDEV_INSTRUCTIONS_END = "<!-- codiedev-cli:end -->";
1500
1402
  const CODIEDEV_INSTRUCTIONS_BODY = `## Codiedev — team artifact layer
@@ -1505,6 +1407,11 @@ decisions, and messages via a CLI on every dev's machine.
1505
1407
  **When the user asks to share, push, ping, check messages, or capture a
1506
1408
  thought, use the \`codiedev\` CLI via Bash:**
1507
1409
 
1410
+ If \`codiedev\` is not on PATH, run the same subcommand with
1411
+ \`npx codiedev@latest\` instead (for example,
1412
+ \`npx codiedev@latest inbox\`). The \`connect\` command installs hooks
1413
+ but does not install a global \`codiedev\` executable.
1414
+
1508
1415
  | Intent | Command |
1509
1416
  |---|---|
1510
1417
  | "push this spec" / "save this as a skill" | \`codiedev push <file.md>\` |
@@ -1514,14 +1421,20 @@ thought, use the \`codiedev\` CLI via Bash:**
1514
1421
  | "any messages?" / "check my inbox" / "what did a teammate say?" | \`codiedev inbox\` |
1515
1422
  | "read that reply" / "mark that ping read" | \`codiedev read <ping-id>\` |
1516
1423
  | "note that X is a follow-up" / "remember X" | \`codiedev note "<text>"\` |
1517
- | "promote the extracted spec" | \`codiedev promote <artifact-id>\` |
1518
1424
  | "create a ticket" / "scope this into tickets" / "ticket this for me" | \`codiedev create-ticket [optional-inline-prompt]\` |
1519
1425
  | "share with a teammate" / "give someone access" (no notification) | \`codiedev share <key> <name> [--role read\\|edit]\` |
1520
1426
  | "send this to a teammate" / "ask someone to look at this" | \`codiedev send <key> <name> ["<msg>"]\` |
1521
1427
  | "find something about X" / "has anyone solved Y?" | \`codiedev search "<query>" [--limit N]\` |
1522
1428
  | "why did we…?" / "what does the team know about X?" | \`codiedev ask "<question>"\` (synthesized answer + citations) |
1523
1429
  | "show my library" / "what artifacts exist?" | \`codiedev library [--scope mine\\|shared\\|all] [--kind doc\\|skill]\` |
1524
- | "react 🛠 to that post" / "mark as used" | \`codiedev react <postId> <emoji>\` |
1430
+ | "run my agent's test cases" / "check the dataset on model Y" / "run v2 of the dataset" | \`codiedev traces cases <agent> [--dataset <name> [--version <n>]] [--model <id>]\` (writes cases + RUN_CASES.md + results.json; follow it, then \`codiedev traces report <results.json>\`) |
1431
+ | "how are my agents doing?" / "what's failing this week?" / "show the booking agent's failed runs" / "why did run X fail?" | \`codiedev traces overview\`; \`codiedev traces runs <agent> [--failed] [--flag <issue>] [--days n]\`; \`codiedev traces run <run> --agent <name>\` (add \`--json\` to read the data yourself) |
1432
+ | "add a helpfulness 1–5 score" / "make a review queue for tone" | \`codiedev traces score-kinds add <name> --type number --min 1 --max 5\`; \`codiedev traces queue create <name> --scores <a,b>\` |
1433
+ | "score that run 4 for helpfulness" / "how is my agent scoring?" | \`codiedev traces score <run> <score> <value> [--agent <name>] [--comment <why>]\`; \`codiedev traces scores <agent>\` |
1434
+ | "put this week's failed runs in the review queue" | \`codiedev traces queue list\`; \`codiedev traces queue add <queue> --agent <name> [--failed] [--days n]\` |
1435
+ | "have a judge score 10% of my agent's runs" / "turn off automatic scoring" | \`codiedev traces judge <agent> --on --scores <a,b> --sample 10 [--budget 25]\`; \`--off\`; no flags shows it |
1436
+ | "import these test cases from a CSV" / "add an example to the dataset" / "save the dataset as a version" | \`codiedev traces import <agent> <file> --dataset <name>\`; \`codiedev traces add-example <agent> --dataset <name> --input "…" [--expected "…"]\`; \`codiedev traces save-version <dataset>\`; \`codiedev traces versions <dataset>\` |
1437
+ | "what was the last eval on X?" / "file these eval results" | \`codiedev evals last <subject>\` before starting a related eval; \`codiedev evals report <run.json>\` after each variant runs (scores only — \`codiedev evals help\` for the shape) |
1525
1438
 
1526
1439
  **Just push the file — we'll figure out where it goes.** Every artifact
1527
1440
  is classified into one of two kinds at upload time:
@@ -1567,19 +1480,13 @@ Tell the user the portal URL when the submit returns.
1567
1480
  API token from https://codiedev.com/portal/integrations/claude-code
1568
1481
  - "ambiguous recipient" → surface candidates to the user
1569
1482
  `;
1570
- /**
1571
- * Write the codiedev agent-facing docs block to the user-level Claude
1572
- * instructions file so Claude Code picks up the commands in every session
1573
- * (no per-repo CLAUDE.md needed). Idempotent — replaces the existing block
1574
- * between its begin/end markers on re-run.
1575
- */
1576
- function writeInstructionsBlock(targetPath) {
1483
+ function writeInstructionsBlock(targetPath, body = CODIEDEV_INSTRUCTIONS_BODY) {
1577
1484
  const dir = path.dirname(targetPath);
1578
1485
  if (!fs.existsSync(dir)) {
1579
1486
  fs.mkdirSync(dir, { recursive: true });
1580
1487
  }
1581
1488
  const block = `${CODIEDEV_INSTRUCTIONS_BEGIN}\n` +
1582
- CODIEDEV_INSTRUCTIONS_BODY +
1489
+ body +
1583
1490
  `\n${CODIEDEV_INSTRUCTIONS_END}\n`;
1584
1491
  let existing = "";
1585
1492
  if (fs.existsSync(targetPath)) {
@@ -1589,14 +1496,12 @@ function writeInstructionsBlock(targetPath) {
1589
1496
  const endIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_END);
1590
1497
  let next;
1591
1498
  if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
1592
- // Replace the existing block in place.
1593
1499
  next =
1594
1500
  existing.slice(0, beginIdx) +
1595
1501
  block +
1596
1502
  existing.slice(endIdx + CODIEDEV_INSTRUCTIONS_END.length);
1597
1503
  }
1598
1504
  else if (existing.trim()) {
1599
- // Append with a blank-line separator.
1600
1505
  next = existing.replace(/\n*$/, "\n\n") + block;
1601
1506
  }
1602
1507
  else {
@@ -1610,8 +1515,18 @@ function installClaudeCodeInstructions() {
1610
1515
  function installCodexInstructions() {
1611
1516
  writeInstructionsBlock(CODEX_USER_INSTRUCTIONS_PATH);
1612
1517
  }
1613
- // Cursor uses .mdc rule files with YAML frontmatter (description, alwaysApply,
1614
- // globs). `.cursorrules` is deprecated and ignored by Agent mode as of 2026.
1518
+ function copilotInstructionsPath() {
1519
+ return path.join((0, copilotHooks_1.copilotHome)(), "copilot-instructions.md");
1520
+ }
1521
+ function piInstructionsPath() {
1522
+ return path.join((0, harnessDirs_1.piAgentDir)(), "AGENTS.md");
1523
+ }
1524
+ function installPiInstructions() {
1525
+ writeInstructionsBlock(piInstructionsPath(), `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}`);
1526
+ }
1527
+ function installCopilotInstructions() {
1528
+ writeInstructionsBlock(copilotInstructionsPath(), `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}`);
1529
+ }
1615
1530
  const CURSOR_INSTRUCTIONS_FRONTMATTER = `---
1616
1531
  description: Codiedev team artifact layer — push, pull, search, ping, and share artifacts with teammates
1617
1532
  alwaysApply: true
@@ -1625,7 +1540,7 @@ function installCursorInstructions() {
1625
1540
  const block = CURSOR_INSTRUCTIONS_FRONTMATTER +
1626
1541
  CODIEDEV_INSTRUCTIONS_BEGIN +
1627
1542
  "\n" +
1628
- CODIEDEV_INSTRUCTIONS_BODY +
1543
+ `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}` +
1629
1544
  "\n" +
1630
1545
  CODIEDEV_INSTRUCTIONS_END +
1631
1546
  "\n";
@@ -1633,8 +1548,6 @@ function installCursorInstructions() {
1633
1548
  if (fs.existsSync(CURSOR_RULES_PATH)) {
1634
1549
  existing = fs.readFileSync(CURSOR_RULES_PATH, "utf8");
1635
1550
  }
1636
- // If the file already has our markers, replace just the block — preserve
1637
- // any frontmatter edits the user made.
1638
1551
  const beginIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_BEGIN);
1639
1552
  const endIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_END);
1640
1553
  let next;
@@ -1643,7 +1556,7 @@ function installCursorInstructions() {
1643
1556
  existing.slice(0, beginIdx) +
1644
1557
  CODIEDEV_INSTRUCTIONS_BEGIN +
1645
1558
  "\n" +
1646
- CODIEDEV_INSTRUCTIONS_BODY +
1559
+ `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}` +
1647
1560
  "\n" +
1648
1561
  CODIEDEV_INSTRUCTIONS_END +
1649
1562
  existing.slice(endIdx + CODIEDEV_INSTRUCTIONS_END.length);
@@ -1653,6 +1566,38 @@ function installCursorInstructions() {
1653
1566
  }
1654
1567
  fs.writeFileSync(CURSOR_RULES_PATH, next, "utf8");
1655
1568
  }
1569
+ function installedInstructionsTargets() {
1570
+ const withTeamContext = `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}`;
1571
+ return [
1572
+ { path: CLAUDE_USER_INSTRUCTIONS_PATH, body: CODIEDEV_INSTRUCTIONS_BODY },
1573
+ { path: CODEX_USER_INSTRUCTIONS_PATH, body: CODIEDEV_INSTRUCTIONS_BODY },
1574
+ { path: copilotInstructionsPath(), body: withTeamContext },
1575
+ { path: CURSOR_RULES_PATH, body: withTeamContext },
1576
+ { path: piInstructionsPath(), body: withTeamContext },
1577
+ ];
1578
+ }
1579
+ function refreshInstalledInstructions(targets = installedInstructionsTargets()) {
1580
+ const refreshed = [];
1581
+ for (const target of targets) {
1582
+ try {
1583
+ const existing = fs.readFileSync(target.path, "utf8");
1584
+ const beginIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_BEGIN);
1585
+ const endIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_END);
1586
+ if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx)
1587
+ continue;
1588
+ const next = existing.slice(0, beginIdx) +
1589
+ `${CODIEDEV_INSTRUCTIONS_BEGIN}\n${target.body}\n${CODIEDEV_INSTRUCTIONS_END}` +
1590
+ existing.slice(endIdx + CODIEDEV_INSTRUCTIONS_END.length);
1591
+ if (next === existing)
1592
+ continue;
1593
+ fs.writeFileSync(target.path, next, "utf8");
1594
+ refreshed.push(target.path);
1595
+ }
1596
+ catch {
1597
+ }
1598
+ }
1599
+ return refreshed;
1600
+ }
1656
1601
  function readMcpConfigSafely(path) {
1657
1602
  if (!fs.existsSync(path))
1658
1603
  return {};
@@ -1669,10 +1614,6 @@ function readMcpConfigSafely(path) {
1669
1614
  return {};
1670
1615
  }
1671
1616
  }
1672
- /**
1673
- * Install the Codiedev MCP server into Claude Code's user-scope config.
1674
- * Safe to call multiple times — updates the existing entry if present.
1675
- */
1676
1617
  function installClaudeCodeMcp() {
1677
1618
  const config = readMcpConfigSafely(CLAUDE_USER_CONFIG_PATH);
1678
1619
  const mcpServers = config.mcpServers ?? {};
@@ -1685,12 +1626,6 @@ function installClaudeCodeMcp() {
1685
1626
  config.mcpServers = mcpServers;
1686
1627
  fs.writeFileSync(CLAUDE_USER_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
1687
1628
  }
1688
- /**
1689
- * Install the Codiedev MCP server into Cursor's user-scope config.
1690
- * Cursor uses the same `mcpServers` schema as Claude Code; just a different
1691
- * file path (~/.cursor/mcp.json). Project-scoped servers can also live at
1692
- * <project>/.cursor/mcp.json — we only manage the user-scope one.
1693
- */
1694
1629
  function installCursorMcp() {
1695
1630
  if (!fs.existsSync(CURSOR_DIR)) {
1696
1631
  fs.mkdirSync(CURSOR_DIR, { recursive: true });
@@ -1706,17 +1641,6 @@ function installCursorMcp() {
1706
1641
  config.mcpServers = mcpServers;
1707
1642
  fs.writeFileSync(CURSOR_MCP_PATH, JSON.stringify(config, null, 2), "utf8");
1708
1643
  }
1709
- /**
1710
- * Install the Codiedev MCP server into VS Code's user-scope MCP config so
1711
- * GitHub Copilot Chat picks it up. VS Code uses a different schema than
1712
- * Claude / Cursor: top-level key is `servers` (not `mcpServers`). On first
1713
- * Copilot Chat run after install, VS Code prompts the user to trust the
1714
- * server — that's expected and documented in the connect success message.
1715
- *
1716
- * Reverse-ticket and the other on-demand verbs work in Copilot identically
1717
- * to Claude Code since the MCP server's reverse_ticket handler is already
1718
- * 100% diff-driven (no transcript dependency).
1719
- */
1720
1644
  function installVSCodeMcp() {
1721
1645
  if (!fs.existsSync(VSCODE_USER_DIR)) {
1722
1646
  fs.mkdirSync(VSCODE_USER_DIR, { recursive: true });
@@ -1733,14 +1657,6 @@ function installVSCodeMcp() {
1733
1657
  config.servers = servers;
1734
1658
  fs.writeFileSync(VSCODE_USER_MCP_PATH, JSON.stringify(config, null, 2), "utf8");
1735
1659
  }
1736
- // ─────────────────────────────────────────────────────────────────────────────
1737
- // Migration cleanup — strips MCP server entries written by pre-CLI-only
1738
- // versions of `codiedev connect`. Those entries pointed at `npx codiedev-mcp`,
1739
- // a binary that was removed from package.json in 0.3.4. Without these
1740
- // cleanups, customers who upgrade to 0.7.11+ keep seeing "MCP server failed
1741
- // to start" errors in their agent UIs even though the CLI works fine.
1742
- // All cleanup helpers are idempotent and safe to run when no entry exists.
1743
- // ─────────────────────────────────────────────────────────────────────────────
1744
1660
  function removeJsonKey(filePath, parentKey) {
1745
1661
  if (!fs.existsSync(filePath))
1746
1662
  return;
@@ -1749,7 +1665,7 @@ function removeJsonKey(filePath, parentKey) {
1749
1665
  parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
1750
1666
  }
1751
1667
  catch {
1752
- return; // Don't damage a file we can't parse.
1668
+ return;
1753
1669
  }
1754
1670
  const parent = parsed[parentKey];
1755
1671
  if (!parent || typeof parent !== "object")
@@ -1759,33 +1675,15 @@ function removeJsonKey(filePath, parentKey) {
1759
1675
  delete parent.codiedev;
1760
1676
  fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), "utf8");
1761
1677
  }
1762
- /**
1763
- * Removes `mcpServers.codiedev` from `~/.claude.json` if present. Leaves
1764
- * other MCP servers and unrelated top-level settings untouched.
1765
- */
1766
1678
  function cleanupClaudeCodeMcp() {
1767
1679
  removeJsonKey(CLAUDE_USER_CONFIG_PATH, "mcpServers");
1768
1680
  }
1769
- /**
1770
- * Removes `mcpServers.codiedev` from `~/.cursor/mcp.json` if present.
1771
- */
1772
1681
  function cleanupCursorMcp() {
1773
1682
  removeJsonKey(CURSOR_MCP_PATH, "mcpServers");
1774
1683
  }
1775
- /**
1776
- * Removes `servers.codiedev` from VS Code's user-scope mcp.json. VS Code
1777
- * uses a different schema (`servers` not `mcpServers`), so this is a
1778
- * separate helper.
1779
- */
1780
1684
  function cleanupVSCodeMcp() {
1781
1685
  removeJsonKey(VSCODE_USER_MCP_PATH, "servers");
1782
1686
  }
1783
- /**
1784
- * Removes the `# codiedev-mcp` block from `~/.codex/config.toml`. The
1785
- * install side appends a well-marked block; we strip it the same way —
1786
- * the marker line through the end of its TOML section. Whitespace-only
1787
- * gaps between sections are preserved.
1788
- */
1789
1687
  function cleanupCodexMcp() {
1790
1688
  if (!fs.existsSync(CODEX_CONFIG_PATH))
1791
1689
  return;
@@ -1802,31 +1700,22 @@ function cleanupCodexMcp() {
1802
1700
  continue;
1803
1701
  }
1804
1702
  if (skipping) {
1805
- // Stay in skip mode through the [mcp_servers.codiedev] block until
1806
- // we hit a blank line or a different TOML section header.
1807
1703
  const trimmed = line.trim();
1808
1704
  if (trimmed === "") {
1809
1705
  skipping = false;
1810
- continue; // also drop the blank that immediately follows the block
1706
+ continue;
1811
1707
  }
1812
1708
  if (trimmed.startsWith("[") && !trimmed.startsWith("[mcp_servers.codiedev")) {
1813
1709
  skipping = false;
1814
1710
  out.push(line);
1815
1711
  continue;
1816
1712
  }
1817
- // Skip lines belonging to the codiedev block.
1818
1713
  continue;
1819
1714
  }
1820
1715
  out.push(line);
1821
1716
  }
1822
1717
  fs.writeFileSync(CODEX_CONFIG_PATH, out.join("\n"), "utf8");
1823
1718
  }
1824
- /**
1825
- * Best-effort append of the Codiedev MCP server block to ~/.codex/config.toml.
1826
- *
1827
- * Codex uses TOML and we don't want a full parser for such a small addition,
1828
- * so we append a well-marked block if one isn't already present. Idempotent.
1829
- */
1830
1719
  function installCodexMcp() {
1831
1720
  if (!fs.existsSync(CODEX_DIR)) {
1832
1721
  fs.mkdirSync(CODEX_DIR, { recursive: true });
@@ -1849,10 +1738,7 @@ function installCodexMcp() {
1849
1738
  fs.writeFileSync(CODEX_CONFIG_PATH, existing + block, "utf8");
1850
1739
  return true;
1851
1740
  }
1852
- // Codex doesn't have a SessionEnd event yet — Stop fires after every turn.
1853
- // The backend dedupes on (companyId, sessionId) and replaces the stored
1854
- // transcript on re-upload, so the latest turn's snapshot wins.
1855
- function installCodexHook(agentSecurityEnabled = true) {
1741
+ function installCodexHook() {
1856
1742
  let hooksFile = {};
1857
1743
  try {
1858
1744
  if (fs.existsSync(CODEX_HOOKS_PATH)) {
@@ -1861,7 +1747,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1861
1747
  }
1862
1748
  }
1863
1749
  catch {
1864
- // Start fresh if missing or invalid
1865
1750
  }
1866
1751
  if (!hooksFile.hooks) {
1867
1752
  hooksFile.hooks = {};
@@ -1876,9 +1761,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1876
1761
  return true;
1877
1762
  return !inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command));
1878
1763
  });
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.
1882
1764
  filtered.push({
1883
1765
  hooks: [
1884
1766
  {
@@ -1897,7 +1779,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1897
1779
  },
1898
1780
  ],
1899
1781
  });
1900
- // Stop → backup: auto-back-up changed artifact files to the private library.
1901
1782
  filtered.push({
1902
1783
  hooks: [
1903
1784
  {
@@ -1908,23 +1789,8 @@ function installCodexHook(agentSecurityEnabled = true) {
1908
1789
  ],
1909
1790
  });
1910
1791
  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.
1792
+ for (const eventName of RETIRED_SECURITY_TOOL_EVENTS)
1793
+ removeCodiedevHook(hooks, eventName);
1928
1794
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover-codex", null);
1929
1795
  if (!fs.existsSync(CODEX_DIR)) {
1930
1796
  fs.mkdirSync(CODEX_DIR, { recursive: true });
@@ -1932,8 +1798,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1932
1798
  fs.writeFileSync(CODEX_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
1933
1799
  (0, codexHookTrust_1.writeCodexHookTrust)(CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, hooksFile);
1934
1800
  }
1935
- // Existing Codex users already have capture-codex on Stop. After a CLI
1936
- // upgrade, that hook repairs newer lifecycle hooks without requiring connect.
1937
1801
  function ensureCodexLimitHookInstalled() {
1938
1802
  try {
1939
1803
  if (!fs.existsSync(CODEX_HOOKS_PATH))
@@ -1950,6 +1814,9 @@ function ensureCodexLimitHookInstalled() {
1950
1814
  return Array.isArray(inner) && inner.some((hook) => typeof hook.command === "string" && hook.command.trim().endsWith(subcommand));
1951
1815
  });
1952
1816
  let changed = false;
1817
+ for (const eventName of RETIRED_SECURITY_TOOL_EVENTS) {
1818
+ changed = removeCodiedevHook(hooks, eventName) || changed;
1819
+ }
1953
1820
  if (!hasCommand(stop, "limit-codex")) {
1954
1821
  stop.unshift({
1955
1822
  hooks: [{ type: "command", command: resolveHookCommand("limit-codex"), timeout: 10 }],
@@ -1968,19 +1835,12 @@ function ensureCodexLimitHookInstalled() {
1968
1835
  return;
1969
1836
  hooksFile.hooks = hooks;
1970
1837
  fs.writeFileSync(CODEX_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
1971
- // Trust keys include each hook's positional index. Adding the new hook at
1972
- // the front shifts capture/backup, so refresh every Codiedev hash together.
1973
1838
  (0, codexHookTrust_1.writeCodexHookTrust)(CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, hooksFile);
1974
1839
  }
1975
1840
  catch {
1976
- // Best-effort: capture must never fail because hook repair did.
1977
1841
  }
1978
1842
  }
1979
- // Cursor's hooks.json uses { version: 1, hooks: { sessionEnd: [{ command, type, timeout }] } }.
1980
- // `sessionEnd` fires when a conversation closes (completed, aborted, error,
1981
- // window_close, user_close); the base stdin payload includes transcript_path
1982
- // and session_id which `codiedev-hook capture-cursor` reads to upload.
1983
- function installCursorHook(agentSecurityEnabled = true) {
1843
+ function installCursorHook() {
1984
1844
  if (!fs.existsSync(CURSOR_DIR)) {
1985
1845
  fs.mkdirSync(CURSOR_DIR, { recursive: true });
1986
1846
  }
@@ -2016,32 +1876,84 @@ function installCursorHook(agentSecurityEnabled = true) {
2016
1876
  else
2017
1877
  hooks[eventName] = [...preserved, ...managed];
2018
1878
  };
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
1879
  replaceManaged("sessionStart", ["session-start-cursor"]);
2024
1880
  replaceManaged("sessionEnd", ["capture-cursor"]);
2025
1881
  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"]);
1882
+ replaceManaged("subagentStart", ["observe-cursor"]);
1883
+ replaceManaged("subagentStop", ["observe-cursor"]);
1884
+ replaceManaged("beforeSubmitPrompt", []);
1885
+ replaceManaged("preToolUse", []);
1886
+ replaceManaged("postToolUse", ["observe-cursor"]);
1887
+ replaceManaged("postToolUseFailure", ["observe-cursor"]);
1888
+ fs.writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
1889
+ }
1890
+ function copilotHooksPath() {
1891
+ return path.join((0, copilotHooks_1.copilotHome)(), "hooks", "codiedev.json");
1892
+ }
1893
+ function copilotManagedHooks() {
1894
+ return [
1895
+ ["sessionStart", "session-start-copilot", 10],
1896
+ ["userPromptSubmitted", "discover-copilot", 10],
1897
+ ["agentStop", "backup-copilot", 30],
1898
+ ["sessionEnd", "capture-copilot", 30],
1899
+ ["errorOccurred", "limit-copilot", 10],
1900
+ ];
1901
+ }
1902
+ const COPILOT_EVENTS = ["sessionStart", "userPromptSubmitted", "agentStop", "sessionEnd", "preToolUse", "postToolUse", "errorOccurred"];
1903
+ function installCopilotHook() {
1904
+ const hooksPath = copilotHooksPath();
1905
+ let hooksFile = {};
1906
+ try {
1907
+ if (fs.existsSync(hooksPath)) {
1908
+ const raw = fs.readFileSync(hooksPath, "utf8");
1909
+ hooksFile = raw.trim() ? JSON.parse(raw) : {};
1910
+ }
2032
1911
  }
2033
- else {
2034
- replaceManaged("beforeSubmitPrompt", []);
2035
- replaceManaged("preToolUse", []);
2036
- replaceManaged("postToolUse", []);
2037
- replaceManaged("postToolUseFailure", []);
1912
+ catch {
1913
+ hooksFile = {};
1914
+ }
1915
+ hooksFile.version = 1;
1916
+ const hooks = (hooksFile.hooks && typeof hooksFile.hooks === "object" ? hooksFile.hooks : {});
1917
+ const managed = copilotManagedHooks();
1918
+ for (const eventName of COPILOT_EVENTS) {
1919
+ const existing = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
1920
+ const preserved = existing.filter((hook) => !(0, detection_1.isCodiedevHookCommand)((hook.bash ?? hook.powershell ?? hook.command)));
1921
+ const ours = managed
1922
+ .filter(([event]) => event === eventName)
1923
+ .map(([, subcommand, timeoutSec]) => {
1924
+ const command = resolveHookCommand(subcommand);
1925
+ return { type: "command", bash: command, powershell: command, timeoutSec };
1926
+ });
1927
+ if (preserved.length + ours.length === 0)
1928
+ delete hooks[eventName];
1929
+ else
1930
+ hooks[eventName] = [...preserved, ...ours];
1931
+ }
1932
+ hooksFile.hooks = hooks;
1933
+ fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
1934
+ fs.writeFileSync(hooksPath, JSON.stringify(hooksFile, null, 2), "utf8");
1935
+ }
1936
+ function ensureCopilotCodiedevHooksInstalled() {
1937
+ try {
1938
+ const hooksPath = copilotHooksPath();
1939
+ if (!fs.existsSync(hooksPath))
1940
+ return;
1941
+ const parsed = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
1942
+ const hooks = parsed.hooks ?? {};
1943
+ const commandsFor = (eventName) => (Array.isArray(hooks[eventName]) ? hooks[eventName] : [])
1944
+ .map((entry) => (entry.bash ?? entry.powershell ?? entry.command))
1945
+ .filter((command) => typeof command === "string" && (0, detection_1.isCodiedevHookCommand)(command));
1946
+ const required = copilotManagedHooks();
1947
+ const complete = required.every(([eventName, subcommand]) => commandsFor(eventName).filter((command) => command.trim().endsWith(subcommand)).length === 1);
1948
+ const stale = COPILOT_EVENTS.some((eventName) => commandsFor(eventName).length !== required.filter(([event]) => event === eventName).length);
1949
+ if (complete && !stale)
1950
+ return;
1951
+ installCopilotHook();
1952
+ }
1953
+ catch {
2038
1954
  }
2039
- fs.writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
2040
1955
  }
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) {
1956
+ function ensureCursorCodiedevHooksInstalled() {
2045
1957
  try {
2046
1958
  if (!fs.existsSync(CURSOR_HOOKS_PATH))
2047
1959
  return;
@@ -2060,28 +1972,24 @@ function ensureCursorCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?
2060
1972
  ["sessionStart", "session-start-cursor"],
2061
1973
  ["sessionEnd", "capture-cursor"],
2062
1974
  ["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
- : []),
1975
+ ["postToolUse", "observe-cursor"],
1976
+ ["postToolUseFailure", "observe-cursor"],
1977
+ ["subagentStart", "observe-cursor"],
1978
+ ["subagentStop", "observe-cursor"],
2071
1979
  ];
2072
- const hasStaleSecurity = !agentSecurityEnabled && [
1980
+ const hasStaleSecurity = [
2073
1981
  "beforeSubmitPrompt", "preToolUse", "postToolUse", "postToolUseFailure",
2074
1982
  ].some((eventName) => {
2075
1983
  const entries = hooks[eventName];
2076
- return Array.isArray(entries) && entries.some((entry) => (0, detection_1.isCodiedevHookCommand)(entry.command));
1984
+ return Array.isArray(entries) && entries.some((entry) => (0, detection_1.isCodiedevHookCommand)(entry.command) &&
1985
+ !String(entry.command).trim().endsWith("observe-cursor"));
2077
1986
  });
2078
1987
  if (required.every(([eventName, subcommand]) => has(eventName, subcommand)) && !hasStaleSecurity) {
2079
1988
  return;
2080
1989
  }
2081
- installCursorHook(agentSecurityEnabled);
1990
+ installCursorHook();
2082
1991
  }
2083
1992
  catch {
2084
- // Best-effort — a malformed third-party config must never block capture.
2085
1993
  }
2086
1994
  }
2087
1995
  function parseClaudeSteering(content) {
@@ -2090,10 +1998,8 @@ function parseClaudeSteering(content) {
2090
1998
  for (const line of content.split("\n")) {
2091
1999
  if (!line.trim())
2092
2000
  continue;
2093
- // "[Request interrupted by user]" / "...for tool use" — Claude Code markers
2094
2001
  if (line.includes("[Request interrupted by user"))
2095
2002
  interrupts++;
2096
- // Standard Claude Code tool-rejection sentinels in tool_result content
2097
2003
  if (line.includes("doesn't want to take this action") ||
2098
2004
  line.includes("user doesn't want to proceed"))
2099
2005
  rejections++;
@@ -2118,56 +2024,59 @@ function parseCodexSteering(content) {
2118
2024
  }
2119
2025
  }
2120
2026
  catch {
2121
- // skip malformed
2122
2027
  }
2123
2028
  }
2124
2029
  return { interrupts, rejections };
2125
2030
  }
2126
2031
  function parseSteering(content, mode) {
2032
+ if (mode === "cursor" || mode === "copilot")
2033
+ return (0, nativeSteering_1.parseNativeSteering)(content);
2127
2034
  return mode === "codex"
2128
2035
  ? parseCodexSteering(content)
2129
2036
  : parseClaudeSteering(content);
2130
2037
  }
2131
2038
  function parseClaudeUsage(content) {
2132
- // Claude Code logs per-turn usage; sum across the session. `input_tokens` is
2133
- // already fresh (cache read/creation are reported separately).
2134
- let inputTokens = 0;
2135
- let outputTokens = 0;
2136
- let cacheReadTokens = 0;
2137
- let cacheCreationTokens = 0;
2039
+ const models = (0, transcriptModels_1.parseTranscriptModels)(content, "claude-code");
2138
2040
  let model = null;
2139
2041
  for (const line of content.split("\n")) {
2140
- if (!line.trim())
2141
- continue;
2142
2042
  try {
2143
- const e = JSON.parse(line);
2144
- const m = e?.message;
2145
- const u = m?.usage;
2146
- if (u) {
2147
- inputTokens += u.input_tokens ?? 0;
2148
- outputTokens += u.output_tokens ?? 0;
2149
- cacheReadTokens += u.cache_read_input_tokens ?? 0;
2150
- cacheCreationTokens += u.cache_creation_input_tokens ?? 0;
2151
- }
2152
- if (typeof m?.model === "string" && m.model)
2153
- model = m.model;
2154
- }
2155
- catch {
2156
- // skip malformed
2043
+ const value = JSON.parse(line)?.message?.model;
2044
+ if (typeof value === "string" && value.trim() && !value.startsWith("<"))
2045
+ model = value;
2157
2046
  }
2047
+ catch { }
2158
2048
  }
2159
- return {
2160
- inputTokens,
2161
- outputTokens,
2162
- cacheTokens: cacheReadTokens + cacheCreationTokens,
2163
- cacheReadTokens,
2164
- cacheCreationTokens,
2165
- model,
2166
- };
2049
+ const sum = (field) => models.reduce((total, usage) => total + usage[field], 0);
2050
+ const cacheReadTokens = sum("cacheReadTokens"), cacheCreationTokens = sum("cacheCreationTokens");
2051
+ const stats = { inputTokens: sum("inputTokens"), outputTokens: sum("outputTokens"),
2052
+ cacheTokens: cacheReadTokens + cacheCreationTokens, cacheReadTokens, cacheCreationTokens, model };
2053
+ return model ? stats : fromClaudeCostState(content, stats);
2054
+ }
2055
+ function fromClaudeCostState(content, stats) {
2056
+ let usage;
2057
+ for (const line of content.split("\n")) {
2058
+ if (!line.includes('"cost-state"'))
2059
+ continue;
2060
+ try {
2061
+ const e = JSON.parse(line);
2062
+ if (e?.type === "cost-state" && e.modelUsage && typeof e.modelUsage === "object")
2063
+ usage = e.modelUsage;
2064
+ }
2065
+ catch { }
2066
+ }
2067
+ const n = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
2068
+ const entries = Object.entries(usage ?? {}).filter(([id, u]) => id.trim() && !id.startsWith("<") && u && typeof u === "object");
2069
+ if (!entries.length)
2070
+ return stats;
2071
+ const [model] = entries.reduce((best, entry) => n(entry[1].outputTokens) > n(best[1].outputTokens) ? entry : best);
2072
+ if (stats.inputTokens || stats.outputTokens || stats.cacheTokens)
2073
+ return { ...stats, model };
2074
+ const total = (field) => entries.reduce((sum, [, u]) => sum + n(u[field]), 0);
2075
+ const cacheReadTokens = total("cacheReadInputTokens"), cacheCreationTokens = total("cacheCreationInputTokens");
2076
+ return { inputTokens: total("inputTokens"), outputTokens: total("outputTokens"),
2077
+ cacheTokens: cacheReadTokens + cacheCreationTokens, cacheReadTokens, cacheCreationTokens, model };
2167
2078
  }
2168
2079
  function parseCodexUsage(content) {
2169
- // Codex reports a CUMULATIVE token_count per turn — the last one is the
2170
- // session total. `input_tokens` includes the cached portion, so subtract it.
2171
2080
  let model = null;
2172
2081
  let last = null;
2173
2082
  for (const line of content.split("\n")) {
@@ -2185,7 +2094,6 @@ function parseCodexUsage(content) {
2185
2094
  }
2186
2095
  }
2187
2096
  catch {
2188
- // skip malformed
2189
2097
  }
2190
2098
  }
2191
2099
  if (!last) {
@@ -2195,30 +2103,19 @@ function parseCodexUsage(content) {
2195
2103
  const input = last.input_tokens ?? 0;
2196
2104
  return {
2197
2105
  inputTokens: Math.max(0, input - cached),
2198
- outputTokens: (last.output_tokens ?? 0) + (last.reasoning_output_tokens ?? 0),
2199
- // Codex only reports cached reads; creation is structurally zero.
2106
+ outputTokens: last.output_tokens ?? 0,
2200
2107
  cacheTokens: cached,
2201
2108
  cacheReadTokens: cached,
2202
2109
  cacheCreationTokens: 0,
2203
2110
  model,
2204
2111
  };
2205
2112
  }
2206
- // opencode SDK transcripts (the plugin's client.session.messages dump) are a
2207
- // single JSON ARRAY of {info, parts} — not Claude Code's JSONL. Sum assistant
2208
- // token/cost metadata; model = providerID/modelID (matches modelPricing slugs
2209
- // like "openrouter/…" never resolving — stored cost is authoritative here).
2210
2113
  function parseOpencodeUsage(content) {
2211
2114
  let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheCreationTokens = 0, costUsd = 0;
2212
- // A session can switch models (user picks another, or a provider falls back).
2213
- // Attribute it to the model that did the most work rather than whichever
2214
- // spoke last, since this label drives pricing and the model-mix card.
2215
2115
  const outputByModel = new Map();
2216
2116
  let lastModel = null;
2217
2117
  try {
2218
2118
  const parsed = JSON.parse(content);
2219
- // Accept both shapes the SDK can produce: a bare array, or the
2220
- // {data: [...]} envelope normalizeOpenCodeTranscript already unwraps.
2221
- // Disagreeing with that parser would silently zero out a session's usage.
2222
2119
  const list = Array.isArray(parsed)
2223
2120
  ? parsed
2224
2121
  : (parsed && typeof parsed === "object" && Array.isArray(parsed.data)
@@ -2248,14 +2145,10 @@ function parseOpencodeUsage(content) {
2248
2145
  }
2249
2146
  }
2250
2147
  }
2251
- catch { /* malformed transcript — zero-safe */ }
2252
- // Ties (e.g. every turn reporting zero output) fall back to the last model
2253
- // seen, preserving the previous behaviour for single-model sessions.
2148
+ catch { }
2254
2149
  let model = lastModel;
2255
2150
  let best = -1;
2256
2151
  for (const [id, out] of outputByModel) {
2257
- // >= so the later model wins a tie, matching the previous last-wins
2258
- // behaviour when no turn reports output tokens.
2259
2152
  if (out >= best) {
2260
2153
  best = out;
2261
2154
  model = id;
@@ -2271,8 +2164,6 @@ function parseUsage(content, mode) {
2271
2164
  return parseOpencodeUsage(content);
2272
2165
  return mode === "codex" ? parseCodexUsage(content) : parseClaudeUsage(content);
2273
2166
  }
2274
- // Lead-time start proxy: the earliest top-level `timestamp` in the transcript.
2275
- // Both Claude Code and Codex stamp every line, so this is mode-agnostic.
2276
2167
  function parseSessionStart(content) {
2277
2168
  let min = null;
2278
2169
  for (const line of content.split("\n")) {
@@ -2288,7 +2179,6 @@ function parseSessionStart(content) {
2288
2179
  }
2289
2180
  }
2290
2181
  catch {
2291
- // skip malformed
2292
2182
  }
2293
2183
  }
2294
2184
  return min;
@@ -2296,33 +2186,38 @@ function parseSessionStart(content) {
2296
2186
  function parseClaudeCodeStats(content) {
2297
2187
  let messageCount = 0;
2298
2188
  let toolCallCount = 0;
2189
+ const messages = new Set();
2190
+ const tools = new Set();
2299
2191
  const lines = content.split("\n").filter((l) => l.trim());
2300
2192
  for (const line of lines) {
2301
2193
  try {
2302
2194
  const entry = JSON.parse(line);
2303
2195
  if (!entry || typeof entry !== "object")
2304
2196
  continue;
2305
- if (entry.type === "user" || entry.type === "assistant") {
2197
+ const id = entry.message?.id ?? entry.uuid;
2198
+ const messageKey = `${entry.type}:${id ?? messages.size}`;
2199
+ if ((entry.type === "user" || entry.type === "assistant") && !messages.has(messageKey)) {
2200
+ messages.add(messageKey);
2306
2201
  messageCount++;
2307
2202
  }
2308
2203
  const c = entry.message?.content;
2309
2204
  if (Array.isArray(c)) {
2310
- for (const block of c) {
2311
- if (block && block.type === "tool_use")
2205
+ for (const [index, block] of c.entries()) {
2206
+ if (!block || block.type !== "tool_use")
2207
+ continue;
2208
+ const toolKey = block.id ?? `${messageKey}:${index}`;
2209
+ if (!tools.has(toolKey)) {
2210
+ tools.add(toolKey);
2312
2211
  toolCallCount++;
2212
+ }
2313
2213
  }
2314
2214
  }
2315
2215
  }
2316
2216
  catch {
2317
- // Skip malformed lines
2318
2217
  }
2319
2218
  }
2320
2219
  return { messageCount, toolCallCount };
2321
2220
  }
2322
- /** Convert OpenCode's SDK message array (`{info, parts}[]`) into the same
2323
- * newline-delimited shape used by Claude Code. The rest of capture—privacy
2324
- * exclusions, git keys, token parsing, scrubbing, storage, and merge joins—can
2325
- * therefore stay one shared pipeline instead of becoming a parallel product. */
2326
2221
  function normalizeOpenCodeTranscript(content) {
2327
2222
  let parsed;
2328
2223
  try {
@@ -2357,14 +2252,14 @@ function normalizeOpenCodeTranscript(content) {
2357
2252
  else if (part.type === "tool") {
2358
2253
  normalizedParts.push({
2359
2254
  type: "tool_use",
2360
- id: part.callID ?? part.id ?? "tool",
2255
+ id: part.callID ?? part.id,
2361
2256
  name: part.tool ?? "tool",
2362
2257
  input: part.state?.input ?? {},
2363
2258
  });
2364
2259
  if (part.state?.output != null || part.state?.error != null) {
2365
2260
  normalizedParts.push({
2366
2261
  type: "tool_result",
2367
- tool_use_id: part.callID ?? part.id ?? "tool",
2262
+ tool_use_id: part.callID ?? part.id,
2368
2263
  content: part.state?.output ?? part.state?.error,
2369
2264
  is_error: part.state?.status === "error" || part.state?.error != null,
2370
2265
  });
@@ -2380,6 +2275,7 @@ function normalizeOpenCodeTranscript(content) {
2380
2275
  type: role,
2381
2276
  timestamp,
2382
2277
  message: {
2278
+ id: typeof info.id === "string" ? info.id : undefined,
2383
2279
  role,
2384
2280
  model: typeof info.modelID === "string" ? info.modelID : undefined,
2385
2281
  usage: role === "assistant" ? {
@@ -2394,13 +2290,6 @@ function normalizeOpenCodeTranscript(content) {
2394
2290
  }
2395
2291
  return lines.join("\n") + (lines.length ? "\n" : "");
2396
2292
  }
2397
- // Codex session JSONL format (rollout-*.jsonl):
2398
- // {type:"session_meta", payload:{id, cwd, git:{repository_url,...}, ...}}
2399
- // {type:"response_item", payload:{type:"message", role, content:[{type:"input_text",text}|{type:"output_text",text}]}}
2400
- // {type:"response_item", payload:{type:"function_call", name, arguments, call_id}}
2401
- // {type:"response_item", payload:{type:"function_call_output", call_id, output}}
2402
- // {type:"event_msg", payload:{type:"token_count"|"agent_reasoning"|...}}
2403
- // {type:"turn_context", payload:{cwd, model, ...}}
2404
2293
  function parseCodexStats(content) {
2405
2294
  let messageCount = 0;
2406
2295
  let toolCallCount = 0;
@@ -2416,18 +2305,15 @@ function parseCodexStats(content) {
2416
2305
  if (payloadType === "message") {
2417
2306
  messageCount++;
2418
2307
  }
2419
- else if (payloadType === "function_call") {
2308
+ else if (payloadType === "function_call" || payloadType === "custom_tool_call") {
2420
2309
  toolCallCount++;
2421
2310
  }
2422
2311
  }
2423
2312
  catch {
2424
- // Skip malformed lines
2425
2313
  }
2426
2314
  }
2427
2315
  return { messageCount, toolCallCount };
2428
2316
  }
2429
- // Pull repository URL from the Codex transcript's session_meta line so we
2430
- // don't depend on the hook's cwd being a git repo.
2431
2317
  function extractCodexRepoUrl(content) {
2432
2318
  const lines = content.split("\n");
2433
2319
  for (const line of lines) {
@@ -2440,24 +2326,16 @@ function extractCodexRepoUrl(content) {
2440
2326
  const url = entry.payload?.git?.repository_url;
2441
2327
  if (typeof url === "string" && url.length > 0)
2442
2328
  return url;
2443
- return null; // session_meta is first — no point scanning further
2329
+ return null;
2444
2330
  }
2445
2331
  }
2446
2332
  catch {
2447
- // Keep scanning
2448
2333
  }
2449
2334
  }
2450
2335
  return null;
2451
2336
  }
2452
- // Give up after this many failed uploads (across sessions) so a permanently
2453
- // failing job never litters /tmp forever. Paired with UPLOAD_TTL_MS.
2454
2337
  exports.MAX_UPLOAD_ATTEMPTS = 5;
2455
2338
  exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
2456
- // Open the capture log for appending so a detached child's stderr lands there
2457
- // instead of vanishing into stdio:"ignore". Returns the stdio value to spawn
2458
- // with plus the parent's fd (to close after spawn — the child gets its own
2459
- // duplicate). Falls back to plain "ignore" when openSync is absent or throws,
2460
- // so a missing/unwritable log dir never breaks a dispatch.
2461
2339
  function openCaptureLogStdio(openSync) {
2462
2340
  if (!openSync)
2463
2341
  return { stdio: "ignore" };
@@ -2469,70 +2347,36 @@ function openCaptureLogStdio(openSync) {
2469
2347
  return { stdio: "ignore" };
2470
2348
  }
2471
2349
  }
2472
- // Best-effort close of the parent's log-fd copy. No-op when there's no fd or
2473
- // no closeSync dep — never throws into the capture path.
2474
2350
  function closeCaptureLogFd(fd, closeSync) {
2475
2351
  if (fd === undefined || !closeSync)
2476
2352
  return;
2477
2353
  try {
2478
2354
  closeSync(fd);
2479
2355
  }
2480
- catch { /* best-effort */ }
2481
- }
2482
- // Hand the SessionEnd capture off to a detached child so the inline hook can
2483
- // exit 0 immediately. Detaching only the network upload (dispatchCaptureDetached)
2484
- // wasn't enough: the synchronous transcript read + parse and — the killer on
2485
- // Windows — one `git rev-parse` per SHA in the transcript (parseAuthoredShas)
2486
- // still ran inline, overrunning Claude Code's SessionEnd shutdown window and
2487
- // triggering "Hook cancelled" on long sessions. This moves ALL of that work
2488
- // into the child. The job carries only the raw hook inputs (the child re-reads
2489
- // the transcript and re-runs git), so the parent's inline work is a single
2490
- // small file write plus a spawn.
2356
+ catch { }
2357
+ }
2491
2358
  function dispatchCaptureFinish(input, hookScriptPath, deps) {
2492
2359
  deps.writeFileSync(deps.tmpFile, JSON.stringify(input), { mode: 0o600 });
2493
- // Route the child's stderr to the capture log so any crash output is
2494
- // visible to `codiedev doctor`. The caller must explicitly pass openSync
2495
- // (e.g. fs.openSync in hook.ts) to enable this; when omitted (tests,
2496
- // older call-sites) we fall back to "ignore" so existing behaviour is
2497
- // unchanged and no real fd is opened.
2498
2360
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2499
2361
  try {
2500
2362
  const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "capture-finish", deps.tmpFile], { detached: true, stdio });
2501
2363
  child.unref();
2502
2364
  }
2503
2365
  finally {
2504
- // Close the PARENT's fd copy even when spawn throws — the child (if
2505
- // spawned) holds its own duplicate, so this never cuts off its stderr.
2506
2366
  closeCaptureLogFd(fd, deps.closeSync);
2507
2367
  }
2508
2368
  }
2509
- // Fire-and-forget the session-capture upload from a fully detached child so the
2510
- // SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
2511
- // Code's shutdown window elapse and kill the hook ("Hook cancelled") on large
2512
- // transcripts. The job (incl. bearer token) is written to a file rather than
2513
- // passed via argv so it never shows up in the process list.
2514
2369
  function dispatchCaptureDetached(job, hookScriptPath, deps) {
2515
2370
  deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
2516
- // Route the child's stderr to the capture log (same pattern as
2517
- // dispatchCaptureFinish). The caller must explicitly pass openSync to
2518
- // enable this; when omitted we fall back to "ignore" so existing callers
2519
- // keep working unchanged.
2520
2371
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2521
2372
  try {
2522
2373
  const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload", deps.tmpFile], { detached: true, stdio });
2523
2374
  child.unref();
2524
2375
  }
2525
2376
  finally {
2526
- // Close the PARENT's fd copy even when spawn throws — the child (if
2527
- // spawned) holds its own duplicate, so this never cuts off its stderr.
2528
2377
  closeCaptureLogFd(fd, deps.closeSync);
2529
2378
  }
2530
2379
  }
2531
- // Deferred twin of dispatchCaptureDetached: persist the job, then spawn the
2532
- // `upload-deferred` child which sleeps for delayMs before claiming + uploading
2533
- // (or finding itself superseded by a newer turn). Used when decideDispatch
2534
- // says a non-final per-turn capture is inside the throttle window. Identical
2535
- // stdio/fd-hygiene contract to the other dispatch fns.
2536
2380
  function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
2537
2381
  deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
2538
2382
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
@@ -2541,29 +2385,11 @@ function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
2541
2385
  child.unref();
2542
2386
  }
2543
2387
  finally {
2544
- // Close the PARENT's fd copy even when spawn throws — the child (if
2545
- // spawned) holds its own duplicate, so this never cuts off its stderr.
2546
2388
  closeCaptureLogFd(fd, deps.closeSync);
2547
2389
  }
2548
2390
  }
2549
- // A claimed job whose uploader hasn't finished after this long is presumed
2550
- // crashed and gets renamed back to its canonical name for a later retry.
2551
2391
  exports.CLAIM_STALE_MS = 15 * 60 * 1000;
2552
- // A finish-input file older than this is an orphan: its capture-finish child
2553
- // normally consumes + unlinks it within seconds, so an hour-old file means the
2554
- // child died (spawn failure, OOM/AV kill, machine sleep). Generous so we never
2555
- // race a legitimately slow child.
2556
2392
  exports.FINISH_ORPHAN_AGE_MS = 60 * 60 * 1000;
2557
- // Retry kept-on-failure capture jobs: re-dispatch each pending
2558
- // `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
2559
- // gives up at the cap). Best-effort — never throws into the hook.
2560
- //
2561
- // Each job is CLAIMED with an atomic rename before dispatch. Several
2562
- // session-end hooks can run this sweep over the same tmpdir at once, and
2563
- // without the claim each of them dispatched an uploader for the same job —
2564
- // every duplicate upload triggered a full server-side re-summarize of the
2565
- // session (real LLM spend per duplicate). Rename is atomic, so exactly one
2566
- // sweeper wins each file; the losers' renames throw and they skip it.
2567
2393
  function sweepPendingCaptures(deps) {
2568
2394
  let files;
2569
2395
  try {
@@ -2573,10 +2399,6 @@ function sweepPendingCaptures(deps) {
2573
2399
  return;
2574
2400
  }
2575
2401
  const now = deps.now?.() ?? Date.now();
2576
- // Recover claims orphaned by a crashed/killed uploader: a stale claim is
2577
- // renamed back to its canonical name so a LATER sweep retries it. (Not this
2578
- // one — the uploader may legitimately still be running near the threshold,
2579
- // and dispatching reclaimed work immediately would race it.)
2580
2402
  for (const f of files) {
2581
2403
  const m = /^(codiedev-capture-.*\.json)\.claim-\d+$/.exec(f);
2582
2404
  if (!m)
@@ -2588,14 +2410,8 @@ function sweepPendingCaptures(deps) {
2588
2410
  }
2589
2411
  }
2590
2412
  catch {
2591
- // raced another sweeper or the uploader just finished — fine
2592
2413
  }
2593
2414
  }
2594
- // Orphaned finish-inputs: the FIRST detached child (capture-finish) died
2595
- // before it could build an upload job. That path has no retry queue — the
2596
- // payload is gone — but the loss must not stay invisible: report the
2597
- // sessionId (filename embeds it: codiedev-finish-<sid>-<pid>-<ts>.json) so
2598
- // it lands as a skip-reason, then clean the file up.
2599
2415
  if (deps.unlinkSync) {
2600
2416
  for (const f of files) {
2601
2417
  const m = /^codiedev-finish-(.+)-\d+-\d+\.json$/.exec(f);
@@ -2606,19 +2422,13 @@ function sweepPendingCaptures(deps) {
2606
2422
  if (now - deps.statSync(orphanPath).mtimeMs < exports.FINISH_ORPHAN_AGE_MS)
2607
2423
  continue;
2608
2424
  deps.unlinkSync(orphanPath);
2609
- // finishInputTmpFile falls back to the PID when the hook had no
2610
- // session_id (codiedev-finish-<pid>-<pid>-<ts>.json). A pure-digit
2611
- // capture is that fallback, not a session id — reporting it would
2612
- // create a phantom session row server-side. Real session ids are
2613
- // UUIDs and never all-digits.
2614
2425
  const sid = /^\d+$/.test(m[1]) ? undefined : m[1];
2615
2426
  try {
2616
2427
  deps.reportOrphan?.(sid);
2617
2428
  }
2618
- catch { /* report is best-effort */ }
2429
+ catch { }
2619
2430
  }
2620
2431
  catch {
2621
- // raced its owner or another sweeper — fine
2622
2432
  }
2623
2433
  }
2624
2434
  }
@@ -2626,12 +2436,6 @@ function sweepPendingCaptures(deps) {
2626
2436
  .filter((f) => /^codiedev-capture-.*\.json$/.test(f))
2627
2437
  .map((f) => path.join(deps.dir, f))
2628
2438
  .filter((p) => p !== deps.exclude)
2629
- // Min-age guard: a YOUNG job file may be a sleeping deferred upload whose
2630
- // own upload-deferred child will consume it within CAPTURE_THROTTLE_MS of
2631
- // its mtime. The sweep must not steal it — that would upload per-turn and
2632
- // defeat the throttle with 2+ concurrent sessions. Only genuinely stuck
2633
- // jobs (older than SWEEP_MIN_AGE_MS) are retried. A stat failure means the
2634
- // job was claimed/consumed mid-sweep — skip it.
2635
2439
  .filter((p) => {
2636
2440
  try {
2637
2441
  return now - deps.statSync(p).mtimeMs >= exports.SWEEP_MIN_AGE_MS;
@@ -2641,8 +2445,6 @@ function sweepPendingCaptures(deps) {
2641
2445
  }
2642
2446
  })
2643
2447
  .slice(0, deps.max ?? 10);
2644
- // One log fd shared by every re-dispatched uploader in this batch (each
2645
- // spawn duplicates it); the parent's copy is closed after the loop.
2646
2448
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2647
2449
  for (const jobFile of jobs) {
2648
2450
  const claimed = `${jobFile}.claim-${process.pid}`;
@@ -2650,135 +2452,71 @@ function sweepPendingCaptures(deps) {
2650
2452
  deps.renameSync(jobFile, claimed);
2651
2453
  }
2652
2454
  catch {
2653
- continue; // another sweeper claimed it first
2455
+ continue;
2654
2456
  }
2655
2457
  try {
2656
2458
  const child = deps.spawn(deps.nodePath ?? process.execPath, [deps.hookScriptPath, "upload", claimed], { detached: true, stdio });
2657
2459
  child.unref();
2658
2460
  }
2659
2461
  catch {
2660
- // Couldn't fork the uploader — release the claim so a later sweep
2661
- // retries instead of the job dying invisible under the claimed name.
2662
2462
  try {
2663
2463
  deps.renameSync(claimed, jobFile);
2664
2464
  }
2665
2465
  catch {
2666
- // best-effort
2667
2466
  }
2668
2467
  }
2669
2468
  }
2670
2469
  closeCaptureLogFd(fd, deps.closeSync);
2671
- // Housekeeping: prune per-session upload-state files older than the upload
2672
- // TTL so capture-state doesn't grow forever. Best-effort, never throws.
2673
2470
  try {
2674
2471
  (deps.pruneState ?? pruneCaptureStateDir)();
2675
2472
  }
2676
2473
  catch {
2677
- // best-effort
2678
2474
  }
2679
2475
  }
2680
- // ─────────────────────────────────────────────────────────────────────────────
2681
- // Per-session upload throttle — defers mid-session Stop-hook uploads so we
2682
- // don't hammer the server (and re-summarize) after every single turn.
2683
- // ─────────────────────────────────────────────────────────────────────────────
2684
- // Minimum interval between non-final uploads for the same session. 10 minutes:
2685
- // fine-grained enough to capture recent work while avoiding per-turn churn.
2686
2476
  exports.CAPTURE_THROTTLE_MS = 10 * 60 * 1000;
2687
- // Minimum AGE of a job file before the retry sweep may claim it. An actively
2688
- // deferred job is consumed by its own upload-deferred child within
2689
- // CAPTURE_THROTTLE_MS of its mtime — a sweep stealing it early would upload
2690
- // per-turn and defeat the throttle whenever 2+ sessions run concurrently.
2691
- // The 2-min slack covers the child's wake-up + claim. Side effect: failed
2692
- // uploads (whose retry bump refreshes mtime) now wait ~12 min between retry
2693
- // attempts — fine for a 7-day-TTL retry queue, and a free rate-limit on
2694
- // retry storms.
2695
2477
  exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS + 2 * 60 * 1000;
2696
- // Map the hook's capture argv to the payload's `final` flag. EXPLICIT
2697
- // ALLOWLIST: Claude Code and Cursor SessionEnd entrypoints are true final
2698
- // signals. capture-opencode/
2699
- // capture-desktop (per-turn snapshot) and capture-opencode-final/
2700
- // capture-desktop-final (real session end) get an explicit false/true.
2701
- // Everything else — capture-codex (Codex's Stop fires per-turn; final:true
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
2704
- // audited here) — gets undefined, i.e. legacy server behaviour. Defaulting
2705
- // unknowns to true would silently re-introduce the per-turn lease-close bug
2706
- // for every new agent.
2707
2478
  function resolveCaptureFinal(argv2) {
2708
- if (argv2 === "capture-opencode" || argv2 === "capture-desktop")
2479
+ if (argv2 === "capture-opencode" || argv2 === "capture-desktop" || argv2 === "capture-pi")
2709
2480
  return false;
2710
- if (argv2 === "capture-opencode-final" || argv2 === "capture-desktop-final")
2481
+ if (argv2 === "capture-opencode-final" || argv2 === "capture-desktop-final" || argv2 === "capture-pi-final")
2711
2482
  return true;
2712
- return argv2 === "capture" || argv2 === "capture-cursor" || argv2 === undefined
2483
+ return argv2 === "capture" || argv2 === "capture-cursor" || argv2 === "capture-copilot" || argv2 === undefined
2713
2484
  ? true
2714
2485
  : undefined;
2715
2486
  }
2716
- // Build a UNIQUE finish-input tmpFile name per dispatch. Both the Stop hook and
2717
- // SessionEnd dispatch capture-finish children, and the child unlinks its input
2718
- // in `finally` — a SHARED per-session name let interleavings drop the final
2719
- // dispatch (Stop's child unlinks the file SessionEnd just wrote) or
2720
- // double-dispatch. Unique names mean each child owns its file exclusively; at
2721
- // worst concurrent Stop+SessionEnd children both upload, which is harmless
2722
- // (the server upserts by sessionId). Orphans (child dies before unlink) sit in
2723
- // tmpdir OUTSIDE the sweep regex (codiedev-finish- ≠ codiedev-capture-);
2724
- // negligible volume (one tiny JSON per crashed child), though note Windows
2725
- // %TEMP% is not auto-cleaned — acceptable.
2726
2487
  function finishInputTmpFile(sessionId) {
2727
2488
  const safeId = String(sessionId ?? process.pid).replace(/[^A-Za-z0-9._-]/g, "_");
2728
2489
  return path.join(os.tmpdir(), `codiedev-finish-${safeId}-${process.pid}-${Date.now()}.json`);
2729
2490
  }
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
2491
  function captureUploadTmpFile(sessionId, mode, pid = process.pid, now = Date.now()) {
2737
2492
  const id = String(sessionId ?? pid).replace(/[^A-Za-z0-9._-]/g, "_");
2738
2493
  return path.join(os.tmpdir(), mode === "immediate"
2739
2494
  ? `codiedev-capture-${id}-${pid}-${now}.json`
2740
2495
  : `codiedev-capture-${id}.json`);
2741
2496
  }
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
2497
  function cancelDeferredCaptureUpload(sessionId, deps = fs) {
2748
2498
  try {
2749
2499
  deps.unlinkSync(captureUploadTmpFile(sessionId, "deferred"));
2750
2500
  return true;
2751
2501
  }
2752
2502
  catch {
2753
- // Missing means there was nothing pending (or another process won).
2754
2503
  return false;
2755
2504
  }
2756
2505
  }
2757
- // Decide whether to upload immediately or defer until the throttle window expires.
2758
- // final=true → always immediate (SessionEnd: close the session now)
2759
- // null → immediate (first upload for this session)
2760
- // outside window → immediate
2761
- // inside window → deferred with exact remaining delay
2762
- // future clock skew → deferred clamped to CAPTURE_THROTTLE_MS
2763
2506
  function decideDispatch(args) {
2764
2507
  if (args.final || args.lastUploadAt === null)
2765
2508
  return { mode: "immediate" };
2766
2509
  const elapsed = args.now - args.lastUploadAt;
2767
- // Clock-skew guard: elapsed < 0 means lastUploadAt is in the future. Clamping
2768
- // to 0 also bounds delayMs to at most CAPTURE_THROTTLE_MS (safeElapsed >= 0).
2769
2510
  const safeElapsed = Math.max(0, elapsed);
2770
2511
  if (safeElapsed >= exports.CAPTURE_THROTTLE_MS)
2771
2512
  return { mode: "immediate" };
2772
2513
  return { mode: "deferred", delayMs: exports.CAPTURE_THROTTLE_MS - safeElapsed };
2773
2514
  }
2774
- // Per-session state dir: ~/.codiedev/capture-state/<sanitized-session-id>.json
2775
2515
  const CAPTURE_STATE_DIR = path.join(CONFIG_DIR, "capture-state");
2776
2516
  function captureStatePath(sessionId) {
2777
2517
  const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
2778
2518
  return path.join(CAPTURE_STATE_DIR, `${safe}.json`);
2779
2519
  }
2780
- // Read the per-session upload state from ~/.codiedev/capture-state/<id>.json.
2781
- // Returns null when missing or unreadable. Never throws.
2782
2520
  function readSessionUploadState(sessionId, deps = fs) {
2783
2521
  try {
2784
2522
  const p = captureStatePath(sessionId);
@@ -2790,8 +2528,6 @@ function readSessionUploadState(sessionId, deps = fs) {
2790
2528
  return null;
2791
2529
  }
2792
2530
  }
2793
- // Write the per-session upload state to ~/.codiedev/capture-state/<id>.json.
2794
- // Best-effort: never throws into the capture path.
2795
2531
  function writeSessionUploadState(sessionId, state, deps = fs) {
2796
2532
  try {
2797
2533
  if (!deps.existsSync(CAPTURE_STATE_DIR)) {
@@ -2801,13 +2537,8 @@ function writeSessionUploadState(sessionId, state, deps = fs) {
2801
2537
  deps.writeFileSync(p, JSON.stringify(state), { mode: 0o600 });
2802
2538
  }
2803
2539
  catch {
2804
- // best-effort — never break the capture path
2805
2540
  }
2806
2541
  }
2807
- // Prune per-session upload-state files older than UPLOAD_TTL_MS (7 days — same
2808
- // TTL as the upload retry queue) so ~/.codiedev/capture-state (one tiny file
2809
- // per session) doesn't grow forever. Called from sweepPendingCaptures, i.e. on
2810
- // the cadence captures already run housekeeping. Best-effort: never throws.
2811
2542
  function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now()) {
2812
2543
  try {
2813
2544
  for (const f of deps.readdirSync(dir)) {
@@ -2817,25 +2548,13 @@ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now
2817
2548
  deps.unlinkSync(p);
2818
2549
  }
2819
2550
  catch {
2820
- // raced another pruner or unreadable — skip
2821
2551
  }
2822
2552
  }
2823
2553
  }
2824
2554
  catch {
2825
- // dir missing/unreadable — nothing to prune
2826
2555
  }
2827
2556
  }
2828
- // Base64 length above which the transcript goes straight to storage instead
2829
- // of inline in the captureSession JSON body. The backend's HTTP-action
2830
- // isolate has a 64MB cap and inline base64 amplifies ~8x in memory (request
2831
- // text + parsed JSON + decode output all resident) — ~7MB transcripts OOM it
2832
- // (observed live 2026-07-02). 2M base64 chars ≈ 1.5MB raw: far below the
2833
- // cliff, while per-turn snapshots (typically KBs) skip the extra roundtrips.
2834
2557
  exports.DIRECT_UPLOAD_THRESHOLD_CHARS = 2_000_000;
2835
- // "Last session captured" breadcrumb. Written next to config.json on a
2836
- // successful capture upload; read by `codiedev doctor` so a user can confirm
2837
- // their most recent session actually landed (the single hardest thing to
2838
- // self-verify today — it previously required eyeballing the portal).
2839
2558
  const LAST_CAPTURE_PATH = path.join(CONFIG_DIR, "last-capture.json");
2840
2559
  function buildLastCaptureRecord(job, now) {
2841
2560
  const b = job.body;
@@ -2858,7 +2577,6 @@ function writeLastCapture(record, deps = fs, filePath = LAST_CAPTURE_PATH) {
2858
2577
  deps.writeFileSync(filePath, JSON.stringify(record), { mode: 0o600 });
2859
2578
  }
2860
2579
  catch {
2861
- // breadcrumb is best-effort — never throw on the capture path.
2862
2580
  }
2863
2581
  }
2864
2582
  function readLastCapture(deps = fs, filePath = LAST_CAPTURE_PATH) {
@@ -2871,21 +2589,16 @@ function readLastCapture(deps = fs, filePath = LAST_CAPTURE_PATH) {
2871
2589
  return null;
2872
2590
  }
2873
2591
  }
2874
- // Runs inside the detached child spawned by `dispatchCaptureDetached`: read the
2875
- // persisted job, POST it, and ALWAYS delete the temp file afterwards (even on
2876
- // failure) so we don't litter the tmp dir. Never throws — capture is
2877
- // best-effort.
2878
2592
  async function runCaptureUpload(jobFile, deps) {
2879
2593
  let job = null;
2880
2594
  try {
2881
2595
  job = JSON.parse(deps.readFileSync(jobFile, "utf8"));
2882
2596
  }
2883
2597
  catch {
2884
- // unreadable/corrupt job — delete it and stop.
2885
2598
  try {
2886
2599
  deps.unlinkSync(jobFile);
2887
2600
  }
2888
- catch { /* gone */ }
2601
+ catch { }
2889
2602
  return;
2890
2603
  }
2891
2604
  const sessionId = job.body?.sessionId;
@@ -2894,18 +2607,15 @@ async function runCaptureUpload(jobFile, deps) {
2894
2607
  try {
2895
2608
  deps.log({ stage: "upload", sessionId, outcome: "skip", detail: "ignored-path" });
2896
2609
  }
2897
- catch { /* log is best-effort */ }
2610
+ catch { }
2898
2611
  }
2899
2612
  try {
2900
2613
  deps.unlinkSync(jobFile);
2901
2614
  }
2902
- catch { /* gone */ }
2615
+ catch { }
2903
2616
  return;
2904
2617
  }
2905
2618
  try {
2906
- // Big transcript + capable deps → direct-to-storage: fetch a one-time
2907
- // upload URL, PUT the raw bytes, send captureSession the storageId. Any
2908
- // step failing throws into the retry path below, same as a failed POST.
2909
2619
  let body = job.body;
2910
2620
  const transcript = body?.transcript;
2911
2621
  if (transcript &&
@@ -2917,49 +2627,39 @@ async function runCaptureUpload(jobFile, deps) {
2917
2627
  const { storageId } = await deps.uploadBytes(uploadUrl, Buffer.from(transcript, "base64"), "application/json");
2918
2628
  body = { ...body, transcriptStorageId: storageId };
2919
2629
  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
2630
  job.body = body;
2925
2631
  if (deps.writeFileSync) {
2926
2632
  deps.writeFileSync(jobFile, JSON.stringify(job), { mode: 0o600 });
2927
2633
  }
2928
2634
  }
2929
2635
  await deps.post(job.url, body, job.token);
2930
- // success — drop a "last session captured" breadcrumb (before removing the
2931
- // job) so doctor can confirm the most recent session actually landed.
2932
2636
  if (deps.recordSuccess) {
2933
2637
  try {
2934
2638
  deps.recordSuccess(job);
2935
2639
  }
2936
- catch { /* breadcrumb is best-effort */ }
2640
+ catch { }
2937
2641
  }
2938
- // Write the per-session upload state so the throttle can defer the next
2939
- // non-final (per-turn) upload until the window expires.
2940
2642
  const uploadedSessionId = job.body?.sessionId;
2941
2643
  if (uploadedSessionId && deps.writeSessionState) {
2942
2644
  try {
2943
2645
  deps.writeSessionState(uploadedSessionId, { lastUploadAt: deps.now ? deps.now() : Date.now() });
2944
2646
  }
2945
- catch { /* best-effort */ }
2647
+ catch { }
2946
2648
  }
2947
2649
  const attempt = (job.attempts ?? 0) + 1;
2948
2650
  if (deps.log) {
2949
2651
  try {
2950
2652
  deps.log({ stage: "upload", sessionId, outcome: "ok", detail: `attempt=${attempt}` });
2951
2653
  }
2952
- catch { /* log is best-effort */ }
2654
+ catch { }
2953
2655
  }
2954
2656
  try {
2955
2657
  deps.unlinkSync(jobFile);
2956
2658
  }
2957
- catch { /* gone */ }
2659
+ catch { }
2958
2660
  return;
2959
2661
  }
2960
2662
  catch (err) {
2961
- // Failure: keep the job for a later sweep to retry, UNLESS we've hit the
2962
- // attempt cap or TTL — then give up so /tmp doesn't accumulate forever.
2963
2663
  const now = deps.now ? deps.now() : Date.now();
2964
2664
  const attempts = (job.attempts ?? 0) + 1;
2965
2665
  const queuedAt = job.queuedAt ?? now;
@@ -2976,19 +2676,12 @@ async function runCaptureUpload(jobFile, deps) {
2976
2676
  detail: `attempt=${attempts} age=${ageMs}ms`,
2977
2677
  });
2978
2678
  }
2979
- catch { /* log is best-effort */ }
2679
+ catch { }
2980
2680
  }
2981
- // Unlink FIRST: if the skip ping below hangs (no timeout on injected
2982
- // post impls), a still-present job file would be reclaimed by the
2983
- // 15-min stale-claim sweep and re-enter this give-up path forever.
2984
2681
  try {
2985
2682
  deps.unlinkSync(jobFile);
2986
2683
  }
2987
- catch { /* gone */ }
2988
- // Tell the server this capture is permanently lost and why. The full
2989
- // transcript POST may be what's blocked (proxy/DLP body inspection) —
2990
- // this tiny ping often still passes, turning an invisible loss into a
2991
- // per-reason bucket on the admin Capture Health panel.
2684
+ catch { }
2992
2685
  if (sessionId) {
2993
2686
  try {
2994
2687
  const skipUrl = job.url.replace(/\/api\/captureSession$/, "/api/cli/sessionSkip");
@@ -3000,52 +2693,42 @@ async function runCaptureUpload(jobFile, deps) {
3000
2693
  }, job.token);
3001
2694
  }
3002
2695
  }
3003
- catch { /* best-effort — the ping may be blocked too */ }
2696
+ catch { }
3004
2697
  }
3005
2698
  return;
3006
2699
  }
3007
- // Log the error before persisting the retry bump.
3008
2700
  if (deps.log) {
3009
2701
  try {
3010
2702
  const msg = err.message ?? String(err);
3011
- // Extract http=<status> when the error message carries one (format used
3012
- // by postJson: "HTTP <status>: <body>"). Truncate to 200 chars.
3013
2703
  const httpMatch = msg.match(/\bhttp[= ](\d{3})\b/i);
3014
2704
  const httpPart = httpMatch ? `http=${httpMatch[1]} ` : "";
3015
2705
  const detail = `${httpPart}${msg.slice(0, 200)} attempt=${attempts}`;
3016
2706
  deps.log({ stage: "upload", sessionId, outcome: "ERROR", detail });
3017
2707
  }
3018
- catch { /* log is best-effort */ }
2708
+ catch { }
3019
2709
  }
3020
- // The sweep hands us a CLAIMED path (`<job>.claim-<pid>`). Persist the
3021
- // retry bump under the canonical name — the sweep only matches
3022
- // `codiedev-capture-*.json`, so a bump left on the claimed path would
3023
- // never be retried (a silently dropped capture).
3024
2710
  const canonical = jobFile.replace(/\.claim-\d+$/, "");
3025
- const writeFn = deps.writeFileSync; // always set here: giveUp branch checked !deps.writeFileSync
2711
+ const writeFn = deps.writeFileSync;
3026
2712
  try {
3027
2713
  writeFn(canonical, JSON.stringify({ ...job, attempts, queuedAt }), { mode: 0o600 });
3028
2714
  if (canonical !== jobFile) {
3029
2715
  try {
3030
2716
  deps.unlinkSync(jobFile);
3031
2717
  }
3032
- catch { /* gone */ }
2718
+ catch { }
3033
2719
  }
3034
2720
  }
3035
2721
  catch {
3036
- // couldn't persist the bump — drop it rather than risk a stuck file.
3037
2722
  try {
3038
2723
  deps.unlinkSync(jobFile);
3039
2724
  }
3040
- catch { /* gone */ }
2725
+ catch { }
3041
2726
  }
3042
2727
  }
3043
2728
  }
3044
2729
  async function runDeferredUpload(jobFile, delayMs, deps) {
3045
2730
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
3046
2731
  await sleep(delayMs);
3047
- // Claim via rename. If the file is gone or already claimed, a newer turn's
3048
- // upload superseded this one: log and exit silently.
3049
2732
  const claimed = `${jobFile}.claim-${process.pid}`;
3050
2733
  try {
3051
2734
  deps.renameSync(jobFile, claimed);
@@ -3053,33 +2736,24 @@ async function runDeferredUpload(jobFile, delayMs, deps) {
3053
2736
  catch {
3054
2737
  if (deps.log) {
3055
2738
  try {
3056
- // The job file is gone, so its body (and sessionId) is unreadable —
3057
- // recover the session id from the per-session filename so doctor can
3058
- // still tie this line to a session.
3059
2739
  const sid = /^codiedev-capture-(.+)\.json/.exec(path.basename(jobFile))?.[1];
3060
2740
  deps.log({ stage: "upload-deferred", sessionId: sid, outcome: "superseded" });
3061
2741
  }
3062
- catch { /* log is best-effort */ }
2742
+ catch { }
3063
2743
  }
3064
2744
  return;
3065
2745
  }
3066
2746
  await runCaptureUpload(claimed, deps);
3067
2747
  }
3068
- // Classify a transcript read failure at capture time. ENOENT = the transcript
3069
- // never existed: a desktop-app phantom helper session (SessionStart/End hooks
3070
- // fire, no conversation ever happens), not lost work — the backend excludes
3071
- // "no-transcript" from coverage. Anything else means a real transcript was
3072
- // there but the read failed: a genuine loss, tagged with the errno so it stays
3073
- // diagnosable from the server side.
3074
2748
  function transcriptReadSkipReason(err) {
3075
2749
  const code = err?.code;
3076
2750
  if (code === "ENOENT")
3077
2751
  return "no-transcript";
3078
2752
  return `transcript-unreadable:${code || "unknown"}`;
3079
2753
  }
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.
2754
+ function isCodexRolloutPath(transcriptPath) {
2755
+ return Boolean(transcriptPath && /^rollout-.*\.jsonl$/.test(path.basename(transcriptPath)));
2756
+ }
3083
2757
  function shouldReportCaptureSkip(final) {
3084
2758
  return final !== false;
3085
2759
  }