codiedev 0.10.0-beta.4 → 0.10.0-beta.6

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