codiedev 0.10.0-beta.4 → 0.10.0-beta.5

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 (146) hide show
  1. package/README.md +7 -1
  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 +66 -3
  10. package/dist/cli.js +41 -18
  11. package/dist/codexHookTrust.js +61 -5
  12. package/dist/codexLimitScan.js +2 -109
  13. package/dist/codie.js +27 -40
  14. package/dist/codieBrandInstall.js +0 -18
  15. package/dist/codieLauncher.js +2 -70
  16. package/dist/codieRuntime.js +19 -23
  17. package/dist/codieRuntimeInstall.js +108 -23
  18. package/dist/codieStartup.js +0 -3
  19. package/dist/codieUsage.js +38 -41
  20. package/dist/commands/ask.js +0 -3
  21. package/dist/commands/capture.js +0 -2
  22. package/dist/commands/createTicket.js +0 -10
  23. package/dist/commands/doctor.js +41 -82
  24. package/dist/commands/inbox.js +0 -2
  25. package/dist/commands/library.js +0 -3
  26. package/dist/commands/pack.js +573 -0
  27. package/dist/commands/ping.js +0 -2
  28. package/dist/commands/pull.js +6 -10
  29. package/dist/commands/push.js +2 -12
  30. package/dist/commands/react.js +0 -2
  31. package/dist/commands/reverseTicket.js +0 -33
  32. package/dist/commands/search.js +0 -4
  33. package/dist/commands/send.js +0 -5
  34. package/dist/commands/share.js +0 -4
  35. package/dist/commands/shared.js +0 -35
  36. package/dist/commands/traces.js +280 -0
  37. package/dist/configDir.js +0 -10
  38. package/dist/connect.js +21 -77
  39. package/dist/connectFlow.js +0 -9
  40. package/dist/copilotHooks.js +395 -0
  41. package/dist/copilotInstallation.js +64 -0
  42. package/dist/cursorHooks.js +40 -17
  43. package/dist/cursorLifecycle.js +109 -0
  44. package/dist/detection.js +0 -30
  45. package/dist/harnessDirs.js +73 -0
  46. package/dist/hook.js +193 -316
  47. package/dist/inboxFormat.js +0 -24
  48. package/dist/launchContext.js +150 -0
  49. package/dist/launcher.js +140 -0
  50. package/dist/limitEvent.js +41 -35
  51. package/dist/limitEventReporter.js +1 -1
  52. package/dist/mcp.js +3 -77
  53. package/dist/nativeSteering.js +24 -0
  54. package/dist/packs/codie.js +182 -0
  55. package/dist/packs/harnesses.js +374 -0
  56. package/dist/packs/hookGate.js +256 -0
  57. package/dist/packs/install.js +285 -0
  58. package/dist/packs/manifest.js +126 -0
  59. package/dist/packs/paths.js +81 -0
  60. package/dist/packs/shim.js +214 -0
  61. package/dist/packs/store.js +155 -0
  62. package/dist/packs/sync.js +170 -0
  63. package/dist/prUrl.js +0 -5
  64. package/dist/repoResolver.js +0 -19
  65. package/dist/secret.js +0 -3
  66. package/dist/security.js +0 -20
  67. package/dist/securityDeepScan.js +1 -14
  68. package/dist/securityDoctor.js +0 -2
  69. package/dist/securityFeature.js +0 -1
  70. package/dist/securityInstall.js +6 -13
  71. package/dist/securityInventory.js +23 -13
  72. package/dist/securityPilot.js +0 -2
  73. package/dist/securityPolicy.js +0 -7
  74. package/dist/selfUpdate.js +331 -0
  75. package/dist/skillMarker.js +23 -0
  76. package/dist/subagents.js +135 -0
  77. package/dist/teamSkillContext.js +10 -0
  78. package/dist/transcriptModels.js +6 -8
  79. package/dist/upgradeNudge.js +0 -30
  80. package/dist/utils.js +286 -733
  81. package/dist/version.js +0 -10
  82. package/dist/versionCheck.js +0 -33
  83. package/package.json +7 -6
  84. package/dist/backup/cache.d.ts +0 -14
  85. package/dist/backup/match.d.ts +0 -2
  86. package/dist/backup/run.d.ts +0 -25
  87. package/dist/captureExclusions.d.ts +0 -30
  88. package/dist/captureLog.d.ts +0 -20
  89. package/dist/captureReadiness.d.ts +0 -65
  90. package/dist/captureSignals.d.ts +0 -19
  91. package/dist/cli.d.ts +0 -16
  92. package/dist/codexHookTrust.d.ts +0 -34
  93. package/dist/codexLimitScan.d.ts +0 -64
  94. package/dist/codie.d.ts +0 -2
  95. package/dist/codieBrandInstall.d.ts +0 -11
  96. package/dist/codieLauncher.d.ts +0 -86
  97. package/dist/codieRuntime.d.ts +0 -29
  98. package/dist/codieRuntimeInstall.d.ts +0 -28
  99. package/dist/codieStartup.d.ts +0 -26
  100. package/dist/codieUsage.d.ts +0 -57
  101. package/dist/commands/ask.d.ts +0 -1
  102. package/dist/commands/capture.d.ts +0 -14
  103. package/dist/commands/createTicket.d.ts +0 -27
  104. package/dist/commands/delete.d.ts +0 -1
  105. package/dist/commands/doctor.d.ts +0 -1
  106. package/dist/commands/inbox.d.ts +0 -3
  107. package/dist/commands/library.d.ts +0 -2
  108. package/dist/commands/note.d.ts +0 -1
  109. package/dist/commands/ping.d.ts +0 -1
  110. package/dist/commands/promote.d.ts +0 -1
  111. package/dist/commands/pull.d.ts +0 -2
  112. package/dist/commands/push.d.ts +0 -33
  113. package/dist/commands/react.d.ts +0 -2
  114. package/dist/commands/reverseTicket.d.ts +0 -2
  115. package/dist/commands/search.d.ts +0 -2
  116. package/dist/commands/security.d.ts +0 -1
  117. package/dist/commands/send.d.ts +0 -2
  118. package/dist/commands/share.d.ts +0 -2
  119. package/dist/commands/shared.d.ts +0 -64
  120. package/dist/configDir.d.ts +0 -1
  121. package/dist/connect.d.ts +0 -1
  122. package/dist/connectFlow.d.ts +0 -13
  123. package/dist/cursorHooks.d.ts +0 -23
  124. package/dist/detection.d.ts +0 -30
  125. package/dist/hook.d.ts +0 -6
  126. package/dist/inboxFormat.d.ts +0 -38
  127. package/dist/limitEvent.d.ts +0 -16
  128. package/dist/limitEventReporter.d.ts +0 -21
  129. package/dist/mcp.d.ts +0 -12
  130. package/dist/prUrl.d.ts +0 -9
  131. package/dist/repoResolver.d.ts +0 -17
  132. package/dist/secret.d.ts +0 -1
  133. package/dist/security.d.ts +0 -102
  134. package/dist/securityDeepScan.d.ts +0 -41
  135. package/dist/securityDoctor.d.ts +0 -32
  136. package/dist/securityFeature.d.ts +0 -5
  137. package/dist/securityInstall.d.ts +0 -63
  138. package/dist/securityInventory.d.ts +0 -54
  139. package/dist/securityPilot.d.ts +0 -19
  140. package/dist/securityPolicy.d.ts +0 -33
  141. package/dist/securitySession.d.ts +0 -6
  142. package/dist/transcriptModels.d.ts +0 -18
  143. package/dist/upgradeNudge.d.ts +0 -25
  144. package/dist/utils.d.ts +0 -346
  145. package/dist/version.d.ts +0 -1
  146. package/dist/versionCheck.d.ts +0 -68
package/dist/utils.js CHANGED
@@ -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,6 +61,7 @@ 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;
65
67
  exports.vscodeCopilotInstalled = vscodeCopilotInstalled;
@@ -70,9 +72,13 @@ exports.ensureDiscoveryHookInstalled = ensureDiscoveryHookInstalled;
70
72
  exports.ensureBackupHookInstalled = ensureBackupHookInstalled;
71
73
  exports.ensureLimitHookInstalled = ensureLimitHookInstalled;
72
74
  exports.ensureAllCodiedevHooksInstalled = ensureAllCodiedevHooksInstalled;
75
+ exports.hasDirectHookPath = hasDirectHookPath;
76
+ exports.migrateHooksToLauncher = migrateHooksToLauncher;
73
77
  exports.ensureSessionStartHookInstalled = ensureSessionStartHookInstalled;
74
78
  exports.installClaudeCodeInstructions = installClaudeCodeInstructions;
75
79
  exports.installCodexInstructions = installCodexInstructions;
80
+ exports.copilotInstructionsPath = copilotInstructionsPath;
81
+ exports.installCopilotInstructions = installCopilotInstructions;
76
82
  exports.installCursorInstructions = installCursorInstructions;
77
83
  exports.installClaudeCodeMcp = installClaudeCodeMcp;
78
84
  exports.installCursorMcp = installCursorMcp;
@@ -85,6 +91,9 @@ exports.installCodexMcp = installCodexMcp;
85
91
  exports.installCodexHook = installCodexHook;
86
92
  exports.ensureCodexLimitHookInstalled = ensureCodexLimitHookInstalled;
87
93
  exports.installCursorHook = installCursorHook;
94
+ exports.copilotHooksPath = copilotHooksPath;
95
+ exports.installCopilotHook = installCopilotHook;
96
+ exports.ensureCopilotCodiedevHooksInstalled = ensureCopilotCodiedevHooksInstalled;
88
97
  exports.ensureCursorCodiedevHooksInstalled = ensureCursorCodiedevHooksInstalled;
89
98
  exports.parseClaudeSteering = parseClaudeSteering;
90
99
  exports.parseCodexSteering = parseCodexSteering;
@@ -113,8 +122,10 @@ exports.readLastCapture = readLastCapture;
113
122
  exports.runCaptureUpload = runCaptureUpload;
114
123
  exports.runDeferredUpload = runDeferredUpload;
115
124
  exports.transcriptReadSkipReason = transcriptReadSkipReason;
125
+ exports.isCodexRolloutPath = isCodexRolloutPath;
116
126
  exports.shouldReportCaptureSkip = shouldReportCaptureSkip;
117
127
  const fs = __importStar(require("fs"));
128
+ const transcriptModels_1 = require("./transcriptModels");
118
129
  const path = __importStar(require("path"));
119
130
  const os = __importStar(require("os"));
120
131
  const child_process_1 = require("child_process");
@@ -123,6 +134,11 @@ const captureLog_1 = require("./captureLog");
123
134
  const configDir_1 = require("./configDir");
124
135
  Object.defineProperty(exports, "codiedevStateDir", { enumerable: true, get: function () { return configDir_1.codiedevStateDir; } });
125
136
  const codexHookTrust_1 = require("./codexHookTrust");
137
+ const launcher_1 = require("./launcher");
138
+ const teamSkillContext_1 = require("./teamSkillContext");
139
+ const nativeSteering_1 = require("./nativeSteering");
140
+ const copilotHooks_1 = require("./copilotHooks");
141
+ const harnessDirs_1 = require("./harnessDirs");
126
142
  const CONFIG_DIR = (0, configDir_1.codiedevStateDir)();
127
143
  exports.CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
128
144
  function readConfig() {
@@ -139,7 +155,6 @@ function readConfig() {
139
155
  function writeConfig(config) {
140
156
  writeConfigAtPath(config, CONFIG_DIR, exports.CONFIG_PATH);
141
157
  }
142
- /** Exported for permission-focused tests; normal callers use writeConfig. */
143
158
  function writeConfigAtPath(config, configDir, configPath) {
144
159
  if (!fs.existsSync(configDir)) {
145
160
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
@@ -148,12 +163,8 @@ function writeConfigAtPath(config, configDir, configPath) {
148
163
  encoding: "utf8",
149
164
  mode: 0o600,
150
165
  });
151
- // writeFile's mode does not replace permissions on a pre-existing file.
152
166
  fs.chmodSync(configPath, 0o600);
153
167
  }
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
168
  function parseGitRemoteFromConfig(configText) {
158
169
  const remotes = {};
159
170
  let current = null;
@@ -165,8 +176,6 @@ function parseGitRemoteFromConfig(configText) {
165
176
  continue;
166
177
  }
167
178
  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
179
  current = null;
171
180
  continue;
172
181
  }
@@ -182,13 +191,8 @@ function parseGitRemoteFromConfig(configText) {
182
191
  const first = Object.values(remotes)[0];
183
192
  return first ?? null;
184
193
  }
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
194
  function resolveGitDir(startDir) {
190
195
  let dir = path.resolve(startDir);
191
- // eslint-disable-next-line no-constant-condition
192
196
  while (true) {
193
197
  const dotGit = path.join(dir, ".git");
194
198
  try {
@@ -204,7 +208,6 @@ function resolveGitDir(startDir) {
204
208
  }
205
209
  }
206
210
  catch {
207
- // No .git here — keep walking up.
208
211
  }
209
212
  const parent = path.dirname(dir);
210
213
  if (parent === dir)
@@ -212,9 +215,6 @@ function resolveGitDir(startDir) {
212
215
  dir = parent;
213
216
  }
214
217
  }
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
218
  function gitConfigCandidates(gitDir) {
219
219
  const candidates = [];
220
220
  try {
@@ -226,15 +226,10 @@ function gitConfigCandidates(gitDir) {
226
226
  }
227
227
  }
228
228
  catch {
229
- // ignore — fall through to the plain config below
230
229
  }
231
230
  candidates.push(path.join(gitDir, "config"));
232
231
  return candidates;
233
232
  }
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
233
  function readGitRemoteFromConfig(startDir) {
239
234
  const gitDir = resolveGitDir(startDir);
240
235
  if (!gitDir)
@@ -246,20 +241,14 @@ function readGitRemoteFromConfig(startDir) {
246
241
  return url;
247
242
  }
248
243
  catch {
249
- // try the next candidate
250
244
  }
251
245
  }
252
246
  return null;
253
247
  }
254
248
  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
249
  const fromConfig = readGitRemoteFromConfig(cwd);
259
250
  if (fromConfig)
260
251
  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
252
  try {
264
253
  const url = (0, child_process_1.execSync)("git remote get-url origin", {
265
254
  cwd,
@@ -273,13 +262,6 @@ function getGitRemoteUrl(cwd) {
273
262
  return null;
274
263
  }
275
264
  }
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
265
  function resolveCaptureRemoteUrl(workspaceRoot, captureDirs, getRemote = getGitRemoteUrl) {
284
266
  const fromRoot = getRemote(workspaceRoot);
285
267
  if (fromRoot)
@@ -292,19 +274,14 @@ function resolveCaptureRemoteUrl(workspaceRoot, captureDirs, getRemote = getGitR
292
274
  return null;
293
275
  }
294
276
  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
277
  let normalized = url.trim();
298
- // Strip trailing .git
299
278
  if (normalized.endsWith(".git")) {
300
279
  normalized = normalized.slice(0, -4);
301
280
  }
302
- // Handle SSH format: git@github.com:org/repo → github.com/org/repo
303
281
  const sshMatch = normalized.match(/^git@([^:]+):(.+)$/);
304
282
  if (sshMatch) {
305
283
  return `${sshMatch[1]}/${sshMatch[2]}`;
306
284
  }
307
- // Handle HTTPS format: https://github.com/org/repo → github.com/org/repo
308
285
  const httpsMatch = normalized.match(/^https?:\/\/(.+)$/);
309
286
  if (httpsMatch) {
310
287
  return httpsMatch[1];
@@ -316,11 +293,6 @@ function matchRepo(remoteUrl, repos) {
316
293
  for (const repo of repos) {
317
294
  const normalizedUrl = normalizeRepoUrl(repo.url);
318
295
  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
296
  if (normalizedRemote === normalizedUrl ||
325
297
  normalizedRemote.endsWith(`/${normalizedName}`)) {
326
298
  return repo;
@@ -328,38 +300,11 @@ function matchRepo(remoteUrl, repos) {
328
300
  }
329
301
  return null;
330
302
  }
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
303
  function resolveCaptureDir(transcriptContent, workspaceRoot) {
346
304
  return resolveCaptureDirs(transcriptContent, workspaceRoot)[0] ?? workspaceRoot;
347
305
  }
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
306
  function resolveCaptureDirs(transcriptContent, workspaceRoot) {
354
307
  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
308
  const candidates = [];
364
309
  const add = (p) => {
365
310
  if (!p)
@@ -374,7 +319,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
374
319
  entry = JSON.parse(line);
375
320
  }
376
321
  catch {
377
- // partial/non-JSON line — best-effort: only mutating git -C, never files.
378
322
  for (const d of mutatingGitCDirs(line))
379
323
  add(d);
380
324
  continue;
@@ -397,10 +341,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
397
341
  }
398
342
  }
399
343
  }
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
344
  const out = [];
405
345
  const seenGitDirs = new Set();
406
346
  for (let i = candidates.length - 1; i >= 0; i--) {
@@ -414,10 +354,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
414
354
  }
415
355
  return out.length > 0 ? out : [workspaceRoot];
416
356
  }
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
357
  const MUTATING_GIT = /\b(?:commit|push|add|checkout|switch|merge|rebase|reset|restore|rm|mv|stash|apply|cherry-pick|am|tag|worktree)\b/;
422
358
  function mutatingGitCDirs(command) {
423
359
  const out = [];
@@ -431,8 +367,6 @@ function mutatingGitCDirs(command) {
431
367
  }
432
368
  return out;
433
369
  }
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
370
  function worktreeRootFromGitDir(gitDir) {
437
371
  try {
438
372
  const p = fs.readFileSync(path.join(gitDir, "gitdir"), "utf8").trim();
@@ -442,10 +376,6 @@ function worktreeRootFromGitDir(gitDir) {
442
376
  return null;
443
377
  }
444
378
  }
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
379
  function collectCaptureKeys(dirs, getBranch = getGitBranch, getSha = getGitHeadSha) {
450
380
  const branches = [];
451
381
  const headShas = [];
@@ -478,19 +408,12 @@ function collectCaptureGitRefs(dirs, getRemote = getGitRemoteUrl, getBranch = ge
478
408
  }
479
409
  return out;
480
410
  }
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
411
  function parsePullRequestUrls(transcriptContent) {
487
412
  const out = [];
488
413
  const createToolIds = new Set();
489
414
  const isCreateCommand = (command) => {
490
415
  if (typeof command !== "string")
491
416
  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
417
  if (/\b(?:gh\s+pr\s+(?:view|list|status)|glab\s+mr\s+(?:view|list))\b/i.test(command)) {
495
418
  return false;
496
419
  }
@@ -514,7 +437,6 @@ function parsePullRequestUrls(transcriptContent) {
514
437
  out.push(canonical);
515
438
  }
516
439
  catch {
517
- // Malformed tool output is ignored; capture remains best-effort.
518
440
  }
519
441
  }
520
442
  };
@@ -552,7 +474,6 @@ function parsePullRequestUrls(transcriptContent) {
552
474
  }
553
475
  }
554
476
  }
555
- // Codex function calls carry arguments as serialized JSON.
556
477
  if (entry?.type === "response_item"
557
478
  && entry?.payload?.type === "function_call"
558
479
  && typeof entry.payload.call_id === "string") {
@@ -561,7 +482,7 @@ function parsePullRequestUrls(transcriptContent) {
561
482
  try {
562
483
  args = JSON.parse(args);
563
484
  }
564
- catch { /* leave as string */ }
485
+ catch { }
565
486
  }
566
487
  if (isCreateCommand(args?.cmd ?? args?.command ?? args)) {
567
488
  createToolIds.add(entry.payload.call_id);
@@ -569,13 +490,8 @@ function parsePullRequestUrls(transcriptContent) {
569
490
  }
570
491
  }
571
492
  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
493
  }
575
494
  }
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
495
  for (const entry of entries) {
580
496
  const content = entry?.message?.content;
581
497
  if (Array.isArray(content)) {
@@ -597,17 +513,11 @@ function parsePullRequestUrls(transcriptContent) {
597
513
  }
598
514
  return out;
599
515
  }
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
516
  function extractShaTokens(transcriptContent) {
607
517
  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)
518
+ const commitRe = /\[[^\]\n]*?\b([0-9a-f]{7,40})\]/g;
519
+ const rangeRe = /\b([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})\b/g;
520
+ const fullRe = /\b([0-9a-f]{40})\b/g;
611
521
  let m;
612
522
  while ((m = commitRe.exec(transcriptContent)))
613
523
  out.add(m[1].toLowerCase());
@@ -619,17 +529,6 @@ function extractShaTokens(transcriptContent) {
619
529
  out.add(m[1].toLowerCase());
620
530
  return [...out];
621
531
  }
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
532
  function parseAuthoredBranches(transcriptContent) {
634
533
  const out = [];
635
534
  const add = (b) => {
@@ -642,25 +541,12 @@ function parseAuthoredBranches(transcriptContent) {
642
541
  while ((m = re.exec(text)))
643
542
  add(m[1]);
644
543
  };
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
544
  const commitRe = /\[([^\]\s]+)(?:\s+\(root-commit\))?\s+[0-9a-f]{7,40}\]/g;
653
545
  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
546
  const createRe = /\bgit\s+(?:checkout\s+-b|switch\s+-c)\s+([^\s"'&;|]+)/g;
659
547
  for (const line of transcriptContent.split("\n")) {
660
548
  if (!line.trim())
661
549
  continue;
662
- // commit/push output: scan the raw serialized line (output lives in string
663
- // fields); robust to non-JSON / partial lines.
664
550
  scan(line, commitRe);
665
551
  scan(line, pushRe);
666
552
  let entry;
@@ -681,11 +567,6 @@ function parseAuthoredBranches(transcriptContent) {
681
567
  }
682
568
  return out;
683
569
  }
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
570
  function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas) => defaultResolveFullShaBatch(shas, repoRoot)) {
690
571
  const out = new Set();
691
572
  const shorts = new Set();
@@ -704,14 +585,6 @@ function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas
704
585
  }
705
586
  return [...out];
706
587
  }
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
588
  function defaultResolveFullShaBatch(shas, repoRoot) {
716
589
  const out = new Map();
717
590
  if (shas.length === 0)
@@ -724,8 +597,6 @@ function defaultResolveFullShaBatch(shas, repoRoot) {
724
597
  stdio: ["pipe", "pipe", "pipe"],
725
598
  }).toString();
726
599
  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
600
  shas.forEach((sha, i) => {
730
601
  const parts = (lines[i] ?? "").trim().split(/\s+/);
731
602
  if (parts.length >= 2 && parts[1] === "commit" && /^[0-9a-f]{40}$/.test(parts[0])) {
@@ -734,14 +605,9 @@ function defaultResolveFullShaBatch(shas, repoRoot) {
734
605
  });
735
606
  }
736
607
  catch {
737
- // best-effort — a git failure just means no shas resolve
738
608
  }
739
609
  return out;
740
610
  }
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
611
  function getGitBranch(cwd) {
746
612
  const gitDir = resolveGitDir(cwd);
747
613
  if (gitDir) {
@@ -750,10 +616,8 @@ function getGitBranch(cwd) {
750
616
  const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
751
617
  if (m)
752
618
  return m[1].trim();
753
- // raw sha => detached HEAD, no branch
754
619
  }
755
620
  catch {
756
- // fall through to git
757
621
  }
758
622
  }
759
623
  try {
@@ -769,8 +633,6 @@ function getGitBranch(cwd) {
769
633
  return null;
770
634
  }
771
635
  }
772
- // Current commit SHA (HEAD), direct-read first then git fallback. Exact join
773
- // key — survives branch renames that branch-matching would miss.
774
636
  function getGitHeadSha(cwd) {
775
637
  const gitDir = resolveGitDir(cwd);
776
638
  if (gitDir) {
@@ -789,7 +651,6 @@ function getGitHeadSha(cwd) {
789
651
  return sha.toLowerCase();
790
652
  }
791
653
  catch {
792
- /* try packed-refs */
793
654
  }
794
655
  try {
795
656
  const packed = fs.readFileSync(path.join(baseDir, "packed-refs"), "utf8");
@@ -803,13 +664,11 @@ function getGitHeadSha(cwd) {
803
664
  }
804
665
  }
805
666
  catch {
806
- /* ignore */
807
667
  }
808
668
  }
809
669
  }
810
670
  }
811
671
  catch {
812
- // fall through to git
813
672
  }
814
673
  }
815
674
  try {
@@ -825,19 +684,22 @@ function getGitHeadSha(cwd) {
825
684
  return null;
826
685
  }
827
686
  }
687
+ function stripRepoUrlCredentials(url) {
688
+ return url.replace(/^(\s*[a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/?#]*@/, "$1");
689
+ }
828
690
  function buildCapturePayload(input) {
829
691
  return {
830
692
  sessionId: input.sessionId,
831
- repoUrl: input.remoteUrl ?? "",
693
+ repoUrl: stripRepoUrlCredentials(input.remoteUrl ?? ""),
832
694
  repoId: input.matchedRepoId,
833
695
  companyId: input.companyId,
834
696
  branch: input.branch ?? "",
835
697
  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
698
  branches: input.branches && input.branches.length ? input.branches : undefined,
839
699
  headShas: input.headShas && input.headShas.length ? input.headShas : undefined,
840
- gitRefs: input.gitRefs && input.gitRefs.length ? input.gitRefs : undefined,
700
+ gitRefs: input.gitRefs && input.gitRefs.length
701
+ ? input.gitRefs.map((ref) => ({ ...ref, repoUrl: stripRepoUrlCredentials(ref.repoUrl) }))
702
+ : undefined,
841
703
  pullRequestUrls: input.pullRequestUrls && input.pullRequestUrls.length
842
704
  ? input.pullRequestUrls
843
705
  : undefined,
@@ -846,8 +708,6 @@ function buildCapturePayload(input) {
846
708
  toolCallCount: input.toolCallCount,
847
709
  capturedAt: input.capturedAt,
848
710
  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
711
  inputTokens: input.inputTokens,
852
712
  outputTokens: input.outputTokens,
853
713
  cacheTokens: input.cacheTokens,
@@ -859,7 +719,6 @@ function buildCapturePayload(input) {
859
719
  sessionStartedAt: input.sessionStartedAt ?? undefined,
860
720
  interruptCount: input.interruptCount,
861
721
  rejectionCount: input.rejectionCount,
862
- // final is only emitted when explicitly set (omitted for Codex Stop).
863
722
  final: input.final,
864
723
  costUsd: input.costUsd,
865
724
  };
@@ -873,21 +732,18 @@ function hashToken(token) {
873
732
  }
874
733
  return hash.toString(36);
875
734
  }
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");
735
+ const CLAUDE_SETTINGS_PATH = path.join((0, harnessDirs_1.claudeConfigDir)(), "settings.json");
736
+ const CLAUDE_DIR = (0, harnessDirs_1.claudeConfigDir)();
737
+ const CODEX_HOOKS_PATH = path.join((0, harnessDirs_1.codexHome)(), "hooks.json");
738
+ const CODEX_DIR = (0, harnessDirs_1.codexHome)();
880
739
  const CURSOR_DIR = path.join(os.homedir(), ".cursor");
881
- const OPENCODE_DIR = path.join(os.homedir(), ".config", "opencode");
740
+ const OPENCODE_DIR = (0, harnessDirs_1.openCodeConfigDir)();
882
741
  const OPENCODE_PLUGIN_DIR = path.join(OPENCODE_DIR, "plugins");
883
742
  const OPENCODE_PLUGIN_PATH = path.join(OPENCODE_PLUGIN_DIR, "codiedev.js");
884
743
  const CURSOR_HOOKS_PATH = path.join(CURSOR_DIR, "hooks.json");
885
744
  const CURSOR_MCP_PATH = path.join(CURSOR_DIR, "mcp.json");
886
745
  const CURSOR_RULES_DIR = path.join(CURSOR_DIR, "rules");
887
746
  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
747
  const VSCODE_EXTENSIONS_DIR = path.join(os.homedir(), ".vscode", "extensions");
892
748
  const VSCODE_USER_DIR = (() => {
893
749
  const home = os.homedir();
@@ -901,40 +757,17 @@ const VSCODE_USER_DIR = (() => {
901
757
  return path.join(home, ".config", "Code", "User");
902
758
  })();
903
759
  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
760
  function shellQuote(s) {
910
761
  return `'${s.replace(/'/g, `'\\''`)}'`;
911
762
  }
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
763
  function extractHookNodePath(cmd) {
918
764
  if (!cmd)
919
765
  return undefined;
920
- // Match the first single-quoted token, honoring shellQuote's `'\''` escape.
921
766
  const m = cmd.match(/^'((?:[^']|'\\'')*)'/);
922
767
  if (!m)
923
768
  return undefined;
924
769
  return m[1].replace(/'\\''/g, "'");
925
770
  }
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
771
  function realpathOrSelf(p) {
939
772
  try {
940
773
  return fs.realpathSync(p);
@@ -943,13 +776,7 @@ function realpathOrSelf(p) {
943
776
  return p;
944
777
  }
945
778
  }
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
779
  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
780
  function resetStableNodeCacheForTests() {
954
781
  cachedStableNodeOnPath = undefined;
955
782
  }
@@ -970,30 +797,21 @@ function stableNodeOnPath() {
970
797
  }
971
798
  }
972
799
  catch {
973
- // No node on PATH (e.g. GUI-launched agent) — fall through.
974
800
  }
975
801
  cachedStableNodeOnPath = null;
976
802
  return undefined;
977
803
  }
978
804
  function resolveHookNodePath(preferredNodePath) {
979
805
  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
806
  if (preferredNodePath &&
986
807
  preferredNodePath !== process.execPath &&
987
808
  fs.existsSync(preferredNodePath) &&
988
809
  realpathOrSelf(preferredNodePath) === execReal) {
989
810
  return preferredNodePath;
990
811
  }
991
- // 2. Prefer a stable `node` on PATH over the version-specific realpath.
992
812
  const onPath = stableNodeOnPath();
993
813
  if (onPath)
994
814
  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
815
  return process.execPath;
998
816
  }
999
817
  function resolveHookInvocation(subcommand, preferredNodePath) {
@@ -1005,12 +823,12 @@ function resolveHookInvocation(subcommand, preferredNodePath) {
1005
823
  const hookScript = path.join(distDir, "hook.js");
1006
824
  if (fs.existsSync(hookScript)) {
1007
825
  const nodePath = resolveHookNodePath(preferredNodePath);
1008
- return { command: nodePath, args: [hookScript, subcommand] };
826
+ const launcher = (0, launcher_1.ensureLauncher)(hookScript);
827
+ return { command: nodePath, args: [launcher ?? hookScript, subcommand] };
1009
828
  }
1010
829
  }
1011
830
  }
1012
831
  catch {
1013
- // Fall through to npx fallback.
1014
832
  }
1015
833
  return {
1016
834
  command: process.platform === "win32" ? "npx.cmd" : "npx",
@@ -1022,8 +840,6 @@ function resolveHookCommand(subcommand, preferredNodePath) {
1022
840
  const argsBeforeSubcommand = invocation.args.slice(0, -1).map(shellQuote);
1023
841
  return [shellQuote(invocation.command), ...argsBeforeSubcommand, subcommand].join(" ");
1024
842
  }
1025
- // `isCodiedevHookCommand` is defined in `./detection` so it's testable
1026
- // without mocking fs/path. Re-imported below; do not re-define here.
1027
843
  function claudeBinOnPath() {
1028
844
  try {
1029
845
  (0, child_process_1.execSync)("command -v claude", { stdio: ["pipe", "pipe", "pipe"] });
@@ -1045,6 +861,19 @@ function codexInstalled() {
1045
861
  function cursorInstalled() {
1046
862
  return fs.existsSync(CURSOR_DIR);
1047
863
  }
864
+ function copilotInstalled() {
865
+ if (fs.existsSync((0, copilotHooks_1.copilotHome)()))
866
+ return true;
867
+ if (vscodeCopilotInstalled())
868
+ return true;
869
+ try {
870
+ (0, child_process_1.execSync)(process.platform === "win32" ? "where copilot" : "command -v copilot", { stdio: ["pipe", "pipe", "pipe"] });
871
+ return true;
872
+ }
873
+ catch {
874
+ return false;
875
+ }
876
+ }
1048
877
  function openCodeInstalled() {
1049
878
  if (fs.existsSync(OPENCODE_DIR))
1050
879
  return true;
@@ -1056,9 +885,6 @@ function openCodeInstalled() {
1056
885
  return false;
1057
886
  }
1058
887
  }
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
888
  function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1063
889
  const start = resolveHookInvocation("session-start-opencode");
1064
890
  const capture = resolveHookInvocation("capture-opencode");
@@ -1070,7 +896,7 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1070
896
  const securityFailure = resolveHookInvocation("security-failure-codie");
1071
897
  const source = `// Managed by codiedev connect. Safe to remove and reinstall.\n` +
1072
898
  `import { spawn, spawnSync } from "node:child_process";\n` +
1073
- `import { mkdir, writeFile } from "node:fs/promises";\n` +
899
+ `import { mkdirSync, writeFileSync, renameSync } from "node:fs";\n` +
1074
900
  `import os from "node:os";\n` +
1075
901
  `import path from "node:path";\n\n` +
1076
902
  `const START = ${JSON.stringify(start)};\n` +
@@ -1082,6 +908,9 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1082
908
  `const SECURITY_POST = ${JSON.stringify(securityPost)};\n` +
1083
909
  `const SECURITY_FAILURE = ${JSON.stringify(securityFailure)};\n` +
1084
910
  `const AGENT_SECURITY_ENABLED = ${JSON.stringify(agentSecurityEnabled)};\n` +
911
+ `// How this Codie process was started (run / serve / tui). OpenCode records\n` +
912
+ `// nothing about it, so the plugin reads its own argv; see launchContext.ts.\n` +
913
+ `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
914
  `const fingerprints = new Map();\n\n` +
1086
915
  `const transcriptPaths = new Map();\n\n` +
1087
916
  `const sessionDirectories = new Map();\n\n` +
@@ -1136,7 +965,7 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1136
965
  ` const finalizeAll = () => {\n` +
1137
966
  ` for (const [id, transcriptPath] of transcriptPaths) {\n` +
1138
967
  ` const cwd = sessionDirectories.get(id) || worktree || directory || process.cwd();\n` +
1139
- ` dispatchSync(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
968
+ ` dispatchSync(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1140
969
  ` }\n` +
1141
970
  ` };\n` +
1142
971
  ` process.once("exit", finalizeAll);\n` +
@@ -1147,7 +976,11 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1147
976
  ` const prompt = Array.isArray(output?.parts) ? output.parts.filter((part) => part?.type === "text").map((part) => part.text || "").join("\\n") : "";\n` +
1148
977
  ` const decision = await enforce(PROMPT, { session_id: id, cwd, prompt });\n` +
1149
978
  ` const context = decision?.hookSpecificOutput?.additionalContext;\n` +
1150
- ` if (context && Array.isArray(output?.parts)) output.parts.push({ type: "text", text: context });\n` +
979
+ ` if (context && Array.isArray(output?.parts)) {\n` +
980
+ ` // Preserve the native part IDs required by current engine schemas.\n` +
981
+ ` const part = output.parts.find((part) => part?.type === "text" && typeof part.text === "string");\n` +
982
+ ` if (part) part.text += "\\n\\n" + context;\n` +
983
+ ` }\n` +
1151
984
  ` },\n` +
1152
985
  ` "tool.execute.before": async (input, output) => {\n` +
1153
986
  ` if (!AGENT_SECURITY_ENABLED) return;\n` +
@@ -1167,12 +1000,12 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1167
1000
  ` if (!id) return;\n` +
1168
1001
  ` const cwd = await resolveCwd(id, event?.properties?.info?.directory);\n` +
1169
1002
  ` if (event.type === "session.created") {\n` +
1170
- ` dispatch(START, { session_id: id, cwd, workspace_roots: [cwd] });\n` +
1003
+ ` dispatch(START, { session_id: id, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1171
1004
  ` return;\n` +
1172
1005
  ` }\n` +
1173
1006
  ` if (event.type === "session.deleted") {\n` +
1174
1007
  ` const transcriptPath = transcriptPaths.get(id);\n` +
1175
- ` if (transcriptPath) dispatch(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd] });\n` +
1008
+ ` if (transcriptPath) dispatch(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1176
1009
  ` transcriptPaths.delete(id);\n` +
1177
1010
  ` sessionDirectories.delete(id);\n` +
1178
1011
  ` fingerprints.delete(id);\n` +
@@ -1186,15 +1019,19 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1186
1019
  ` const last = list.at(-1);\n` +
1187
1020
  ` const fingerprint = String(list.length) + ":" + String(last?.info?.id ?? last?.info?.time?.completed ?? "");\n` +
1188
1021
  ` if (fingerprints.get(id) === fingerprint) return;\n` +
1189
- ` fingerprints.set(id, fingerprint);\n` +
1190
1022
  ` const captureDir = path.join(os.homedir(), ".codiedev", "opencode-captures");\n` +
1191
- ` await mkdir(captureDir, { recursive: true, mode: 0o700 });\n` +
1023
+ ` mkdirSync(captureDir, { recursive: true, mode: 0o700 });\n` +
1192
1024
  ` const safeId = String(id).replace(/[^a-zA-Z0-9._-]/g, "_");\n` +
1193
1025
  ` const transcriptPath = path.join(captureDir, safeId + ".json");\n` +
1194
- ` await writeFile(transcriptPath, JSON.stringify(list), { mode: 0o600 });\n` +
1026
+ ` // The engine may exit as soon as it emits idle. Publish the complete\n` +
1027
+ ` // transcript synchronously so exit finalization never sees a partial file.\n` +
1028
+ ` const temporaryPath = transcriptPath + "." + process.pid + ".tmp";\n` +
1029
+ ` writeFileSync(temporaryPath, JSON.stringify(list), { mode: 0o600 });\n` +
1030
+ ` renameSync(temporaryPath, transcriptPath);\n` +
1195
1031
  ` 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` +
1032
+ ` fingerprints.set(id, fingerprint);\n` +
1033
+ ` dispatch(CAPTURE, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1034
+ ` dispatch(BACKUP, { session_id: id, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1198
1035
  ` } catch {}\n` +
1199
1036
  ` },\n` +
1200
1037
  ` });\n` +
@@ -1203,11 +1040,6 @@ function installOpenCodeCapturePlugin(agentSecurityEnabled = true) {
1203
1040
  fs.writeFileSync(OPENCODE_PLUGIN_PATH, source, { encoding: "utf8", mode: 0o600 });
1204
1041
  fs.chmodSync(OPENCODE_PLUGIN_PATH, 0o600);
1205
1042
  }
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
- */
1211
1043
  function vscodeCopilotInstalled() {
1212
1044
  if (!fs.existsSync(VSCODE_EXTENSIONS_DIR))
1213
1045
  return false;
@@ -1220,19 +1052,10 @@ function vscodeCopilotInstalled() {
1220
1052
  return false;
1221
1053
  }
1222
1054
  }
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
1055
  function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
1228
1056
  if (!hooks[eventName])
1229
1057
  hooks[eventName] = [];
1230
1058
  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
1059
  let preservedNodePath;
1237
1060
  for (const hook of existing) {
1238
1061
  const inner = hook.hooks;
@@ -1257,7 +1080,6 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
1257
1080
  });
1258
1081
  hooks[eventName] = filtered;
1259
1082
  }
1260
- /** Remove Codiedev's entries for one event while preserving foreign hooks. */
1261
1083
  function removeCodiedevHook(hooks, eventName) {
1262
1084
  const entries = hooks[eventName];
1263
1085
  if (!Array.isArray(entries))
@@ -1295,16 +1117,12 @@ function installHook(agentSecurityEnabled = true) {
1295
1117
  }
1296
1118
  }
1297
1119
  catch {
1298
- // Start with empty settings if file doesn't exist or is invalid
1299
1120
  }
1300
1121
  if (!settings.hooks) {
1301
1122
  settings.hooks = {};
1302
1123
  }
1303
1124
  const hooks = settings.hooks;
1304
- // SessionEnd → capture (upload the finished session for the team library).
1305
1125
  upsertCodiedevHook(hooks, "SessionEnd", "capture");
1306
- // Tool boundaries → ADR shadow detection. PreToolUse is the eventual
1307
- // enforcement point; alert mode always returns allow.
1308
1126
  if (agentSecurityEnabled) {
1309
1127
  upsertCodiedevHook(hooks, "PreToolUse", "security-pre");
1310
1128
  upsertCodiedevHook(hooks, "PostToolUse", "security-post");
@@ -1315,28 +1133,19 @@ function installHook(agentSecurityEnabled = true) {
1315
1133
  removeCodiedevHook(hooks, "PostToolUse");
1316
1134
  removeCodiedevHook(hooks, "PostToolUseFailure");
1317
1135
  }
1318
- // UserPromptSubmit → discover (surface a matching team skill for the task).
1319
1136
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover");
1320
- // Stop → backup (auto-back-up changed artifact files to your private library).
1321
1137
  upsertCodiedevHook(hooks, "Stop", "backup");
1322
- // StopFailure (matcher rate_limit) → limit (post rate-limit event to the team).
1323
1138
  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
1139
  upsertCodiedevHook(hooks, "SessionStart", "session-start");
1327
1140
  if (!fs.existsSync(CLAUDE_DIR)) {
1328
1141
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
1329
1142
  }
1330
1143
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1331
1144
  }
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
1145
  function ensureDiscoveryHookInstalled() {
1337
1146
  try {
1338
1147
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
1339
- return; // not a Claude Code user
1148
+ return;
1340
1149
  const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8");
1341
1150
  const settings = JSON.parse(raw);
1342
1151
  const hooks = (settings.hooks ?? {});
@@ -1353,13 +1162,8 @@ function ensureDiscoveryHookInstalled() {
1353
1162
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1354
1163
  }
1355
1164
  catch {
1356
- // Best-effort — never block a session on self-heal.
1357
1165
  }
1358
1166
  }
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
1167
  function ensureBackupHookInstalled() {
1364
1168
  try {
1365
1169
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1377,12 +1181,8 @@ function ensureBackupHookInstalled() {
1377
1181
  upsertCodiedevHook(hooks, "Stop", "backup");
1378
1182
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1379
1183
  }
1380
- catch { /* best-effort */ }
1184
+ catch { }
1381
1185
  }
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
1186
  function ensureLimitHookInstalled() {
1387
1187
  try {
1388
1188
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1400,26 +1200,12 @@ function ensureLimitHookInstalled() {
1400
1200
  upsertCodiedevHook(hooks, "StopFailure", "limit", "rate_limit");
1401
1201
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1402
1202
  }
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.
1203
+ catch { }
1204
+ }
1419
1205
  function ensureAllCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.agentSecurityEnabled === true) {
1420
1206
  try {
1421
1207
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
1422
- return; // not a Claude Code user
1208
+ return;
1423
1209
  const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
1424
1210
  const hooks = (settings.hooks ?? {});
1425
1211
  const wanted = [
@@ -1440,7 +1226,7 @@ function ensureAllCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.ag
1440
1226
  }
1441
1227
  const missing = wanted.filter(([eventName]) => !hasCodiedevHook(hooks, eventName));
1442
1228
  if (missing.length === 0 && !changed)
1443
- return; // steady state: no write, no churn
1229
+ return;
1444
1230
  settings.hooks = hooks;
1445
1231
  for (const [eventName, subcommand, matcher] of missing) {
1446
1232
  upsertCodiedevHook(hooks, eventName, subcommand, matcher);
@@ -1448,16 +1234,38 @@ function ensureAllCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.ag
1448
1234
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1449
1235
  }
1450
1236
  catch {
1451
- // Best-effort — never block a session on self-heal.
1452
1237
  }
1453
1238
  }
1239
+ const DIRECT_HOOK_PATH = /codiedev[^"'\s/\\]*[\\/]+dist[\\/]+hook\.js/;
1240
+ function hasDirectHookPath(configText) {
1241
+ return DIRECT_HOOK_PATH.test(configText);
1242
+ }
1243
+ function migrateHooksToLauncher(agentSecurityEnabled = readConfig()?.agentSecurityEnabled === true) {
1244
+ const probe = resolveHookInvocation("session-start");
1245
+ if (!(0, launcher_1.usesLauncher)(probe.args[0]))
1246
+ return [];
1247
+ const targets = [
1248
+ ["claude-code", CLAUDE_SETTINGS_PATH, () => installHook(agentSecurityEnabled)],
1249
+ ["codex", CODEX_HOOKS_PATH, () => installCodexHook(agentSecurityEnabled)],
1250
+ ["cursor", CURSOR_HOOKS_PATH, () => installCursorHook(agentSecurityEnabled)],
1251
+ ["copilot", copilotHooksPath(), () => installCopilotHook(agentSecurityEnabled)],
1252
+ ["opencode", OPENCODE_PLUGIN_PATH, () => installOpenCodeCapturePlugin(agentSecurityEnabled)],
1253
+ ];
1254
+ const migrated = [];
1255
+ for (const [name, file, install] of targets) {
1256
+ try {
1257
+ if (!fs.existsSync(file) || !hasDirectHookPath(fs.readFileSync(file, "utf8")))
1258
+ continue;
1259
+ install();
1260
+ migrated.push(name);
1261
+ }
1262
+ catch {
1263
+ }
1264
+ }
1265
+ return migrated;
1266
+ }
1454
1267
  function hasCodiedevHook(hooks, eventName) {
1455
1268
  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
1269
  if (!Array.isArray(entries))
1462
1270
  return true;
1463
1271
  return entries.some((hook) => {
@@ -1466,10 +1274,6 @@ function hasCodiedevHook(hooks, eventName) {
1466
1274
  inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command)));
1467
1275
  });
1468
1276
  }
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
1277
  function ensureSessionStartHookInstalled() {
1474
1278
  try {
1475
1279
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1487,16 +1291,12 @@ function ensureSessionStartHookInstalled() {
1487
1291
  upsertCodiedevHook(hooks, "SessionStart", "session-start");
1488
1292
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1489
1293
  }
1490
- catch { /* best-effort */ }
1294
+ catch { }
1491
1295
  }
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");
1296
+ const CLAUDE_USER_CONFIG_PATH = (0, harnessDirs_1.claudeUserConfigPath)();
1297
+ const CLAUDE_USER_INSTRUCTIONS_PATH = path.join((0, harnessDirs_1.claudeConfigDir)(), "CLAUDE.md");
1298
+ const CODEX_USER_INSTRUCTIONS_PATH = path.join((0, harnessDirs_1.codexHome)(), "AGENTS.md");
1299
+ const CODEX_CONFIG_PATH = path.join((0, harnessDirs_1.codexHome)(), "config.toml");
1500
1300
  const CODIEDEV_INSTRUCTIONS_BEGIN = "<!-- codiedev-cli:begin -->";
1501
1301
  const CODIEDEV_INSTRUCTIONS_END = "<!-- codiedev-cli:end -->";
1502
1302
  const CODIEDEV_INSTRUCTIONS_BODY = `## Codiedev — team artifact layer
@@ -1507,6 +1307,11 @@ decisions, and messages via a CLI on every dev's machine.
1507
1307
  **When the user asks to share, push, ping, check messages, or capture a
1508
1308
  thought, use the \`codiedev\` CLI via Bash:**
1509
1309
 
1310
+ If \`codiedev\` is not on PATH, run the same subcommand with
1311
+ \`npx codiedev@latest\` instead (for example,
1312
+ \`npx codiedev@latest inbox\`). The \`connect\` command installs hooks
1313
+ but does not install a global \`codiedev\` executable.
1314
+
1510
1315
  | Intent | Command |
1511
1316
  |---|---|
1512
1317
  | "push this spec" / "save this as a skill" | \`codiedev push <file.md>\` |
@@ -1524,6 +1329,7 @@ thought, use the \`codiedev\` CLI via Bash:**
1524
1329
  | "why did we…?" / "what does the team know about X?" | \`codiedev ask "<question>"\` (synthesized answer + citations) |
1525
1330
  | "show my library" / "what artifacts exist?" | \`codiedev library [--scope mine\\|shared\\|all] [--kind doc\\|skill]\` |
1526
1331
  | "react 🛠 to that post" / "mark as used" | \`codiedev react <postId> <emoji>\` |
1332
+ | "run my agent's test cases" / "check the suite on model Y" | \`codiedev traces cases <agent> [--suite <name>] [--model <id>]\` (writes cases + RUN_CASES.md + results.json; follow it, then \`codiedev traces report <results.json>\`) |
1527
1333
 
1528
1334
  **Just push the file — we'll figure out where it goes.** Every artifact
1529
1335
  is classified into one of two kinds at upload time:
@@ -1569,19 +1375,13 @@ Tell the user the portal URL when the submit returns.
1569
1375
  API token from https://codiedev.com/portal/integrations/claude-code
1570
1376
  - "ambiguous recipient" → surface candidates to the user
1571
1377
  `;
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) {
1378
+ function writeInstructionsBlock(targetPath, body = CODIEDEV_INSTRUCTIONS_BODY) {
1579
1379
  const dir = path.dirname(targetPath);
1580
1380
  if (!fs.existsSync(dir)) {
1581
1381
  fs.mkdirSync(dir, { recursive: true });
1582
1382
  }
1583
1383
  const block = `${CODIEDEV_INSTRUCTIONS_BEGIN}\n` +
1584
- CODIEDEV_INSTRUCTIONS_BODY +
1384
+ body +
1585
1385
  `\n${CODIEDEV_INSTRUCTIONS_END}\n`;
1586
1386
  let existing = "";
1587
1387
  if (fs.existsSync(targetPath)) {
@@ -1591,14 +1391,12 @@ function writeInstructionsBlock(targetPath) {
1591
1391
  const endIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_END);
1592
1392
  let next;
1593
1393
  if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
1594
- // Replace the existing block in place.
1595
1394
  next =
1596
1395
  existing.slice(0, beginIdx) +
1597
1396
  block +
1598
1397
  existing.slice(endIdx + CODIEDEV_INSTRUCTIONS_END.length);
1599
1398
  }
1600
1399
  else if (existing.trim()) {
1601
- // Append with a blank-line separator.
1602
1400
  next = existing.replace(/\n*$/, "\n\n") + block;
1603
1401
  }
1604
1402
  else {
@@ -1612,8 +1410,12 @@ function installClaudeCodeInstructions() {
1612
1410
  function installCodexInstructions() {
1613
1411
  writeInstructionsBlock(CODEX_USER_INSTRUCTIONS_PATH);
1614
1412
  }
1615
- // Cursor uses .mdc rule files with YAML frontmatter (description, alwaysApply,
1616
- // globs). `.cursorrules` is deprecated and ignored by Agent mode as of 2026.
1413
+ function copilotInstructionsPath() {
1414
+ return path.join((0, copilotHooks_1.copilotHome)(), "copilot-instructions.md");
1415
+ }
1416
+ function installCopilotInstructions() {
1417
+ writeInstructionsBlock(copilotInstructionsPath(), `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}`);
1418
+ }
1617
1419
  const CURSOR_INSTRUCTIONS_FRONTMATTER = `---
1618
1420
  description: Codiedev team artifact layer — push, pull, search, ping, and share artifacts with teammates
1619
1421
  alwaysApply: true
@@ -1627,7 +1429,7 @@ function installCursorInstructions() {
1627
1429
  const block = CURSOR_INSTRUCTIONS_FRONTMATTER +
1628
1430
  CODIEDEV_INSTRUCTIONS_BEGIN +
1629
1431
  "\n" +
1630
- CODIEDEV_INSTRUCTIONS_BODY +
1432
+ `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}` +
1631
1433
  "\n" +
1632
1434
  CODIEDEV_INSTRUCTIONS_END +
1633
1435
  "\n";
@@ -1635,8 +1437,6 @@ function installCursorInstructions() {
1635
1437
  if (fs.existsSync(CURSOR_RULES_PATH)) {
1636
1438
  existing = fs.readFileSync(CURSOR_RULES_PATH, "utf8");
1637
1439
  }
1638
- // If the file already has our markers, replace just the block — preserve
1639
- // any frontmatter edits the user made.
1640
1440
  const beginIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_BEGIN);
1641
1441
  const endIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_END);
1642
1442
  let next;
@@ -1645,7 +1445,7 @@ function installCursorInstructions() {
1645
1445
  existing.slice(0, beginIdx) +
1646
1446
  CODIEDEV_INSTRUCTIONS_BEGIN +
1647
1447
  "\n" +
1648
- CODIEDEV_INSTRUCTIONS_BODY +
1448
+ `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}` +
1649
1449
  "\n" +
1650
1450
  CODIEDEV_INSTRUCTIONS_END +
1651
1451
  existing.slice(endIdx + CODIEDEV_INSTRUCTIONS_END.length);
@@ -1671,10 +1471,6 @@ function readMcpConfigSafely(path) {
1671
1471
  return {};
1672
1472
  }
1673
1473
  }
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
1474
  function installClaudeCodeMcp() {
1679
1475
  const config = readMcpConfigSafely(CLAUDE_USER_CONFIG_PATH);
1680
1476
  const mcpServers = config.mcpServers ?? {};
@@ -1687,12 +1483,6 @@ function installClaudeCodeMcp() {
1687
1483
  config.mcpServers = mcpServers;
1688
1484
  fs.writeFileSync(CLAUDE_USER_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
1689
1485
  }
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
1486
  function installCursorMcp() {
1697
1487
  if (!fs.existsSync(CURSOR_DIR)) {
1698
1488
  fs.mkdirSync(CURSOR_DIR, { recursive: true });
@@ -1708,17 +1498,6 @@ function installCursorMcp() {
1708
1498
  config.mcpServers = mcpServers;
1709
1499
  fs.writeFileSync(CURSOR_MCP_PATH, JSON.stringify(config, null, 2), "utf8");
1710
1500
  }
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
1501
  function installVSCodeMcp() {
1723
1502
  if (!fs.existsSync(VSCODE_USER_DIR)) {
1724
1503
  fs.mkdirSync(VSCODE_USER_DIR, { recursive: true });
@@ -1735,14 +1514,6 @@ function installVSCodeMcp() {
1735
1514
  config.servers = servers;
1736
1515
  fs.writeFileSync(VSCODE_USER_MCP_PATH, JSON.stringify(config, null, 2), "utf8");
1737
1516
  }
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
1517
  function removeJsonKey(filePath, parentKey) {
1747
1518
  if (!fs.existsSync(filePath))
1748
1519
  return;
@@ -1751,7 +1522,7 @@ function removeJsonKey(filePath, parentKey) {
1751
1522
  parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
1752
1523
  }
1753
1524
  catch {
1754
- return; // Don't damage a file we can't parse.
1525
+ return;
1755
1526
  }
1756
1527
  const parent = parsed[parentKey];
1757
1528
  if (!parent || typeof parent !== "object")
@@ -1761,33 +1532,15 @@ function removeJsonKey(filePath, parentKey) {
1761
1532
  delete parent.codiedev;
1762
1533
  fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), "utf8");
1763
1534
  }
1764
- /**
1765
- * Removes `mcpServers.codiedev` from `~/.claude.json` if present. Leaves
1766
- * other MCP servers and unrelated top-level settings untouched.
1767
- */
1768
1535
  function cleanupClaudeCodeMcp() {
1769
1536
  removeJsonKey(CLAUDE_USER_CONFIG_PATH, "mcpServers");
1770
1537
  }
1771
- /**
1772
- * Removes `mcpServers.codiedev` from `~/.cursor/mcp.json` if present.
1773
- */
1774
1538
  function cleanupCursorMcp() {
1775
1539
  removeJsonKey(CURSOR_MCP_PATH, "mcpServers");
1776
1540
  }
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
1541
  function cleanupVSCodeMcp() {
1783
1542
  removeJsonKey(VSCODE_USER_MCP_PATH, "servers");
1784
1543
  }
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
1544
  function cleanupCodexMcp() {
1792
1545
  if (!fs.existsSync(CODEX_CONFIG_PATH))
1793
1546
  return;
@@ -1804,31 +1557,22 @@ function cleanupCodexMcp() {
1804
1557
  continue;
1805
1558
  }
1806
1559
  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
1560
  const trimmed = line.trim();
1810
1561
  if (trimmed === "") {
1811
1562
  skipping = false;
1812
- continue; // also drop the blank that immediately follows the block
1563
+ continue;
1813
1564
  }
1814
1565
  if (trimmed.startsWith("[") && !trimmed.startsWith("[mcp_servers.codiedev")) {
1815
1566
  skipping = false;
1816
1567
  out.push(line);
1817
1568
  continue;
1818
1569
  }
1819
- // Skip lines belonging to the codiedev block.
1820
1570
  continue;
1821
1571
  }
1822
1572
  out.push(line);
1823
1573
  }
1824
1574
  fs.writeFileSync(CODEX_CONFIG_PATH, out.join("\n"), "utf8");
1825
1575
  }
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
1576
  function installCodexMcp() {
1833
1577
  if (!fs.existsSync(CODEX_DIR)) {
1834
1578
  fs.mkdirSync(CODEX_DIR, { recursive: true });
@@ -1851,9 +1595,6 @@ function installCodexMcp() {
1851
1595
  fs.writeFileSync(CODEX_CONFIG_PATH, existing + block, "utf8");
1852
1596
  return true;
1853
1597
  }
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
1598
  function installCodexHook(agentSecurityEnabled = true) {
1858
1599
  let hooksFile = {};
1859
1600
  try {
@@ -1863,7 +1604,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1863
1604
  }
1864
1605
  }
1865
1606
  catch {
1866
- // Start fresh if missing or invalid
1867
1607
  }
1868
1608
  if (!hooksFile.hooks) {
1869
1609
  hooksFile.hooks = {};
@@ -1878,9 +1618,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1878
1618
  return true;
1879
1619
  return !inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command));
1880
1620
  });
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
1621
  filtered.push({
1885
1622
  hooks: [
1886
1623
  {
@@ -1899,7 +1636,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1899
1636
  },
1900
1637
  ],
1901
1638
  });
1902
- // Stop → backup: auto-back-up changed artifact files to the private library.
1903
1639
  filtered.push({
1904
1640
  hooks: [
1905
1641
  {
@@ -1910,9 +1646,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1910
1646
  ],
1911
1647
  });
1912
1648
  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
1649
  if (agentSecurityEnabled) {
1917
1650
  upsertCodiedevHook(hooks, "PreToolUse", "security-pre-codex");
1918
1651
  upsertCodiedevHook(hooks, "PostToolUse", "security-post-codex");
@@ -1923,10 +1656,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1923
1656
  removeCodiedevHook(hooks, "PostToolUse");
1924
1657
  removeCodiedevHook(hooks, "PostToolUseFailure");
1925
1658
  }
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.
1930
1659
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover-codex", null);
1931
1660
  if (!fs.existsSync(CODEX_DIR)) {
1932
1661
  fs.mkdirSync(CODEX_DIR, { recursive: true });
@@ -1934,8 +1663,6 @@ function installCodexHook(agentSecurityEnabled = true) {
1934
1663
  fs.writeFileSync(CODEX_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
1935
1664
  (0, codexHookTrust_1.writeCodexHookTrust)(CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, hooksFile);
1936
1665
  }
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
1666
  function ensureCodexLimitHookInstalled() {
1940
1667
  try {
1941
1668
  if (!fs.existsSync(CODEX_HOOKS_PATH))
@@ -1970,18 +1697,11 @@ function ensureCodexLimitHookInstalled() {
1970
1697
  return;
1971
1698
  hooksFile.hooks = hooks;
1972
1699
  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
1700
  (0, codexHookTrust_1.writeCodexHookTrust)(CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, hooksFile);
1976
1701
  }
1977
1702
  catch {
1978
- // Best-effort: capture must never fail because hook repair did.
1979
1703
  }
1980
1704
  }
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
1705
  function installCursorHook(agentSecurityEnabled = true) {
1986
1706
  if (!fs.existsSync(CURSOR_DIR)) {
1987
1707
  fs.mkdirSync(CURSOR_DIR, { recursive: true });
@@ -2018,31 +1738,97 @@ function installCursorHook(agentSecurityEnabled = true) {
2018
1738
  else
2019
1739
  hooks[eventName] = [...preserved, ...managed];
2020
1740
  };
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
1741
  replaceManaged("sessionStart", ["session-start-cursor"]);
2026
1742
  replaceManaged("sessionEnd", ["capture-cursor"]);
2027
1743
  replaceManaged("stop", ["backup-cursor"]);
1744
+ replaceManaged("subagentStart", ["observe-cursor"]);
1745
+ replaceManaged("subagentStop", ["observe-cursor"]);
2028
1746
  if (agentSecurityEnabled) {
2029
- // Cursor supports blocking here, but not dynamic context injection.
2030
1747
  replaceManaged("beforeSubmitPrompt", ["security-prompt-cursor"]);
2031
1748
  replaceManaged("preToolUse", ["security-pre-cursor"]);
2032
- replaceManaged("postToolUse", ["security-post-cursor"]);
2033
- replaceManaged("postToolUseFailure", ["security-failure-cursor"]);
1749
+ replaceManaged("postToolUse", ["observe-cursor", "security-post-cursor"]);
1750
+ replaceManaged("postToolUseFailure", ["observe-cursor", "security-failure-cursor"]);
2034
1751
  }
2035
1752
  else {
2036
1753
  replaceManaged("beforeSubmitPrompt", []);
2037
1754
  replaceManaged("preToolUse", []);
2038
- replaceManaged("postToolUse", []);
2039
- replaceManaged("postToolUseFailure", []);
1755
+ replaceManaged("postToolUse", ["observe-cursor"]);
1756
+ replaceManaged("postToolUseFailure", ["observe-cursor"]);
2040
1757
  }
2041
1758
  fs.writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
2042
1759
  }
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. */
1760
+ function copilotHooksPath() {
1761
+ return path.join((0, copilotHooks_1.copilotHome)(), "hooks", "codiedev.json");
1762
+ }
1763
+ function copilotManagedHooks(agentSecurityEnabled) {
1764
+ return [
1765
+ ["sessionStart", "session-start-copilot", 10],
1766
+ ["userPromptSubmitted", "discover-copilot", 10],
1767
+ ["agentStop", "backup-copilot", 30],
1768
+ ["sessionEnd", "capture-copilot", 30],
1769
+ ["errorOccurred", "limit-copilot", 10],
1770
+ ...(agentSecurityEnabled
1771
+ ? [
1772
+ ["preToolUse", "security-pre-copilot", 10],
1773
+ ["postToolUse", "security-post-copilot", 10],
1774
+ ]
1775
+ : []),
1776
+ ];
1777
+ }
1778
+ const COPILOT_EVENTS = ["sessionStart", "userPromptSubmitted", "agentStop", "sessionEnd", "preToolUse", "postToolUse", "errorOccurred"];
1779
+ function installCopilotHook(agentSecurityEnabled = false) {
1780
+ const hooksPath = copilotHooksPath();
1781
+ let hooksFile = {};
1782
+ try {
1783
+ if (fs.existsSync(hooksPath)) {
1784
+ const raw = fs.readFileSync(hooksPath, "utf8");
1785
+ hooksFile = raw.trim() ? JSON.parse(raw) : {};
1786
+ }
1787
+ }
1788
+ catch {
1789
+ hooksFile = {};
1790
+ }
1791
+ hooksFile.version = 1;
1792
+ const hooks = (hooksFile.hooks && typeof hooksFile.hooks === "object" ? hooksFile.hooks : {});
1793
+ const managed = copilotManagedHooks(agentSecurityEnabled);
1794
+ for (const eventName of COPILOT_EVENTS) {
1795
+ const existing = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
1796
+ const preserved = existing.filter((hook) => !(0, detection_1.isCodiedevHookCommand)((hook.bash ?? hook.powershell ?? hook.command)));
1797
+ const ours = managed
1798
+ .filter(([event]) => event === eventName)
1799
+ .map(([, subcommand, timeoutSec]) => {
1800
+ const command = resolveHookCommand(subcommand);
1801
+ return { type: "command", bash: command, powershell: command, timeoutSec };
1802
+ });
1803
+ if (preserved.length + ours.length === 0)
1804
+ delete hooks[eventName];
1805
+ else
1806
+ hooks[eventName] = [...preserved, ...ours];
1807
+ }
1808
+ hooksFile.hooks = hooks;
1809
+ fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
1810
+ fs.writeFileSync(hooksPath, JSON.stringify(hooksFile, null, 2), "utf8");
1811
+ }
1812
+ function ensureCopilotCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.agentSecurityEnabled === true) {
1813
+ try {
1814
+ const hooksPath = copilotHooksPath();
1815
+ if (!fs.existsSync(hooksPath))
1816
+ return;
1817
+ const parsed = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
1818
+ const hooks = parsed.hooks ?? {};
1819
+ const commandsFor = (eventName) => (Array.isArray(hooks[eventName]) ? hooks[eventName] : [])
1820
+ .map((entry) => (entry.bash ?? entry.powershell ?? entry.command))
1821
+ .filter((command) => typeof command === "string" && (0, detection_1.isCodiedevHookCommand)(command));
1822
+ const required = copilotManagedHooks(agentSecurityEnabled);
1823
+ const complete = required.every(([eventName, subcommand]) => commandsFor(eventName).filter((command) => command.trim().endsWith(subcommand)).length === 1);
1824
+ const stale = COPILOT_EVENTS.some((eventName) => commandsFor(eventName).length !== required.filter(([event]) => event === eventName).length);
1825
+ if (complete && !stale)
1826
+ return;
1827
+ installCopilotHook(agentSecurityEnabled);
1828
+ }
1829
+ catch {
1830
+ }
1831
+ }
2046
1832
  function ensureCursorCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?.agentSecurityEnabled === true) {
2047
1833
  try {
2048
1834
  if (!fs.existsSync(CURSOR_HOOKS_PATH))
@@ -2062,6 +1848,10 @@ function ensureCursorCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?
2062
1848
  ["sessionStart", "session-start-cursor"],
2063
1849
  ["sessionEnd", "capture-cursor"],
2064
1850
  ["stop", "backup-cursor"],
1851
+ ["postToolUse", "observe-cursor"],
1852
+ ["postToolUseFailure", "observe-cursor"],
1853
+ ["subagentStart", "observe-cursor"],
1854
+ ["subagentStop", "observe-cursor"],
2065
1855
  ...(agentSecurityEnabled
2066
1856
  ? [
2067
1857
  ["beforeSubmitPrompt", "security-prompt-cursor"],
@@ -2075,7 +1865,8 @@ function ensureCursorCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?
2075
1865
  "beforeSubmitPrompt", "preToolUse", "postToolUse", "postToolUseFailure",
2076
1866
  ].some((eventName) => {
2077
1867
  const entries = hooks[eventName];
2078
- return Array.isArray(entries) && entries.some((entry) => (0, detection_1.isCodiedevHookCommand)(entry.command));
1868
+ return Array.isArray(entries) && entries.some((entry) => (0, detection_1.isCodiedevHookCommand)(entry.command) &&
1869
+ !String(entry.command).trim().endsWith("observe-cursor"));
2079
1870
  });
2080
1871
  if (required.every(([eventName, subcommand]) => has(eventName, subcommand)) && !hasStaleSecurity) {
2081
1872
  return;
@@ -2083,7 +1874,6 @@ function ensureCursorCodiedevHooksInstalled(agentSecurityEnabled = readConfig()?
2083
1874
  installCursorHook(agentSecurityEnabled);
2084
1875
  }
2085
1876
  catch {
2086
- // Best-effort — a malformed third-party config must never block capture.
2087
1877
  }
2088
1878
  }
2089
1879
  function parseClaudeSteering(content) {
@@ -2092,10 +1882,8 @@ function parseClaudeSteering(content) {
2092
1882
  for (const line of content.split("\n")) {
2093
1883
  if (!line.trim())
2094
1884
  continue;
2095
- // "[Request interrupted by user]" / "...for tool use" — Claude Code markers
2096
1885
  if (line.includes("[Request interrupted by user"))
2097
1886
  interrupts++;
2098
- // Standard Claude Code tool-rejection sentinels in tool_result content
2099
1887
  if (line.includes("doesn't want to take this action") ||
2100
1888
  line.includes("user doesn't want to proceed"))
2101
1889
  rejections++;
@@ -2120,56 +1908,59 @@ function parseCodexSteering(content) {
2120
1908
  }
2121
1909
  }
2122
1910
  catch {
2123
- // skip malformed
2124
1911
  }
2125
1912
  }
2126
1913
  return { interrupts, rejections };
2127
1914
  }
2128
1915
  function parseSteering(content, mode) {
1916
+ if (mode === "cursor" || mode === "copilot")
1917
+ return (0, nativeSteering_1.parseNativeSteering)(content);
2129
1918
  return mode === "codex"
2130
1919
  ? parseCodexSteering(content)
2131
1920
  : parseClaudeSteering(content);
2132
1921
  }
2133
1922
  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;
1923
+ const models = (0, transcriptModels_1.parseTranscriptModels)(content, "claude-code");
2140
1924
  let model = null;
2141
1925
  for (const line of content.split("\n")) {
2142
- if (!line.trim())
2143
- continue;
2144
1926
  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
1927
+ const value = JSON.parse(line)?.message?.model;
1928
+ if (typeof value === "string" && value.trim() && !value.startsWith("<"))
1929
+ model = value;
2159
1930
  }
1931
+ catch { }
2160
1932
  }
2161
- return {
2162
- inputTokens,
2163
- outputTokens,
2164
- cacheTokens: cacheReadTokens + cacheCreationTokens,
2165
- cacheReadTokens,
2166
- cacheCreationTokens,
2167
- model,
2168
- };
1933
+ const sum = (field) => models.reduce((total, usage) => total + usage[field], 0);
1934
+ const cacheReadTokens = sum("cacheReadTokens"), cacheCreationTokens = sum("cacheCreationTokens");
1935
+ const stats = { inputTokens: sum("inputTokens"), outputTokens: sum("outputTokens"),
1936
+ cacheTokens: cacheReadTokens + cacheCreationTokens, cacheReadTokens, cacheCreationTokens, model };
1937
+ return model ? stats : fromClaudeCostState(content, stats);
1938
+ }
1939
+ function fromClaudeCostState(content, stats) {
1940
+ let usage;
1941
+ for (const line of content.split("\n")) {
1942
+ if (!line.includes('"cost-state"'))
1943
+ continue;
1944
+ try {
1945
+ const e = JSON.parse(line);
1946
+ if (e?.type === "cost-state" && e.modelUsage && typeof e.modelUsage === "object")
1947
+ usage = e.modelUsage;
1948
+ }
1949
+ catch { }
1950
+ }
1951
+ const n = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
1952
+ const entries = Object.entries(usage ?? {}).filter(([id, u]) => id.trim() && !id.startsWith("<") && u && typeof u === "object");
1953
+ if (!entries.length)
1954
+ return stats;
1955
+ const [model] = entries.reduce((best, entry) => n(entry[1].outputTokens) > n(best[1].outputTokens) ? entry : best);
1956
+ if (stats.inputTokens || stats.outputTokens || stats.cacheTokens)
1957
+ return { ...stats, model };
1958
+ const total = (field) => entries.reduce((sum, [, u]) => sum + n(u[field]), 0);
1959
+ const cacheReadTokens = total("cacheReadInputTokens"), cacheCreationTokens = total("cacheCreationInputTokens");
1960
+ return { inputTokens: total("inputTokens"), outputTokens: total("outputTokens"),
1961
+ cacheTokens: cacheReadTokens + cacheCreationTokens, cacheReadTokens, cacheCreationTokens, model };
2169
1962
  }
2170
1963
  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
1964
  let model = null;
2174
1965
  let last = null;
2175
1966
  for (const line of content.split("\n")) {
@@ -2187,7 +1978,6 @@ function parseCodexUsage(content) {
2187
1978
  }
2188
1979
  }
2189
1980
  catch {
2190
- // skip malformed
2191
1981
  }
2192
1982
  }
2193
1983
  if (!last) {
@@ -2197,31 +1987,19 @@ function parseCodexUsage(content) {
2197
1987
  const input = last.input_tokens ?? 0;
2198
1988
  return {
2199
1989
  inputTokens: Math.max(0, input - cached),
2200
- // Reasoning is included in output_tokens, not additional output.
2201
1990
  outputTokens: last.output_tokens ?? 0,
2202
- // Codex only reports cached reads; creation is structurally zero.
2203
1991
  cacheTokens: cached,
2204
1992
  cacheReadTokens: cached,
2205
1993
  cacheCreationTokens: 0,
2206
1994
  model,
2207
1995
  };
2208
1996
  }
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
1997
  function parseOpencodeUsage(content) {
2214
1998
  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
1999
  const outputByModel = new Map();
2219
2000
  let lastModel = null;
2220
2001
  try {
2221
2002
  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
2003
  const list = Array.isArray(parsed)
2226
2004
  ? parsed
2227
2005
  : (parsed && typeof parsed === "object" && Array.isArray(parsed.data)
@@ -2251,14 +2029,10 @@ function parseOpencodeUsage(content) {
2251
2029
  }
2252
2030
  }
2253
2031
  }
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.
2032
+ catch { }
2257
2033
  let model = lastModel;
2258
2034
  let best = -1;
2259
2035
  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
2036
  if (out >= best) {
2263
2037
  best = out;
2264
2038
  model = id;
@@ -2274,8 +2048,6 @@ function parseUsage(content, mode) {
2274
2048
  return parseOpencodeUsage(content);
2275
2049
  return mode === "codex" ? parseCodexUsage(content) : parseClaudeUsage(content);
2276
2050
  }
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
2051
  function parseSessionStart(content) {
2280
2052
  let min = null;
2281
2053
  for (const line of content.split("\n")) {
@@ -2291,7 +2063,6 @@ function parseSessionStart(content) {
2291
2063
  }
2292
2064
  }
2293
2065
  catch {
2294
- // skip malformed
2295
2066
  }
2296
2067
  }
2297
2068
  return min;
@@ -2299,33 +2070,38 @@ function parseSessionStart(content) {
2299
2070
  function parseClaudeCodeStats(content) {
2300
2071
  let messageCount = 0;
2301
2072
  let toolCallCount = 0;
2073
+ const messages = new Set();
2074
+ const tools = new Set();
2302
2075
  const lines = content.split("\n").filter((l) => l.trim());
2303
2076
  for (const line of lines) {
2304
2077
  try {
2305
2078
  const entry = JSON.parse(line);
2306
2079
  if (!entry || typeof entry !== "object")
2307
2080
  continue;
2308
- if (entry.type === "user" || entry.type === "assistant") {
2081
+ const id = entry.message?.id ?? entry.uuid;
2082
+ const messageKey = `${entry.type}:${id ?? messages.size}`;
2083
+ if ((entry.type === "user" || entry.type === "assistant") && !messages.has(messageKey)) {
2084
+ messages.add(messageKey);
2309
2085
  messageCount++;
2310
2086
  }
2311
2087
  const c = entry.message?.content;
2312
2088
  if (Array.isArray(c)) {
2313
- for (const block of c) {
2314
- if (block && block.type === "tool_use")
2089
+ for (const [index, block] of c.entries()) {
2090
+ if (!block || block.type !== "tool_use")
2091
+ continue;
2092
+ const toolKey = block.id ?? `${messageKey}:${index}`;
2093
+ if (!tools.has(toolKey)) {
2094
+ tools.add(toolKey);
2315
2095
  toolCallCount++;
2096
+ }
2316
2097
  }
2317
2098
  }
2318
2099
  }
2319
2100
  catch {
2320
- // Skip malformed lines
2321
2101
  }
2322
2102
  }
2323
2103
  return { messageCount, toolCallCount };
2324
2104
  }
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
2105
  function normalizeOpenCodeTranscript(content) {
2330
2106
  let parsed;
2331
2107
  try {
@@ -2360,14 +2136,14 @@ function normalizeOpenCodeTranscript(content) {
2360
2136
  else if (part.type === "tool") {
2361
2137
  normalizedParts.push({
2362
2138
  type: "tool_use",
2363
- id: part.callID ?? part.id ?? "tool",
2139
+ id: part.callID ?? part.id,
2364
2140
  name: part.tool ?? "tool",
2365
2141
  input: part.state?.input ?? {},
2366
2142
  });
2367
2143
  if (part.state?.output != null || part.state?.error != null) {
2368
2144
  normalizedParts.push({
2369
2145
  type: "tool_result",
2370
- tool_use_id: part.callID ?? part.id ?? "tool",
2146
+ tool_use_id: part.callID ?? part.id,
2371
2147
  content: part.state?.output ?? part.state?.error,
2372
2148
  is_error: part.state?.status === "error" || part.state?.error != null,
2373
2149
  });
@@ -2383,6 +2159,7 @@ function normalizeOpenCodeTranscript(content) {
2383
2159
  type: role,
2384
2160
  timestamp,
2385
2161
  message: {
2162
+ id: typeof info.id === "string" ? info.id : undefined,
2386
2163
  role,
2387
2164
  model: typeof info.modelID === "string" ? info.modelID : undefined,
2388
2165
  usage: role === "assistant" ? {
@@ -2397,15 +2174,6 @@ function normalizeOpenCodeTranscript(content) {
2397
2174
  }
2398
2175
  return lines.join("\n") + (lines.length ? "\n" : "");
2399
2176
  }
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
2177
  function parseCodexStats(content) {
2410
2178
  let messageCount = 0;
2411
2179
  let toolCallCount = 0;
@@ -2426,13 +2194,10 @@ function parseCodexStats(content) {
2426
2194
  }
2427
2195
  }
2428
2196
  catch {
2429
- // Skip malformed lines
2430
2197
  }
2431
2198
  }
2432
2199
  return { messageCount, toolCallCount };
2433
2200
  }
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
2201
  function extractCodexRepoUrl(content) {
2437
2202
  const lines = content.split("\n");
2438
2203
  for (const line of lines) {
@@ -2445,24 +2210,16 @@ function extractCodexRepoUrl(content) {
2445
2210
  const url = entry.payload?.git?.repository_url;
2446
2211
  if (typeof url === "string" && url.length > 0)
2447
2212
  return url;
2448
- return null; // session_meta is first — no point scanning further
2213
+ return null;
2449
2214
  }
2450
2215
  }
2451
2216
  catch {
2452
- // Keep scanning
2453
2217
  }
2454
2218
  }
2455
2219
  return null;
2456
2220
  }
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
2221
  exports.MAX_UPLOAD_ATTEMPTS = 5;
2460
2222
  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
2223
  function openCaptureLogStdio(openSync) {
2467
2224
  if (!openSync)
2468
2225
  return { stdio: "ignore" };
@@ -2474,70 +2231,36 @@ function openCaptureLogStdio(openSync) {
2474
2231
  return { stdio: "ignore" };
2475
2232
  }
2476
2233
  }
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
2234
  function closeCaptureLogFd(fd, closeSync) {
2480
2235
  if (fd === undefined || !closeSync)
2481
2236
  return;
2482
2237
  try {
2483
2238
  closeSync(fd);
2484
2239
  }
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.
2240
+ catch { }
2241
+ }
2496
2242
  function dispatchCaptureFinish(input, hookScriptPath, deps) {
2497
2243
  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
2244
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2504
2245
  try {
2505
2246
  const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "capture-finish", deps.tmpFile], { detached: true, stdio });
2506
2247
  child.unref();
2507
2248
  }
2508
2249
  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
2250
  closeCaptureLogFd(fd, deps.closeSync);
2512
2251
  }
2513
2252
  }
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
2253
  function dispatchCaptureDetached(job, hookScriptPath, deps) {
2520
2254
  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
2255
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2526
2256
  try {
2527
2257
  const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload", deps.tmpFile], { detached: true, stdio });
2528
2258
  child.unref();
2529
2259
  }
2530
2260
  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
2261
  closeCaptureLogFd(fd, deps.closeSync);
2534
2262
  }
2535
2263
  }
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
2264
  function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
2542
2265
  deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
2543
2266
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
@@ -2546,29 +2269,11 @@ function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
2546
2269
  child.unref();
2547
2270
  }
2548
2271
  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
2272
  closeCaptureLogFd(fd, deps.closeSync);
2552
2273
  }
2553
2274
  }
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
2275
  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
2276
  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
2277
  function sweepPendingCaptures(deps) {
2573
2278
  let files;
2574
2279
  try {
@@ -2578,10 +2283,6 @@ function sweepPendingCaptures(deps) {
2578
2283
  return;
2579
2284
  }
2580
2285
  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
2286
  for (const f of files) {
2586
2287
  const m = /^(codiedev-capture-.*\.json)\.claim-\d+$/.exec(f);
2587
2288
  if (!m)
@@ -2593,14 +2294,8 @@ function sweepPendingCaptures(deps) {
2593
2294
  }
2594
2295
  }
2595
2296
  catch {
2596
- // raced another sweeper or the uploader just finished — fine
2597
2297
  }
2598
2298
  }
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
2299
  if (deps.unlinkSync) {
2605
2300
  for (const f of files) {
2606
2301
  const m = /^codiedev-finish-(.+)-\d+-\d+\.json$/.exec(f);
@@ -2611,19 +2306,13 @@ function sweepPendingCaptures(deps) {
2611
2306
  if (now - deps.statSync(orphanPath).mtimeMs < exports.FINISH_ORPHAN_AGE_MS)
2612
2307
  continue;
2613
2308
  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
2309
  const sid = /^\d+$/.test(m[1]) ? undefined : m[1];
2620
2310
  try {
2621
2311
  deps.reportOrphan?.(sid);
2622
2312
  }
2623
- catch { /* report is best-effort */ }
2313
+ catch { }
2624
2314
  }
2625
2315
  catch {
2626
- // raced its owner or another sweeper — fine
2627
2316
  }
2628
2317
  }
2629
2318
  }
@@ -2631,12 +2320,6 @@ function sweepPendingCaptures(deps) {
2631
2320
  .filter((f) => /^codiedev-capture-.*\.json$/.test(f))
2632
2321
  .map((f) => path.join(deps.dir, f))
2633
2322
  .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
2323
  .filter((p) => {
2641
2324
  try {
2642
2325
  return now - deps.statSync(p).mtimeMs >= exports.SWEEP_MIN_AGE_MS;
@@ -2646,8 +2329,6 @@ function sweepPendingCaptures(deps) {
2646
2329
  }
2647
2330
  })
2648
2331
  .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
2332
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2652
2333
  for (const jobFile of jobs) {
2653
2334
  const claimed = `${jobFile}.claim-${process.pid}`;
@@ -2655,135 +2336,71 @@ function sweepPendingCaptures(deps) {
2655
2336
  deps.renameSync(jobFile, claimed);
2656
2337
  }
2657
2338
  catch {
2658
- continue; // another sweeper claimed it first
2339
+ continue;
2659
2340
  }
2660
2341
  try {
2661
2342
  const child = deps.spawn(deps.nodePath ?? process.execPath, [deps.hookScriptPath, "upload", claimed], { detached: true, stdio });
2662
2343
  child.unref();
2663
2344
  }
2664
2345
  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
2346
  try {
2668
2347
  deps.renameSync(claimed, jobFile);
2669
2348
  }
2670
2349
  catch {
2671
- // best-effort
2672
2350
  }
2673
2351
  }
2674
2352
  }
2675
2353
  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
2354
  try {
2679
2355
  (deps.pruneState ?? pruneCaptureStateDir)();
2680
2356
  }
2681
2357
  catch {
2682
- // best-effort
2683
2358
  }
2684
2359
  }
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
2360
  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
2361
  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
2362
  function resolveCaptureFinal(argv2) {
2713
2363
  if (argv2 === "capture-opencode" || argv2 === "capture-desktop")
2714
2364
  return false;
2715
2365
  if (argv2 === "capture-opencode-final" || argv2 === "capture-desktop-final")
2716
2366
  return true;
2717
- return argv2 === "capture" || argv2 === "capture-cursor" || argv2 === undefined
2367
+ return argv2 === "capture" || argv2 === "capture-cursor" || argv2 === "capture-copilot" || argv2 === undefined
2718
2368
  ? true
2719
2369
  : undefined;
2720
2370
  }
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
2371
  function finishInputTmpFile(sessionId) {
2732
2372
  const safeId = String(sessionId ?? process.pid).replace(/[^A-Za-z0-9._-]/g, "_");
2733
2373
  return path.join(os.tmpdir(), `codiedev-finish-${safeId}-${process.pid}-${Date.now()}.json`);
2734
2374
  }
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
2375
  function captureUploadTmpFile(sessionId, mode, pid = process.pid, now = Date.now()) {
2742
2376
  const id = String(sessionId ?? pid).replace(/[^A-Za-z0-9._-]/g, "_");
2743
2377
  return path.join(os.tmpdir(), mode === "immediate"
2744
2378
  ? `codiedev-capture-${id}-${pid}-${now}.json`
2745
2379
  : `codiedev-capture-${id}.json`);
2746
2380
  }
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
2381
  function cancelDeferredCaptureUpload(sessionId, deps = fs) {
2753
2382
  try {
2754
2383
  deps.unlinkSync(captureUploadTmpFile(sessionId, "deferred"));
2755
2384
  return true;
2756
2385
  }
2757
2386
  catch {
2758
- // Missing means there was nothing pending (or another process won).
2759
2387
  return false;
2760
2388
  }
2761
2389
  }
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
2390
  function decideDispatch(args) {
2769
2391
  if (args.final || args.lastUploadAt === null)
2770
2392
  return { mode: "immediate" };
2771
2393
  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
2394
  const safeElapsed = Math.max(0, elapsed);
2775
2395
  if (safeElapsed >= exports.CAPTURE_THROTTLE_MS)
2776
2396
  return { mode: "immediate" };
2777
2397
  return { mode: "deferred", delayMs: exports.CAPTURE_THROTTLE_MS - safeElapsed };
2778
2398
  }
2779
- // Per-session state dir: ~/.codiedev/capture-state/<sanitized-session-id>.json
2780
2399
  const CAPTURE_STATE_DIR = path.join(CONFIG_DIR, "capture-state");
2781
2400
  function captureStatePath(sessionId) {
2782
2401
  const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
2783
2402
  return path.join(CAPTURE_STATE_DIR, `${safe}.json`);
2784
2403
  }
2785
- // Read the per-session upload state from ~/.codiedev/capture-state/<id>.json.
2786
- // Returns null when missing or unreadable. Never throws.
2787
2404
  function readSessionUploadState(sessionId, deps = fs) {
2788
2405
  try {
2789
2406
  const p = captureStatePath(sessionId);
@@ -2795,8 +2412,6 @@ function readSessionUploadState(sessionId, deps = fs) {
2795
2412
  return null;
2796
2413
  }
2797
2414
  }
2798
- // Write the per-session upload state to ~/.codiedev/capture-state/<id>.json.
2799
- // Best-effort: never throws into the capture path.
2800
2415
  function writeSessionUploadState(sessionId, state, deps = fs) {
2801
2416
  try {
2802
2417
  if (!deps.existsSync(CAPTURE_STATE_DIR)) {
@@ -2806,13 +2421,8 @@ function writeSessionUploadState(sessionId, state, deps = fs) {
2806
2421
  deps.writeFileSync(p, JSON.stringify(state), { mode: 0o600 });
2807
2422
  }
2808
2423
  catch {
2809
- // best-effort — never break the capture path
2810
2424
  }
2811
2425
  }
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
2426
  function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now()) {
2817
2427
  try {
2818
2428
  for (const f of deps.readdirSync(dir)) {
@@ -2822,25 +2432,13 @@ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now
2822
2432
  deps.unlinkSync(p);
2823
2433
  }
2824
2434
  catch {
2825
- // raced another pruner or unreadable — skip
2826
2435
  }
2827
2436
  }
2828
2437
  }
2829
2438
  catch {
2830
- // dir missing/unreadable — nothing to prune
2831
2439
  }
2832
2440
  }
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
2441
  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
2442
  const LAST_CAPTURE_PATH = path.join(CONFIG_DIR, "last-capture.json");
2845
2443
  function buildLastCaptureRecord(job, now) {
2846
2444
  const b = job.body;
@@ -2863,7 +2461,6 @@ function writeLastCapture(record, deps = fs, filePath = LAST_CAPTURE_PATH) {
2863
2461
  deps.writeFileSync(filePath, JSON.stringify(record), { mode: 0o600 });
2864
2462
  }
2865
2463
  catch {
2866
- // breadcrumb is best-effort — never throw on the capture path.
2867
2464
  }
2868
2465
  }
2869
2466
  function readLastCapture(deps = fs, filePath = LAST_CAPTURE_PATH) {
@@ -2876,21 +2473,16 @@ function readLastCapture(deps = fs, filePath = LAST_CAPTURE_PATH) {
2876
2473
  return null;
2877
2474
  }
2878
2475
  }
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
2476
  async function runCaptureUpload(jobFile, deps) {
2884
2477
  let job = null;
2885
2478
  try {
2886
2479
  job = JSON.parse(deps.readFileSync(jobFile, "utf8"));
2887
2480
  }
2888
2481
  catch {
2889
- // unreadable/corrupt job — delete it and stop.
2890
2482
  try {
2891
2483
  deps.unlinkSync(jobFile);
2892
2484
  }
2893
- catch { /* gone */ }
2485
+ catch { }
2894
2486
  return;
2895
2487
  }
2896
2488
  const sessionId = job.body?.sessionId;
@@ -2899,18 +2491,15 @@ async function runCaptureUpload(jobFile, deps) {
2899
2491
  try {
2900
2492
  deps.log({ stage: "upload", sessionId, outcome: "skip", detail: "ignored-path" });
2901
2493
  }
2902
- catch { /* log is best-effort */ }
2494
+ catch { }
2903
2495
  }
2904
2496
  try {
2905
2497
  deps.unlinkSync(jobFile);
2906
2498
  }
2907
- catch { /* gone */ }
2499
+ catch { }
2908
2500
  return;
2909
2501
  }
2910
2502
  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
2503
  let body = job.body;
2915
2504
  const transcript = body?.transcript;
2916
2505
  if (transcript &&
@@ -2922,49 +2511,39 @@ async function runCaptureUpload(jobFile, deps) {
2922
2511
  const { storageId } = await deps.uploadBytes(uploadUrl, Buffer.from(transcript, "base64"), "application/json");
2923
2512
  body = { ...body, transcriptStorageId: storageId };
2924
2513
  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
2514
  job.body = body;
2930
2515
  if (deps.writeFileSync) {
2931
2516
  deps.writeFileSync(jobFile, JSON.stringify(job), { mode: 0o600 });
2932
2517
  }
2933
2518
  }
2934
2519
  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
2520
  if (deps.recordSuccess) {
2938
2521
  try {
2939
2522
  deps.recordSuccess(job);
2940
2523
  }
2941
- catch { /* breadcrumb is best-effort */ }
2524
+ catch { }
2942
2525
  }
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
2526
  const uploadedSessionId = job.body?.sessionId;
2946
2527
  if (uploadedSessionId && deps.writeSessionState) {
2947
2528
  try {
2948
2529
  deps.writeSessionState(uploadedSessionId, { lastUploadAt: deps.now ? deps.now() : Date.now() });
2949
2530
  }
2950
- catch { /* best-effort */ }
2531
+ catch { }
2951
2532
  }
2952
2533
  const attempt = (job.attempts ?? 0) + 1;
2953
2534
  if (deps.log) {
2954
2535
  try {
2955
2536
  deps.log({ stage: "upload", sessionId, outcome: "ok", detail: `attempt=${attempt}` });
2956
2537
  }
2957
- catch { /* log is best-effort */ }
2538
+ catch { }
2958
2539
  }
2959
2540
  try {
2960
2541
  deps.unlinkSync(jobFile);
2961
2542
  }
2962
- catch { /* gone */ }
2543
+ catch { }
2963
2544
  return;
2964
2545
  }
2965
2546
  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
2547
  const now = deps.now ? deps.now() : Date.now();
2969
2548
  const attempts = (job.attempts ?? 0) + 1;
2970
2549
  const queuedAt = job.queuedAt ?? now;
@@ -2981,19 +2560,12 @@ async function runCaptureUpload(jobFile, deps) {
2981
2560
  detail: `attempt=${attempts} age=${ageMs}ms`,
2982
2561
  });
2983
2562
  }
2984
- catch { /* log is best-effort */ }
2563
+ catch { }
2985
2564
  }
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
2565
  try {
2990
2566
  deps.unlinkSync(jobFile);
2991
2567
  }
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.
2568
+ catch { }
2997
2569
  if (sessionId) {
2998
2570
  try {
2999
2571
  const skipUrl = job.url.replace(/\/api\/captureSession$/, "/api/cli/sessionSkip");
@@ -3005,52 +2577,42 @@ async function runCaptureUpload(jobFile, deps) {
3005
2577
  }, job.token);
3006
2578
  }
3007
2579
  }
3008
- catch { /* best-effort — the ping may be blocked too */ }
2580
+ catch { }
3009
2581
  }
3010
2582
  return;
3011
2583
  }
3012
- // Log the error before persisting the retry bump.
3013
2584
  if (deps.log) {
3014
2585
  try {
3015
2586
  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
2587
  const httpMatch = msg.match(/\bhttp[= ](\d{3})\b/i);
3019
2588
  const httpPart = httpMatch ? `http=${httpMatch[1]} ` : "";
3020
2589
  const detail = `${httpPart}${msg.slice(0, 200)} attempt=${attempts}`;
3021
2590
  deps.log({ stage: "upload", sessionId, outcome: "ERROR", detail });
3022
2591
  }
3023
- catch { /* log is best-effort */ }
2592
+ catch { }
3024
2593
  }
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
2594
  const canonical = jobFile.replace(/\.claim-\d+$/, "");
3030
- const writeFn = deps.writeFileSync; // always set here: giveUp branch checked !deps.writeFileSync
2595
+ const writeFn = deps.writeFileSync;
3031
2596
  try {
3032
2597
  writeFn(canonical, JSON.stringify({ ...job, attempts, queuedAt }), { mode: 0o600 });
3033
2598
  if (canonical !== jobFile) {
3034
2599
  try {
3035
2600
  deps.unlinkSync(jobFile);
3036
2601
  }
3037
- catch { /* gone */ }
2602
+ catch { }
3038
2603
  }
3039
2604
  }
3040
2605
  catch {
3041
- // couldn't persist the bump — drop it rather than risk a stuck file.
3042
2606
  try {
3043
2607
  deps.unlinkSync(jobFile);
3044
2608
  }
3045
- catch { /* gone */ }
2609
+ catch { }
3046
2610
  }
3047
2611
  }
3048
2612
  }
3049
2613
  async function runDeferredUpload(jobFile, delayMs, deps) {
3050
2614
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
3051
2615
  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
2616
  const claimed = `${jobFile}.claim-${process.pid}`;
3055
2617
  try {
3056
2618
  deps.renameSync(jobFile, claimed);
@@ -3058,33 +2620,24 @@ async function runDeferredUpload(jobFile, delayMs, deps) {
3058
2620
  catch {
3059
2621
  if (deps.log) {
3060
2622
  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
2623
  const sid = /^codiedev-capture-(.+)\.json/.exec(path.basename(jobFile))?.[1];
3065
2624
  deps.log({ stage: "upload-deferred", sessionId: sid, outcome: "superseded" });
3066
2625
  }
3067
- catch { /* log is best-effort */ }
2626
+ catch { }
3068
2627
  }
3069
2628
  return;
3070
2629
  }
3071
2630
  await runCaptureUpload(claimed, deps);
3072
2631
  }
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
2632
  function transcriptReadSkipReason(err) {
3080
2633
  const code = err?.code;
3081
2634
  if (code === "ENOENT")
3082
2635
  return "no-transcript";
3083
2636
  return `transcript-unreadable:${code || "unknown"}`;
3084
2637
  }
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.
2638
+ function isCodexRolloutPath(transcriptPath) {
2639
+ return Boolean(transcriptPath && /^rollout-.*\.jsonl$/.test(path.basename(transcriptPath)));
2640
+ }
3088
2641
  function shouldReportCaptureSkip(final) {
3089
2642
  return final !== false;
3090
2643
  }