codiedev 0.9.80 → 0.10.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +12 -5
  2. package/dist/backup/cache.js +1 -5
  3. package/dist/backup/match.js +0 -6
  4. package/dist/backup/run.js +3 -35
  5. package/dist/captureExclusions.js +0 -12
  6. package/dist/captureLog.js +0 -28
  7. package/dist/captureReadiness.js +0 -58
  8. package/dist/captureSignals.js +0 -11
  9. package/dist/cli.js +11 -33
  10. package/dist/cliSession.js +3 -60
  11. package/dist/codexHookTrust.js +0 -28
  12. package/dist/codexLimitScan.js +0 -107
  13. package/dist/codie.js +64 -57
  14. package/dist/codieBrandInstall.js +0 -18
  15. package/dist/codieLauncher.js +1 -72
  16. package/dist/codieRuntime.js +27 -32
  17. package/dist/codieRuntimeInstall.js +11 -43
  18. package/dist/codieUsage.js +0 -49
  19. package/dist/commands/agentTemplates.generated.js +136 -0
  20. package/dist/commands/agents.js +1088 -0
  21. package/dist/commands/agentsSdk.generated.js +7 -0
  22. package/dist/commands/ask.js +0 -3
  23. package/dist/commands/capture.js +0 -2
  24. package/dist/commands/createTicket.js +0 -10
  25. package/dist/commands/doctor.js +2 -72
  26. package/dist/commands/evals.js +3 -9
  27. package/dist/commands/inbox.js +0 -2
  28. package/dist/commands/library.js +0 -3
  29. package/dist/commands/pack.js +0 -29
  30. package/dist/commands/ping.js +0 -2
  31. package/dist/commands/pull.js +0 -9
  32. package/dist/commands/push.js +0 -12
  33. package/dist/commands/search.js +0 -4
  34. package/dist/commands/send.js +0 -5
  35. package/dist/commands/share.js +0 -4
  36. package/dist/commands/shared.js +0 -35
  37. package/dist/commands/traces.js +0 -13
  38. package/dist/commands/tracesLabel.js +0 -8
  39. package/dist/configDir.js +1 -18
  40. package/dist/connect.js +3 -75
  41. package/dist/connectFlow.js +0 -15
  42. package/dist/copilotHooks.js +0 -67
  43. package/dist/copilotInstallation.js +0 -1
  44. package/dist/cursorHooks.js +1 -11
  45. package/dist/cursorLifecycle.js +1 -7
  46. package/dist/detection.js +0 -30
  47. package/dist/harnessDirs.js +0 -19
  48. package/dist/hook.js +43 -293
  49. package/dist/inboxFormat.js +0 -24
  50. package/dist/launchContext.js +0 -50
  51. package/dist/launcher.js +0 -33
  52. package/dist/limitEvent.js +2 -34
  53. package/dist/managedPi.js +2 -50
  54. package/dist/mcp.js +0 -48
  55. package/dist/nativeSteering.js +1 -4
  56. package/dist/packs/codie.js +0 -15
  57. package/dist/packs/harnesses.js +1 -38
  58. package/dist/packs/hookGate.js +0 -33
  59. package/dist/packs/install.js +0 -20
  60. package/dist/packs/manifest.js +1 -15
  61. package/dist/packs/paths.js +0 -8
  62. package/dist/packs/shim.js +0 -8
  63. package/dist/packs/store.js +0 -12
  64. package/dist/packs/sync.js +0 -20
  65. package/dist/piHooks.js +4 -45
  66. package/dist/piRuntime.js +0 -18
  67. package/dist/repoResolver.js +0 -19
  68. package/dist/secret.js +0 -3
  69. package/dist/selfUpdate.js +2 -77
  70. package/dist/skillMarker.js +0 -14
  71. package/dist/subagents.js +0 -18
  72. package/dist/teamSkillContext.js +0 -1
  73. package/dist/transcriptModels.js +0 -8
  74. package/dist/upgradeNudge.js +1 -30
  75. package/dist/utils.js +165 -768
  76. package/dist/version.js +0 -10
  77. package/dist/versionCheck.js +0 -33
  78. package/package.json +13 -8
  79. package/dist/backup/cache.d.ts +0 -14
  80. package/dist/backup/match.d.ts +0 -2
  81. package/dist/backup/run.d.ts +0 -25
  82. package/dist/captureExclusions.d.ts +0 -30
  83. package/dist/captureLog.d.ts +0 -20
  84. package/dist/captureReadiness.d.ts +0 -65
  85. package/dist/captureSignals.d.ts +0 -47
  86. package/dist/cli.d.ts +0 -15
  87. package/dist/cliSession.d.ts +0 -108
  88. package/dist/codexHookTrust.d.ts +0 -49
  89. package/dist/codexLimitScan.d.ts +0 -64
  90. package/dist/codie.d.ts +0 -2
  91. package/dist/codieBrandInstall.d.ts +0 -11
  92. package/dist/codieLauncher.d.ts +0 -87
  93. package/dist/codieRuntime.d.ts +0 -35
  94. package/dist/codieRuntimeInstall.d.ts +0 -56
  95. package/dist/codieUsage.d.ts +0 -64
  96. package/dist/commands/ask.d.ts +0 -1
  97. package/dist/commands/capture.d.ts +0 -14
  98. package/dist/commands/createTicket.d.ts +0 -27
  99. package/dist/commands/delete.d.ts +0 -1
  100. package/dist/commands/doctor.d.ts +0 -9
  101. package/dist/commands/evals.d.ts +0 -3
  102. package/dist/commands/inbox.d.ts +0 -3
  103. package/dist/commands/library.d.ts +0 -2
  104. package/dist/commands/note.d.ts +0 -1
  105. package/dist/commands/pack.d.ts +0 -54
  106. package/dist/commands/ping.d.ts +0 -1
  107. package/dist/commands/pull.d.ts +0 -2
  108. package/dist/commands/push.d.ts +0 -33
  109. package/dist/commands/search.d.ts +0 -2
  110. package/dist/commands/send.d.ts +0 -2
  111. package/dist/commands/share.d.ts +0 -2
  112. package/dist/commands/shared.d.ts +0 -64
  113. package/dist/commands/traces.d.ts +0 -72
  114. package/dist/commands/tracesLabel.d.ts +0 -24
  115. package/dist/configDir.d.ts +0 -10
  116. package/dist/connect.d.ts +0 -1
  117. package/dist/connectFlow.d.ts +0 -34
  118. package/dist/copilotHooks.d.ts +0 -85
  119. package/dist/copilotInstallation.d.ts +0 -10
  120. package/dist/cursorHooks.d.ts +0 -13
  121. package/dist/cursorLifecycle.d.ts +0 -2
  122. package/dist/detection.d.ts +0 -30
  123. package/dist/harnessDirs.d.ts +0 -15
  124. package/dist/hook.d.ts +0 -6
  125. package/dist/inboxFormat.d.ts +0 -38
  126. package/dist/launchContext.d.ts +0 -46
  127. package/dist/launcher.d.ts +0 -25
  128. package/dist/limitEvent.d.ts +0 -15
  129. package/dist/limitEventReporter.d.ts +0 -21
  130. package/dist/managedPi.d.ts +0 -48
  131. package/dist/mcp.d.ts +0 -12
  132. package/dist/nativeSteering.d.ts +0 -5
  133. package/dist/packs/codie.d.ts +0 -45
  134. package/dist/packs/harnesses.d.ts +0 -74
  135. package/dist/packs/hookGate.d.ts +0 -53
  136. package/dist/packs/install.d.ts +0 -46
  137. package/dist/packs/manifest.d.ts +0 -52
  138. package/dist/packs/paths.d.ts +0 -17
  139. package/dist/packs/shim.d.ts +0 -26
  140. package/dist/packs/store.d.ts +0 -42
  141. package/dist/packs/sync.d.ts +0 -40
  142. package/dist/piHooks.d.ts +0 -22
  143. package/dist/piRuntime.d.ts +0 -26
  144. package/dist/repoResolver.d.ts +0 -17
  145. package/dist/secret.d.ts +0 -1
  146. package/dist/selfUpdate.d.ts +0 -64
  147. package/dist/skillMarker.d.ts +0 -6
  148. package/dist/subagents.d.ts +0 -36
  149. package/dist/teamSkillContext.d.ts +0 -9
  150. package/dist/transcriptModels.d.ts +0 -20
  151. package/dist/upgradeNudge.d.ts +0 -24
  152. package/dist/utils.d.ts +0 -396
  153. package/dist/version.d.ts +0 -1
  154. package/dist/versionCheck.d.ts +0 -68
package/dist/utils.js CHANGED
@@ -64,12 +64,14 @@ exports.cursorInstalled = cursorInstalled;
64
64
  exports.copilotInstalled = copilotInstalled;
65
65
  exports.openCodeInstalled = openCodeInstalled;
66
66
  exports.installOpenCodeCapturePlugin = installOpenCodeCapturePlugin;
67
+ exports.ensureOpenCodePluginCurrent = ensureOpenCodePluginCurrent;
67
68
  exports.piInstalled = piInstalled;
68
69
  exports.piExtensionSource = piExtensionSource;
69
70
  exports.installPiExtension = installPiExtension;
70
71
  exports.ensurePiExtensionInstalled = ensurePiExtensionInstalled;
71
72
  exports.vscodeCopilotInstalled = vscodeCopilotInstalled;
72
73
  exports.upsertCodiedevHook = upsertCodiedevHook;
74
+ exports.removeCodiedevHook = removeCodiedevHook;
73
75
  exports.installHook = installHook;
74
76
  exports.ensureDiscoveryHookInstalled = ensureDiscoveryHookInstalled;
75
77
  exports.ensureBackupHookInstalled = ensureBackupHookInstalled;
@@ -167,18 +169,12 @@ function readConfig() {
167
169
  function writeConfig(config) {
168
170
  writeConfigAtPath(config, CONFIG_DIR, exports.CONFIG_PATH);
169
171
  }
170
- /** Exported for permission-focused tests; normal callers use writeConfig. */
171
172
  function writeConfigAtPath(config, configDir, configPath) {
172
173
  if (!fs.existsSync(configDir)) {
173
174
  fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
174
175
  }
175
- // Temp file + rename: hooks read this file concurrently, and a torn read
176
- // looks like "not connected". The temp file is created 0600.
177
176
  (0, cliSession_1.writeConfigAtomic)(configPath, config);
178
177
  }
179
- // Parse a remote URL out of git-config INI text WITHOUT invoking git. Prefers
180
- // the "origin" remote; falls back to the first remote found. Returns null if
181
- // there are no remote sections.
182
178
  function parseGitRemoteFromConfig(configText) {
183
179
  const remotes = {};
184
180
  let current = null;
@@ -190,8 +186,6 @@ function parseGitRemoteFromConfig(configText) {
190
186
  continue;
191
187
  }
192
188
  if (line.startsWith("[")) {
193
- // Any other section header ends the current remote context, so a
194
- // `url =` under e.g. [core] or [some-section] is never mistaken for one.
195
189
  current = null;
196
190
  continue;
197
191
  }
@@ -207,13 +201,8 @@ function parseGitRemoteFromConfig(configText) {
207
201
  const first = Object.values(remotes)[0];
208
202
  return first ?? null;
209
203
  }
210
- // Locate the git directory for `startDir`, walking up parent directories.
211
- // Handles a `.git` directory (normal repo), a `.git` FILE containing
212
- // `gitdir: <path>` (submodules, some devcontainer/worktree layouts), and stops
213
- // at the filesystem root. Returns the resolved git dir path, or null.
214
204
  function resolveGitDir(startDir) {
215
205
  let dir = path.resolve(startDir);
216
- // eslint-disable-next-line no-constant-condition
217
206
  while (true) {
218
207
  const dotGit = path.join(dir, ".git");
219
208
  try {
@@ -229,7 +218,6 @@ function resolveGitDir(startDir) {
229
218
  }
230
219
  }
231
220
  catch {
232
- // No .git here — keep walking up.
233
221
  }
234
222
  const parent = path.dirname(dir);
235
223
  if (parent === dir)
@@ -237,9 +225,6 @@ function resolveGitDir(startDir) {
237
225
  dir = parent;
238
226
  }
239
227
  }
240
- // Ordered list of config files that may hold the remotes for a git dir. Linked
241
- // worktrees keep their remotes in the COMMON (main) git dir, pointed at by a
242
- // `commondir` file, so we check that first, then the git dir's own config.
243
228
  function gitConfigCandidates(gitDir) {
244
229
  const candidates = [];
245
230
  try {
@@ -251,15 +236,10 @@ function gitConfigCandidates(gitDir) {
251
236
  }
252
237
  }
253
238
  catch {
254
- // ignore — fall through to the plain config below
255
239
  }
256
240
  candidates.push(path.join(gitDir, "config"));
257
241
  return candidates;
258
242
  }
259
- // Resolve a repo's remote URL by reading .git/config directly — no `git`
260
- // binary required. This is the primary path so lean container images (Alpine
261
- // devcontainers without git) still capture sessions instead of silently
262
- // dropping them.
263
243
  function readGitRemoteFromConfig(startDir) {
264
244
  const gitDir = resolveGitDir(startDir);
265
245
  if (!gitDir)
@@ -271,20 +251,14 @@ function readGitRemoteFromConfig(startDir) {
271
251
  return url;
272
252
  }
273
253
  catch {
274
- // try the next candidate
275
254
  }
276
255
  }
277
256
  return null;
278
257
  }
279
258
  function getGitRemoteUrl(cwd) {
280
- // Primary: read .git/config directly. Works even when the `git` binary is
281
- // absent (lean Alpine/devcontainer images), which previously made the
282
- // SessionEnd hook silently drop every session.
283
259
  const fromConfig = readGitRemoteFromConfig(cwd);
284
260
  if (fromConfig)
285
261
  return fromConfig;
286
- // Fallback: ask git itself, for setups the direct read doesn't cover
287
- // (insteadOf rewrites, includeIf'd remotes, unusual gitdir indirection).
288
262
  try {
289
263
  const url = (0, child_process_1.execSync)("git remote get-url origin", {
290
264
  cwd,
@@ -298,13 +272,6 @@ function getGitRemoteUrl(cwd) {
298
272
  return null;
299
273
  }
300
274
  }
301
- // Resolve the remote that tags the session's repo. The workspace root is the
302
- // primary source, but when the session was launched at a multi-repo WORKSPACE
303
- // root — a parent folder holding several cloned repos with `.devcontainer/` at
304
- // the top, which is not itself a git repo — the root has no remote, and tagging
305
- // used to silently produce repo-UNMATCHED sessions. Fall back to the worktrees
306
- // the session actually wrote to (resolveCaptureDirs order: most-recent first),
307
- // so the session tags the repo the work happened in.
308
275
  function resolveCaptureRemoteUrl(workspaceRoot, captureDirs, getRemote = getGitRemoteUrl) {
309
276
  const fromRoot = getRemote(workspaceRoot);
310
277
  if (fromRoot)
@@ -317,19 +284,14 @@ function resolveCaptureRemoteUrl(workspaceRoot, captureDirs, getRemote = getGitR
317
284
  return null;
318
285
  }
319
286
  function normalizeRepoUrl(url) {
320
- // Normalize SSH (git@github.com:org/repo.git) and HTTPS (https://github.com/org/repo.git)
321
- // to a comparable form: github.com/org/repo
322
287
  let normalized = url.trim();
323
- // Strip trailing .git
324
288
  if (normalized.endsWith(".git")) {
325
289
  normalized = normalized.slice(0, -4);
326
290
  }
327
- // Handle SSH format: git@github.com:org/repo → github.com/org/repo
328
291
  const sshMatch = normalized.match(/^git@([^:]+):(.+)$/);
329
292
  if (sshMatch) {
330
293
  return `${sshMatch[1]}/${sshMatch[2]}`;
331
294
  }
332
- // Handle HTTPS format: https://github.com/org/repo → github.com/org/repo
333
295
  const httpsMatch = normalized.match(/^https?:\/\/(.+)$/);
334
296
  if (httpsMatch) {
335
297
  return httpsMatch[1];
@@ -341,11 +303,6 @@ function matchRepo(remoteUrl, repos) {
341
303
  for (const repo of repos) {
342
304
  const normalizedUrl = normalizeRepoUrl(repo.url);
343
305
  const normalizedName = repo.fullName.toLowerCase();
344
- // Match the REMOTE against this repo: exact normalized URL, or the remote
345
- // ending with this repo's full path (`owner/.../name`). Both are anchored to
346
- // `normalizedRemote` — never compare a repo to ITSELF (the old third clause
347
- // `normalizedUrl.endsWith('/'+name)` was remote-independent and returned the
348
- // first repo for every session, collapsing all repos for multi-repo orgs).
349
306
  if (normalizedRemote === normalizedUrl ||
350
307
  normalizedRemote.endsWith(`/${normalizedName}`)) {
351
308
  return repo;
@@ -353,38 +310,11 @@ function matchRepo(remoteUrl, repos) {
353
310
  }
354
311
  return null;
355
312
  }
356
- // Resolve the directory to capture git branch/headSha from.
357
- //
358
- // The session→merge join keys must describe the work that becomes the PR. But
359
- // agents do that work in a `git worktree` (.claude/worktrees/<name> or a
360
- // sibling dir) on a feature branch, while the session's LAUNCH dir
361
- // (workspaceRoot) stays on main/detached — a feature branch checked out in a
362
- // worktree cannot also be checked out in the launch dir. Capturing from the
363
- // launch dir therefore recorded main/null, matching no PR (velocity starved).
364
- //
365
- // Fix: scan the transcript for the cwd the agent actually worked in — the
366
- // most-recent one that is a SEPARATE worktree (its resolved gitdir differs from
367
- // the launch root's; a worktree's gitdir is .git/worktrees/<name>, while the
368
- // root and its subdirs share .git). Fall back to the launch root when no
369
- // separate worktree was used (work done directly in the repo).
370
313
  function resolveCaptureDir(transcriptContent, workspaceRoot) {
371
314
  return resolveCaptureDirs(transcriptContent, workspaceRoot)[0] ?? workspaceRoot;
372
315
  }
373
- // Plural: EVERY separate worktree the session touched, most-recent first. One
374
- // session can produce multiple PRs (work across several worktrees, each on its
375
- // own feature branch); capturing every worktree's branch+sha lets all of those
376
- // PRs attribute. Falls back to [workspaceRoot] when no separate worktree was
377
- // used (work done directly in the repo).
378
316
  function resolveCaptureDirs(transcriptContent, workspaceRoot) {
379
317
  const rootGitDir = resolveGitDir(workspaceRoot);
380
- // Collect the folders the session WROTE to (not merely referenced), in order:
381
- // - the session cwd
382
- // - `git -C <path>` of MUTATING git commands (commit/push/add/...) — NOT
383
- // read-only ones (log/status/diff), which would falsely credit a worktree
384
- // the session only inspected
385
- // - the dir of files edited via Edit/Write/NotebookEdit — NOT Read/Grep/Glob
386
- // Reading/inspecting another worktree must not attribute its PRs to this
387
- // session (that also corrupts the even cost-split).
388
318
  const candidates = [];
389
319
  const add = (p) => {
390
320
  if (!p)
@@ -399,7 +329,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
399
329
  entry = JSON.parse(line);
400
330
  }
401
331
  catch {
402
- // partial/non-JSON line — best-effort: only mutating git -C, never files.
403
332
  for (const d of mutatingGitCDirs(line))
404
333
  add(d);
405
334
  continue;
@@ -422,10 +351,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
422
351
  }
423
352
  }
424
353
  }
425
- // Walk most-recent first; one entry per distinct worktree gitdir (a worktree's
426
- // gitdir is .git/worktrees/<name>; the root and its subdirs share .git, so
427
- // they're excluded as not-separate). Normalize each to its canonical worktree
428
- // root so a file path / subdir resolves to the worktree dir itself.
429
354
  const out = [];
430
355
  const seenGitDirs = new Set();
431
356
  for (let i = candidates.length - 1; i >= 0; i--) {
@@ -439,10 +364,6 @@ function resolveCaptureDirs(transcriptContent, workspaceRoot) {
439
364
  }
440
365
  return out.length > 0 ? out : [workspaceRoot];
441
366
  }
442
- // Extract the `-C <path>` targets of MUTATING git commands in a shell string.
443
- // Splits on command separators and only takes a sub-command's -C path when that
444
- // sub-command runs a state-changing git verb — so `git -C x log` is ignored but
445
- // `git -C x commit`/`push`/`add` count.
446
367
  const MUTATING_GIT = /\b(?:commit|push|add|checkout|switch|merge|rebase|reset|restore|rm|mv|stash|apply|cherry-pick|am|tag|worktree)\b/;
447
368
  function mutatingGitCDirs(command) {
448
369
  const out = [];
@@ -456,8 +377,6 @@ function mutatingGitCDirs(command) {
456
377
  }
457
378
  return out;
458
379
  }
459
- // A linked worktree's gitdir (`.git/worktrees/<name>`) holds a `gitdir` file
460
- // pointing at the worktree's own `.git` file; its parent is the worktree root.
461
380
  function worktreeRootFromGitDir(gitDir) {
462
381
  try {
463
382
  const p = fs.readFileSync(path.join(gitDir, "gitdir"), "utf8").trim();
@@ -467,10 +386,6 @@ function worktreeRootFromGitDir(gitDir) {
467
386
  return null;
468
387
  }
469
388
  }
470
- // Turn the worktrees a session touched into the upload join keys: dedup'd
471
- // branch + headSha arrays (so one session → many PRs all attribute), plus the
472
- // primary single values (first entry) for back-compat with servers that read
473
- // only branch/headSha. getBranch/getSha are injectable for testing.
474
389
  function collectCaptureKeys(dirs, getBranch = getGitBranch, getSha = getGitHeadSha) {
475
390
  const branches = [];
476
391
  const headShas = [];
@@ -503,19 +418,12 @@ function collectCaptureGitRefs(dirs, getRemote = getGitRemoteUrl, getBranch = ge
503
418
  }
504
419
  return out;
505
420
  }
506
- // Exact PR/MR URLs are the strongest provenance the transcript can provide.
507
- // Both `gh pr create` and `glab mr create` print one, and the OpenCode capture
508
- // is normalized through the same parser as Claude Code. A direct URL survives
509
- // branch deletion, squash/rebase, cherry-picks, and SessionEnd happening after
510
- // the PR was opened.
511
421
  function parsePullRequestUrls(transcriptContent) {
512
422
  const out = [];
513
423
  const createToolIds = new Set();
514
424
  const isCreateCommand = (command) => {
515
425
  if (typeof command !== "string")
516
426
  return false;
517
- // A combined `gh pr view || gh pr create` result is ambiguous: when the PR
518
- // already exists, the VIEW half prints its URL and CREATE never runs.
519
427
  if (/\b(?:gh\s+pr\s+(?:view|list|status)|glab\s+mr\s+(?:view|list))\b/i.test(command)) {
520
428
  return false;
521
429
  }
@@ -539,7 +447,6 @@ function parsePullRequestUrls(transcriptContent) {
539
447
  out.push(canonical);
540
448
  }
541
449
  catch {
542
- // Malformed tool output is ignored; capture remains best-effort.
543
450
  }
544
451
  }
545
452
  };
@@ -577,7 +484,6 @@ function parsePullRequestUrls(transcriptContent) {
577
484
  }
578
485
  }
579
486
  }
580
- // Codex function calls carry arguments as serialized JSON.
581
487
  if (entry?.type === "response_item"
582
488
  && entry?.payload?.type === "function_call"
583
489
  && typeof entry.payload.call_id === "string") {
@@ -586,7 +492,7 @@ function parsePullRequestUrls(transcriptContent) {
586
492
  try {
587
493
  args = JSON.parse(args);
588
494
  }
589
- catch { /* leave as string */ }
495
+ catch { }
590
496
  }
591
497
  if (isCreateCommand(args?.cmd ?? args?.command ?? args)) {
592
498
  createToolIds.add(entry.payload.call_id);
@@ -594,13 +500,8 @@ function parsePullRequestUrls(transcriptContent) {
594
500
  }
595
501
  }
596
502
  catch {
597
- // Never regex the raw line: prompts and assistant prose can contain a PR
598
- // URL, but only actual tool output is provenance.
599
503
  }
600
504
  }
601
- // Second pass: a URL is proof only when it came from the result of the
602
- // specific gh/glab CREATE call above. `gh pr view/list` also prints URLs and
603
- // must never claim somebody else's already-existing PR for this session.
604
505
  for (const entry of entries) {
605
506
  const content = entry?.message?.content;
606
507
  if (Array.isArray(content)) {
@@ -622,17 +523,11 @@ function parsePullRequestUrls(transcriptContent) {
622
523
  }
623
524
  return out;
624
525
  }
625
- // Pull the commit SHAs the agent actually authored/pushed straight out of the
626
- // transcript's git command OUTPUT — independent of end-of-session filesystem
627
- // state. Catches: commit output "[branch 4c4e790]", push ranges
628
- // "abc1234..def5678", and bare 40-hex (rev-parse). This is the signal that
629
- // rescues attribution when the worktree's HEAD is stale or the worktree is gone
630
- // at SessionEnd (pruned worktree, submodule pointer bump).
631
526
  function extractShaTokens(transcriptContent) {
632
527
  const out = new Set();
633
- const commitRe = /\[[^\]\n]*?\b([0-9a-f]{7,40})\]/g; // [branch <sha>]
634
- const rangeRe = /\b([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})\b/g; // <sha>..<sha>
635
- const fullRe = /\b([0-9a-f]{40})\b/g; // bare full sha (rev-parse)
528
+ const commitRe = /\[[^\]\n]*?\b([0-9a-f]{7,40})\]/g;
529
+ const rangeRe = /\b([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})\b/g;
530
+ const fullRe = /\b([0-9a-f]{40})\b/g;
636
531
  let m;
637
532
  while ((m = commitRe.exec(transcriptContent)))
638
533
  out.add(m[1].toLowerCase());
@@ -644,17 +539,6 @@ function extractShaTokens(transcriptContent) {
644
539
  out.add(m[1].toLowerCase());
645
540
  return [...out];
646
541
  }
647
- // Pull the branch NAMES the agent actually committed/pushed to, straight out of
648
- // the transcript's git OUTPUT — the branch companion to extractShaTokens. This
649
- // is the rescue for the dominant capture miss: the agent works in a
650
- // `.claude/worktrees/<name>` worktree on branch `worktree-<name>`, commits/pushes
651
- // /PRs, then the worktree is REMOVED before SessionEnd. resolveCaptureDirs then
652
- // can't resolve the (deleted) worktree's git dir and falls back to the parent
653
- // dir, capturing the PARENT's branch — so the PR's sourceBranch matches no
654
- // captured session. The branch name survives in the commit line `[branch sha]`
655
- // and the push ref `<local> -> <remote>`, both of which prove authorship (a
656
- // read-only `git log` emits neither), so this stays mutation-scoped like the
657
- // SHA parse. Pure string parse — no filesystem, no server change.
658
542
  function parseAuthoredBranches(transcriptContent) {
659
543
  const out = [];
660
544
  const add = (b) => {
@@ -667,25 +551,12 @@ function parseAuthoredBranches(transcriptContent) {
667
551
  while ((m = re.exec(text)))
668
552
  add(m[1]);
669
553
  };
670
- // From git OUTPUT (proof the work shipped on that branch):
671
- // commit: `[<branch> [(root-commit) ]<sha>]`. `[^\]\s]+` rejects "detached
672
- // HEAD" (the space breaks the token before the sha can match).
673
- // push: `<sha>..<sha> <local> -> <remote>` (updated) / `[new branch]
674
- // <local> -> <remote>` (created). Anchor on the push-ref shape so prose
675
- // arrows ("a -> b") don't match; capture the remote (= the PR
676
- // sourceBranch). `[^\s"]+` stops at the JSON string boundary.
677
554
  const commitRe = /\[([^\]\s]+)(?:\s+\(root-commit\))?\s+[0-9a-f]{7,40}\]/g;
678
555
  const pushRe = /(?:[0-9a-f]{7,40}\.\.[0-9a-f]{7,40}|\[new branch\])\s+\S+\s+->\s+([^\s"]+)/g;
679
- // From CREATE commands (rescues work committed/pushed entirely OUTSIDE the
680
- // session): only `checkout -b`/`switch -c` (branch CREATION — not plain
681
- // checkout/switch navigation), and only from real Bash tool_use inputs, never
682
- // prose/output, so a suggested command in assistant text can't false-match.
683
556
  const createRe = /\bgit\s+(?:checkout\s+-b|switch\s+-c)\s+([^\s"'&;|]+)/g;
684
557
  for (const line of transcriptContent.split("\n")) {
685
558
  if (!line.trim())
686
559
  continue;
687
- // commit/push output: scan the raw serialized line (output lives in string
688
- // fields); robust to non-JSON / partial lines.
689
560
  scan(line, commitRe);
690
561
  scan(line, pushRe);
691
562
  let entry;
@@ -706,11 +577,6 @@ function parseAuthoredBranches(transcriptContent) {
706
577
  }
707
578
  return out;
708
579
  }
709
- // Resolve the extracted tokens to FULL 40-char SHAs via the repo's shared object
710
- // store (a worktree's commits live in the main repo's .git/objects, so they
711
- // resolve even after the worktree dir is removed). Full SHAs pass through; short
712
- // ones are resolved; unresolvable ones are dropped (a short sha can't exact-
713
- // match the worker's full commitShas). Keeps the server-side match exact.
714
580
  function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas) => defaultResolveFullShaBatch(shas, repoRoot)) {
715
581
  const out = new Set();
716
582
  const shorts = new Set();
@@ -729,14 +595,6 @@ function parseAuthoredShas(transcriptContent, repoRoot, resolveFullBatch = (shas
729
595
  }
730
596
  return [...out];
731
597
  }
732
- // Resolve many short SHAs to full in ONE git spawn via `git cat-file
733
- // --batch-check` (reads requests on stdin, emits one result line per request,
734
- // in order). The previous one-`git rev-parse`-per-sha approach cost N spawns;
735
- // on Windows (~50-150ms/spawn) a long session's transcript made the detached
736
- // capture child run for tens of seconds (measured 28s for 40 shas on a real
737
- // windows-latest runner) — fragile if the child is torn down before it
738
- // finishes. This is ~constant-time regardless of sha count. Missing objects
739
- // yield a "<req> missing" line and are skipped. Never throws.
740
598
  function defaultResolveFullShaBatch(shas, repoRoot) {
741
599
  const out = new Map();
742
600
  if (shas.length === 0)
@@ -749,8 +607,6 @@ function defaultResolveFullShaBatch(shas, repoRoot) {
749
607
  stdio: ["pipe", "pipe", "pipe"],
750
608
  }).toString();
751
609
  const lines = res.split("\n");
752
- // One output line per input line, in order: "<full-sha> commit <size>" when
753
- // resolved; "<request> missing" / "<request> ambiguous" otherwise.
754
610
  shas.forEach((sha, i) => {
755
611
  const parts = (lines[i] ?? "").trim().split(/\s+/);
756
612
  if (parts.length >= 2 && parts[1] === "commit" && /^[0-9a-f]{40}$/.test(parts[0])) {
@@ -759,14 +615,9 @@ function defaultResolveFullShaBatch(shas, repoRoot) {
759
615
  });
760
616
  }
761
617
  catch {
762
- // best-effort — a git failure just means no shas resolve
763
618
  }
764
619
  return out;
765
620
  }
766
- // Current branch via direct .git/HEAD read (no git binary required, same
767
- // robustness as getGitRemoteUrl). Returns null on detached HEAD or when
768
- // unresolvable. This is the session→merge join key: a captured session's
769
- // branch is matched against a merged MR/PR's source_branch.
770
621
  function getGitBranch(cwd) {
771
622
  const gitDir = resolveGitDir(cwd);
772
623
  if (gitDir) {
@@ -775,10 +626,8 @@ function getGitBranch(cwd) {
775
626
  const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
776
627
  if (m)
777
628
  return m[1].trim();
778
- // raw sha => detached HEAD, no branch
779
629
  }
780
630
  catch {
781
- // fall through to git
782
631
  }
783
632
  }
784
633
  try {
@@ -794,8 +643,6 @@ function getGitBranch(cwd) {
794
643
  return null;
795
644
  }
796
645
  }
797
- // Current commit SHA (HEAD), direct-read first then git fallback. Exact join
798
- // key — survives branch renames that branch-matching would miss.
799
646
  function getGitHeadSha(cwd) {
800
647
  const gitDir = resolveGitDir(cwd);
801
648
  if (gitDir) {
@@ -814,7 +661,6 @@ function getGitHeadSha(cwd) {
814
661
  return sha.toLowerCase();
815
662
  }
816
663
  catch {
817
- /* try packed-refs */
818
664
  }
819
665
  try {
820
666
  const packed = fs.readFileSync(path.join(baseDir, "packed-refs"), "utf8");
@@ -828,13 +674,11 @@ function getGitHeadSha(cwd) {
828
674
  }
829
675
  }
830
676
  catch {
831
- /* ignore */
832
677
  }
833
678
  }
834
679
  }
835
680
  }
836
681
  catch {
837
- // fall through to git
838
682
  }
839
683
  }
840
684
  try {
@@ -861,11 +705,11 @@ function buildCapturePayload(input) {
861
705
  companyId: input.companyId,
862
706
  branch: input.branch ?? "",
863
707
  headSha: input.headSha ?? "",
864
- // Arrays omitted when empty so older servers (which ignore unknown keys)
865
- // see no change; newer servers index the session under every key.
866
708
  branches: input.branches && input.branches.length ? input.branches : undefined,
867
709
  headShas: input.headShas && input.headShas.length ? input.headShas : undefined,
868
- gitRefs: input.gitRefs && input.gitRefs.length ? input.gitRefs.map(ref => ({ ...ref, repoUrl: stripRepoUrlCredentials(ref.repoUrl) })) : undefined,
710
+ gitRefs: input.gitRefs && input.gitRefs.length
711
+ ? input.gitRefs.map((ref) => ({ ...ref, repoUrl: stripRepoUrlCredentials(ref.repoUrl) }))
712
+ : undefined,
869
713
  pullRequestUrls: input.pullRequestUrls && input.pullRequestUrls.length
870
714
  ? input.pullRequestUrls
871
715
  : undefined,
@@ -874,8 +718,6 @@ function buildCapturePayload(input) {
874
718
  toolCallCount: input.toolCallCount,
875
719
  capturedAt: input.capturedAt,
876
720
  source: input.source,
877
- // New capture signals (cost / velocity / steering). Optional — omitted
878
- // (undefined) keys drop out of the JSON body, so older servers ignore them.
879
721
  inputTokens: input.inputTokens,
880
722
  outputTokens: input.outputTokens,
881
723
  cacheTokens: input.cacheTokens,
@@ -887,7 +729,6 @@ function buildCapturePayload(input) {
887
729
  sessionStartedAt: input.sessionStartedAt ?? undefined,
888
730
  interruptCount: input.interruptCount,
889
731
  rejectionCount: input.rejectionCount,
890
- // final is only emitted when explicitly set (not for Codex).
891
732
  final: input.final,
892
733
  costUsd: input.costUsd,
893
734
  };
@@ -913,9 +754,6 @@ const CURSOR_HOOKS_PATH = path.join(CURSOR_DIR, "hooks.json");
913
754
  const CURSOR_MCP_PATH = path.join(CURSOR_DIR, "mcp.json");
914
755
  const CURSOR_RULES_DIR = path.join(CURSOR_DIR, "rules");
915
756
  const CURSOR_RULES_PATH = path.join(CURSOR_RULES_DIR, "codiedev.mdc");
916
- // VS Code Copilot Chat: detected by the github.copilot* extension dir, then
917
- // MCP wired via VS Code's user-scope mcp.json (top-level key is "servers",
918
- // not "mcpServers" like Claude/Cursor).
919
757
  const VSCODE_EXTENSIONS_DIR = path.join(os.homedir(), ".vscode", "extensions");
920
758
  const VSCODE_USER_DIR = (() => {
921
759
  const home = os.homedir();
@@ -929,40 +767,17 @@ const VSCODE_USER_DIR = (() => {
929
767
  return path.join(home, ".config", "Code", "User");
930
768
  })();
931
769
  const VSCODE_USER_MCP_PATH = path.join(VSCODE_USER_DIR, "mcp.json");
932
- // GUI-launched agents (Cursor.app, future JetBrains plugins) don't source the
933
- // user's shell rc, so nvm-managed `npx` and `node` aren't on PATH. Resolve the
934
- // absolute path to the current node binary and our hook.js at install time and
935
- // write that into hook configs so execution doesn't depend on the spawned
936
- // shell's PATH. Falls back to `npx` if we can't resolve dist/hook.js.
937
770
  function shellQuote(s) {
938
771
  return `'${s.replace(/'/g, `'\\''`)}'`;
939
772
  }
940
- // Pull the `<node>` prefix out of a codiedev hook command we previously wrote.
941
- // Commands take the form `'<node>' '<hookScript>' <subcommand>` (each path
942
- // single-quoted by shellQuote). Returns undefined for the legacy
943
- // `npx codiedev-hook <subcommand>` form (no explicit node path) or if the
944
- // command doesn't start with a quoted token. Exported for unit testing.
945
773
  function extractHookNodePath(cmd) {
946
774
  if (!cmd)
947
775
  return undefined;
948
- // Match the first single-quoted token, honoring shellQuote's `'\''` escape.
949
776
  const m = cmd.match(/^'((?:[^']|'\\'')*)'/);
950
777
  if (!m)
951
778
  return undefined;
952
779
  return m[1].replace(/'\\''/g, "'");
953
780
  }
954
- // `process.execPath` is the *resolved* path to the running node binary, so under
955
- // Homebrew it's the version-specific Cellar path (e.g.
956
- // /opt/homebrew/Cellar/node/26.4.0/bin/node). Baking that into the hook means a
957
- // later `brew upgrade node` moves the Cellar dir and the hook path 404s. Prefer
958
- // a stable alias for the *same* binary:
959
- // 1. A node path from a hook we previously wrote that still resolves — usually
960
- // a symlink the user deliberately pinned (e.g. /opt/homebrew/bin/node). A
961
- // re-`connect`/self-heal then honors it instead of clobbering it.
962
- // 2. Otherwise, `node` as found on PATH when it points at the binary we're
963
- // running (the Homebrew symlink, an nvm `current` alias, etc.).
964
- // 3. Last resort: the resolved `process.execPath`. Always valid right now, and
965
- // still an absolute path a GUI-launched agent can execute without PATH.
966
781
  function realpathOrSelf(p) {
967
782
  try {
968
783
  return fs.realpathSync(p);
@@ -971,13 +786,7 @@ function realpathOrSelf(p) {
971
786
  return p;
972
787
  }
973
788
  }
974
- // A stable `node` alias on PATH (Homebrew symlink, nvm `current`, …) that points
975
- // at the very binary we're running, or undefined if none. `installHook` writes
976
- // five hooks per run, so memoize: `command -v node` is a synchronous subprocess
977
- // spawn and its result can't change within a process. `null` = "looked, none".
978
789
  let cachedStableNodeOnPath;
979
- // Test-only: the memo lives for the process lifetime, so suites that stage
980
- // their own PATH/symlink fixtures must clear it between tests.
981
790
  function resetStableNodeCacheForTests() {
982
791
  cachedStableNodeOnPath = undefined;
983
792
  }
@@ -998,30 +807,21 @@ function stableNodeOnPath() {
998
807
  }
999
808
  }
1000
809
  catch {
1001
- // No node on PATH (e.g. GUI-launched agent) — fall through.
1002
810
  }
1003
811
  cachedStableNodeOnPath = null;
1004
812
  return undefined;
1005
813
  }
1006
814
  function resolveHookNodePath(preferredNodePath) {
1007
815
  const execReal = realpathOrSelf(process.execPath);
1008
- // 1. Honor a still-working node path from the existing hook — but only when
1009
- // it's a *different* string than the resolved realpath. If the existing
1010
- // path already IS `process.execPath` it's the version-specific realpath we
1011
- // want to upgrade away from (that's the Homebrew bug), so fall through to
1012
- // hunt for a stable alias in step 2.
1013
816
  if (preferredNodePath &&
1014
817
  preferredNodePath !== process.execPath &&
1015
818
  fs.existsSync(preferredNodePath) &&
1016
819
  realpathOrSelf(preferredNodePath) === execReal) {
1017
820
  return preferredNodePath;
1018
821
  }
1019
- // 2. Prefer a stable `node` on PATH over the version-specific realpath.
1020
822
  const onPath = stableNodeOnPath();
1021
823
  if (onPath)
1022
824
  return onPath;
1023
- // 3. Last resort: the resolved absolute path. Always valid right now, and
1024
- // still an absolute path a GUI-launched agent can execute without PATH.
1025
825
  return process.execPath;
1026
826
  }
1027
827
  function resolveHookInvocation(subcommand, preferredNodePath) {
@@ -1033,11 +833,7 @@ function resolveHookInvocation(subcommand, preferredNodePath) {
1033
833
  const hookScript = path.join(distDir, "hook.js");
1034
834
  if (fs.existsSync(hookScript)) {
1035
835
  const nodePath = resolveHookNodePath(preferredNodePath);
1036
- // Bake the stable launcher, not this install's path, so later
1037
- // upgrades (including pnpm's hashed dirs) never strand the hook.
1038
836
  const launcher = (0, launcher_1.ensureLauncher)(hookScript);
1039
- // No launcher and no stable install to point at (e.g. an npx cache under a bogus "~"):
1040
- // the npx fallback below beats baking a path that won't exist tomorrow.
1041
837
  if (launcher || (0, launcher_1.stableHookScript)(hookScript)) {
1042
838
  return { command: nodePath, args: [launcher ?? hookScript, subcommand] };
1043
839
  }
@@ -1045,7 +841,6 @@ function resolveHookInvocation(subcommand, preferredNodePath) {
1045
841
  }
1046
842
  }
1047
843
  catch {
1048
- // Fall through to npx fallback.
1049
844
  }
1050
845
  return {
1051
846
  command: process.platform === "win32" ? "npx.cmd" : "npx",
@@ -1057,8 +852,6 @@ function resolveHookCommand(subcommand, preferredNodePath) {
1057
852
  const argsBeforeSubcommand = invocation.args.slice(0, -1).map(shellQuote);
1058
853
  return [shellQuote(invocation.command), ...argsBeforeSubcommand, subcommand].join(" ");
1059
854
  }
1060
- // `isCodiedevHookCommand` is defined in `./detection` so it's testable
1061
- // without mocking fs/path. Re-imported below; do not re-define here.
1062
855
  function claudeBinOnPath() {
1063
856
  try {
1064
857
  (0, child_process_1.execSync)("command -v claude", { stdio: ["pipe", "pipe", "pipe"] });
@@ -1080,11 +873,6 @@ function codexInstalled() {
1080
873
  function cursorInstalled() {
1081
874
  return fs.existsSync(CURSOR_DIR);
1082
875
  }
1083
- /**
1084
- * GitHub Copilot: `$COPILOT_HOME` / ~/.copilot (either surface has run once),
1085
- * the `copilot` CLI on PATH, or VS Code's Copilot Chat extension. VS Code's
1086
- * agent and the CLI read the same user hooks, so one install covers both.
1087
- */
1088
876
  function copilotInstalled() {
1089
877
  if (fs.existsSync((0, copilotHooks_1.copilotHome)()))
1090
878
  return true;
@@ -1109,13 +897,11 @@ function openCodeInstalled() {
1109
897
  return false;
1110
898
  }
1111
899
  }
1112
- /** Install a dormant global OpenCode adapter. Installing it unconditionally
1113
- * keeps `codiedev connect` as the only setup step even when Codie/OpenCode is
1114
- * installed later. OpenCode loads this file from its documented plugin dir. */
1115
900
  function installOpenCodeCapturePlugin() {
1116
901
  const start = resolveHookInvocation("session-start-opencode");
1117
902
  const capture = resolveHookInvocation("capture-opencode");
1118
903
  const finish = resolveHookInvocation("capture-opencode-final");
904
+ const backup = resolveHookInvocation("backup");
1119
905
  const prompt = resolveHookInvocation("discover-codie");
1120
906
  const source = `// Managed by codiedev connect. Safe to remove and reinstall.\n` +
1121
907
  `import { spawn, spawnSync } from "node:child_process";\n` +
@@ -1124,12 +910,14 @@ function installOpenCodeCapturePlugin() {
1124
910
  `import path from "node:path";\n\n` +
1125
911
  `const START = ${JSON.stringify(start)};\n` +
1126
912
  `const FINISH = ${JSON.stringify(finish)};\n` +
913
+ `const BACKUP = ${JSON.stringify(backup)};\n` +
1127
914
  `const PROMPT = ${JSON.stringify(prompt)};\n` +
1128
915
  `// How this Codie process was started (run / serve / tui). OpenCode records\n` +
1129
916
  `// nothing about it, so the plugin reads its own argv; see launchContext.ts.\n` +
1130
917
  `const LAUNCH = (() => { const sub = process.argv.slice(2).find((a) => ["run", "serve", "acp", "web", "attach"].includes(a)); return sub ? "codie:" + sub : "codie:tui"; })();\n` +
1131
918
  `const fingerprints = new Map();\n\n` +
1132
919
  `const transcriptPaths = new Map();\n\n` +
920
+ `const sessionDirectories = new Map();\n\n` +
1133
921
  `function sessionId(event) {\n` +
1134
922
  ` return event?.properties?.sessionID ?? event?.properties?.sessionId ?? event?.properties?.info?.id ?? null;\n` +
1135
923
  `}\n\n` +
@@ -1151,9 +939,22 @@ function installOpenCodeCapturePlugin() {
1151
939
  ` } catch { return null; }\n` +
1152
940
  `}\n\n` +
1153
941
  `export const CodiedevCapture = async ({ client, directory, worktree }) => {\n` +
942
+ ` const resolveCwd = async (id, hint) => {\n` +
943
+ ` if (typeof hint === "string" && hint && hint !== "/") { sessionDirectories.set(id, hint); return hint; }\n` +
944
+ ` const remembered = sessionDirectories.get(id);\n` +
945
+ ` if (remembered) return remembered;\n` +
946
+ ` try {\n` +
947
+ ` const response = await client.session.get({ path: { id } });\n` +
948
+ ` const session = response?.data ?? response;\n` +
949
+ ` if (typeof session?.directory === "string" && session.directory) { sessionDirectories.set(id, session.directory); return session.directory; }\n` +
950
+ ` } catch {}\n` +
951
+ ` const fallback = worktree || directory || process.cwd();\n` +
952
+ ` sessionDirectories.set(id, fallback);\n` +
953
+ ` return fallback;\n` +
954
+ ` };\n` +
1154
955
  ` const finalizeAll = () => {\n` +
1155
956
  ` for (const [id, transcriptPath] of transcriptPaths) {\n` +
1156
- ` const cwd = worktree || directory || process.cwd();\n` +
957
+ ` const cwd = sessionDirectories.get(id) || worktree || directory || process.cwd();\n` +
1157
958
  ` dispatchSync(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1158
959
  ` }\n` +
1159
960
  ` };\n` +
@@ -1161,7 +962,7 @@ function installOpenCodeCapturePlugin() {
1161
962
  ` return ({\n` +
1162
963
  ` "chat.message": async (input, output) => {\n` +
1163
964
  ` const id = input?.sessionID ?? input?.sessionId ?? "unknown";\n` +
1164
- ` const cwd = worktree || directory || process.cwd();\n` +
965
+ ` const cwd = await resolveCwd(id);\n` +
1165
966
  ` const prompt = Array.isArray(output?.parts) ? output.parts.filter((part) => part?.type === "text").map((part) => part.text || "").join("\\n") : "";\n` +
1166
967
  ` const decision = invokeSync(PROMPT, { session_id: id, cwd, prompt });\n` +
1167
968
  ` const context = decision?.hookSpecificOutput?.additionalContext;\n` +
@@ -1174,7 +975,7 @@ function installOpenCodeCapturePlugin() {
1174
975
  ` event: async ({ event }) => {\n` +
1175
976
  ` const id = sessionId(event);\n` +
1176
977
  ` if (!id) return;\n` +
1177
- ` const cwd = worktree || directory || process.cwd();\n` +
978
+ ` const cwd = await resolveCwd(id, event?.properties?.info?.directory);\n` +
1178
979
  ` if (event.type === "session.created") {\n` +
1179
980
  ` dispatch(START, { session_id: id, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1180
981
  ` return;\n` +
@@ -1183,6 +984,7 @@ function installOpenCodeCapturePlugin() {
1183
984
  ` const transcriptPath = transcriptPaths.get(id);\n` +
1184
985
  ` if (transcriptPath) dispatch(FINISH, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1185
986
  ` transcriptPaths.delete(id);\n` +
987
+ ` sessionDirectories.delete(id);\n` +
1186
988
  ` fingerprints.delete(id);\n` +
1187
989
  ` return;\n` +
1188
990
  ` }\n` +
@@ -1206,6 +1008,7 @@ function installOpenCodeCapturePlugin() {
1206
1008
  ` transcriptPaths.set(id, transcriptPath);\n` +
1207
1009
  ` fingerprints.set(id, fingerprint);\n` +
1208
1010
  ` dispatch(CAPTURE, { session_id: id, transcript_path: transcriptPath, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1011
+ ` dispatch(BACKUP, { session_id: id, cwd, workspace_roots: [cwd], launch: LAUNCH });\n` +
1209
1012
  ` } catch {}\n` +
1210
1013
  ` },\n` +
1211
1014
  ` });\n` +
@@ -1214,6 +1017,16 @@ function installOpenCodeCapturePlugin() {
1214
1017
  fs.writeFileSync(OPENCODE_PLUGIN_PATH, source, { encoding: "utf8", mode: 0o600 });
1215
1018
  fs.chmodSync(OPENCODE_PLUGIN_PATH, 0o600);
1216
1019
  }
1020
+ function ensureOpenCodePluginCurrent() {
1021
+ try {
1022
+ if (!fs.existsSync(OPENCODE_PLUGIN_PATH))
1023
+ return;
1024
+ if (!fs.readFileSync(OPENCODE_PLUGIN_PATH, "utf8").includes("SECURITY_PRE"))
1025
+ return;
1026
+ installOpenCodeCapturePlugin();
1027
+ }
1028
+ catch { }
1029
+ }
1217
1030
  function piInstalled() {
1218
1031
  if (fs.existsSync((0, harnessDirs_1.piAgentDir)()))
1219
1032
  return true;
@@ -1225,29 +1038,17 @@ function piInstalled() {
1225
1038
  return false;
1226
1039
  }
1227
1040
  }
1228
- /** Subcommands the pi extension calls, in the order inspectPiExtension checks. */
1229
1041
  exports.PI_EXTENSION_SUBCOMMANDS = [
1230
1042
  "session-start-pi", "backup-pi", "capture-pi-final", "discover-pi",
1231
- "security-pre-pi", "security-post-pi", "security-failure-pi",
1232
1043
  ];
1233
- /**
1234
- * pi has no hook config: it auto-loads TypeScript extensions from
1235
- * <agentDir>/extensions/. This one forwards pi's events to the same hook
1236
- * subcommands as every other harness. pi already writes the session JSONL, so
1237
- * capture just hands its path over (no transcript dump like OpenCode's).
1238
- */
1239
- function piExtensionSource(agentSecurityEnabled) {
1240
- const [start, backup, finish, prompt, securityPre, securityPost, securityFailure] = exports.PI_EXTENSION_SUBCOMMANDS.map((subcommand) => resolveHookInvocation(subcommand));
1044
+ function piExtensionSource() {
1045
+ const [start, backup, finish, prompt] = exports.PI_EXTENSION_SUBCOMMANDS.map((subcommand) => resolveHookInvocation(subcommand));
1241
1046
  return `// Managed by codiedev connect. Safe to remove; \`codiedev connect\` reinstalls it.\n` +
1242
1047
  `import { spawn, spawnSync } from "node:child_process";\n\n` +
1243
1048
  `const START = ${JSON.stringify(start)};\n` +
1244
1049
  `const FINISH = ${JSON.stringify(finish)};\n` +
1245
1050
  `const BACKUP = ${JSON.stringify(backup)};\n` +
1246
1051
  `const PROMPT = ${JSON.stringify(prompt)};\n` +
1247
- `const SECURITY_PRE = ${JSON.stringify(securityPre)};\n` +
1248
- `const SECURITY_POST = ${JSON.stringify(securityPost)};\n` +
1249
- `const SECURITY_FAILURE = ${JSON.stringify(securityFailure)};\n` +
1250
- `const AGENT_SECURITY_ENABLED = ${JSON.stringify(agentSecurityEnabled)};\n` +
1251
1052
  `const CONTEXT_TYPE = ${JSON.stringify(piHooks_1.PI_CONTEXT_CUSTOM_TYPE)};\n` +
1252
1053
  `const piLaunchFromArgv = ${piHooks_1.piLaunchFromArgv.toString()};\n` +
1253
1054
  `const LAUNCH = piLaunchFromArgv(process.argv.slice(2));\n\n` +
@@ -1268,14 +1069,6 @@ function piExtensionSource(agentSecurityEnabled) {
1268
1069
  ` return JSON.parse(result.stdout || "{}");\n` +
1269
1070
  ` } catch { return null; }\n` +
1270
1071
  `}\n\n` +
1271
- `function blockReason(decision) {\n` +
1272
- ` const denied = decision?.decision === "block" || decision?.hookSpecificOutput?.permissionDecision === "deny";\n` +
1273
- ` return denied ? (decision?.reason || decision?.hookSpecificOutput?.permissionDecisionReason || "Codie security policy blocked this action") : null;\n` +
1274
- `}\n\n` +
1275
- `function warn(ctx, reason) {\n` +
1276
- ` if (ctx?.hasUI) { try { ctx.ui.notify("Codie Security: " + reason, "error"); return; } catch {} }\n` +
1277
- ` process.stderr.write("\\nCodie Security blocked this action:\\n" + reason + "\\n");\n` +
1278
- `}\n\n` +
1279
1072
  `export default function codiedev(pi) {\n` +
1280
1073
  ` let session = null;\n` +
1281
1074
  ` let pendingContext = null;\n` +
@@ -1317,8 +1110,6 @@ function piExtensionSource(agentSecurityEnabled) {
1317
1110
  ` if (event?.source === "extension") return { action: "continue" };\n` +
1318
1111
  ` const s = current(ctx);\n` +
1319
1112
  ` const decision = invokeSync(PROMPT, { session_id: s.id, cwd: s.cwd, prompt: event?.text ?? "" });\n` +
1320
- ` const reason = blockReason(decision);\n` +
1321
- ` if (reason) { warn(ctx, reason); return { action: "handled" }; }\n` +
1322
1113
  ` pendingContext = decision?.hookSpecificOutput?.additionalContext || null;\n` +
1323
1114
  ` return { action: "continue" };\n` +
1324
1115
  ` });\n\n` +
@@ -1328,17 +1119,6 @@ function piExtensionSource(agentSecurityEnabled) {
1328
1119
  ` pendingContext = null;\n` +
1329
1120
  ` return { message: { customType: CONTEXT_TYPE, content, display: false } };\n` +
1330
1121
  ` });\n\n` +
1331
- ` pi.on("tool_call", async (event, ctx) => {\n` +
1332
- ` if (!AGENT_SECURITY_ENABLED) return;\n` +
1333
- ` const s = current(ctx);\n` +
1334
- ` const reason = blockReason(invokeSync(SECURITY_PRE, { sessionId: s.id, cwd: s.cwd, toolName: event.toolName, toolUseId: event.toolCallId, toolInput: event.input }));\n` +
1335
- ` if (reason) { warn(ctx, reason); return { block: true, reason }; }\n` +
1336
- ` });\n\n` +
1337
- ` pi.on("tool_result", async (event, ctx) => {\n` +
1338
- ` if (!AGENT_SECURITY_ENABLED) return;\n` +
1339
- ` const s = current(ctx);\n` +
1340
- ` dispatch(event.isError ? SECURITY_FAILURE : SECURITY_POST, { sessionId: s.id, cwd: s.cwd, toolName: event.toolName, toolUseId: event.toolCallId, toolInput: event.input, toolResponse: { content: event.content, isError: event.isError } });\n` +
1341
- ` });\n\n` +
1342
1122
  ` pi.on("agent_end", async (_event, ctx) => {\n` +
1343
1123
  ` const s = current(ctx);\n` +
1344
1124
  ` if (!s.id || !s.file) return;\n` +
@@ -1355,31 +1135,22 @@ function piExtensionSource(agentSecurityEnabled) {
1355
1135
  ` });\n` +
1356
1136
  `}\n`;
1357
1137
  }
1358
- /** Install the pi extension. Unconditional like OpenCode's: pi installed
1359
- * later still gets captured without re-running `codiedev connect`. This
1360
- * release line has no Agent Security hooks, so security stays off. */
1361
- function installPiExtension(agentSecurityEnabled = false) {
1138
+ function installPiExtension() {
1362
1139
  const target = (0, piHooks_1.piExtensionPath)();
1363
1140
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
1364
1141
  const tmp = `${target}.${process.pid}.tmp`;
1365
- fs.writeFileSync(tmp, piExtensionSource(agentSecurityEnabled), { encoding: "utf8", mode: 0o600 });
1142
+ fs.writeFileSync(tmp, piExtensionSource(), { encoding: "utf8", mode: 0o600 });
1366
1143
  fs.renameSync(tmp, target);
1367
1144
  }
1368
- /** Self-heal after an upgrade or drift, like Copilot's hook file. */
1369
- function ensurePiExtensionInstalled(agentSecurityEnabled = false) {
1145
+ function ensurePiExtensionInstalled() {
1370
1146
  try {
1371
1147
  const file = (0, piHooks_1.piExtensionPath)();
1372
- if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === piExtensionSource(agentSecurityEnabled))
1148
+ if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === piExtensionSource())
1373
1149
  return;
1374
- installPiExtension(agentSecurityEnabled);
1150
+ installPiExtension();
1375
1151
  }
1376
- catch { /* best-effort */ }
1152
+ catch { }
1377
1153
  }
1378
- /**
1379
- * VS Code with the GitHub Copilot Chat extension installed. The extension
1380
- * directory pattern is `~/.vscode/extensions/github.copilot-chat-*`. We treat
1381
- * the presence of any `github.copilot*` directory as the Copilot fingerprint.
1382
- */
1383
1154
  function vscodeCopilotInstalled() {
1384
1155
  if (!fs.existsSync(VSCODE_EXTENSIONS_DIR))
1385
1156
  return false;
@@ -1392,19 +1163,10 @@ function vscodeCopilotInstalled() {
1392
1163
  return false;
1393
1164
  }
1394
1165
  }
1395
- // Idempotently put a single codiedev hook (matcher ".*" by default, running our hook.js
1396
- // with `subcommand`) into the given hook event, stripping any prior codiedev
1397
- // entries first so connect re-runs / upgrades refresh the resolved binary path.
1398
- // Mutates `hooks` in place. Exported for unit testing.
1399
1166
  function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
1400
1167
  if (!hooks[eventName])
1401
1168
  hooks[eventName] = [];
1402
1169
  const existing = hooks[eventName];
1403
- // Before stripping the old codiedev entry, remember the node path it used so a
1404
- // re-run/upgrade can honor a stable symlink the user pinned instead of
1405
- // overwriting it with the version-specific `process.execPath` (see
1406
- // resolveHookNodePath). Done in an explicit scan rather than as a side effect
1407
- // of the filter predicate below, which stays a pure predicate.
1408
1170
  let preservedNodePath;
1409
1171
  for (const hook of existing) {
1410
1172
  const inner = hook.hooks;
@@ -1429,6 +1191,35 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
1429
1191
  });
1430
1192
  hooks[eventName] = filtered;
1431
1193
  }
1194
+ function removeCodiedevHook(hooks, eventName) {
1195
+ const entries = hooks[eventName];
1196
+ if (!Array.isArray(entries))
1197
+ return false;
1198
+ let changed = false;
1199
+ const filtered = entries.flatMap((entry) => {
1200
+ const record = entry;
1201
+ const inner = record.hooks;
1202
+ if (!Array.isArray(inner))
1203
+ return [record];
1204
+ const remaining = inner.filter((hook) => {
1205
+ const remove = (0, detection_1.isCodiedevHookCommand)(hook.command);
1206
+ if (remove)
1207
+ changed = true;
1208
+ return !remove;
1209
+ });
1210
+ if (remaining.length === 0)
1211
+ return [];
1212
+ return [{ ...record, hooks: remaining }];
1213
+ });
1214
+ if (!changed)
1215
+ return false;
1216
+ if (filtered.length === 0)
1217
+ delete hooks[eventName];
1218
+ else
1219
+ hooks[eventName] = filtered;
1220
+ return true;
1221
+ }
1222
+ const RETIRED_SECURITY_TOOL_EVENTS = ["PreToolUse", "PostToolUse", "PostToolUseFailure"];
1432
1223
  function installHook() {
1433
1224
  let settings = {};
1434
1225
  try {
@@ -1438,36 +1229,27 @@ function installHook() {
1438
1229
  }
1439
1230
  }
1440
1231
  catch {
1441
- // Start with empty settings if file doesn't exist or is invalid
1442
1232
  }
1443
1233
  if (!settings.hooks) {
1444
1234
  settings.hooks = {};
1445
1235
  }
1446
1236
  const hooks = settings.hooks;
1447
- // SessionEnd → capture (upload the finished session for the team library).
1448
1237
  upsertCodiedevHook(hooks, "SessionEnd", "capture");
1449
- // UserPromptSubmit → discover (surface a matching team skill for the task).
1238
+ for (const eventName of RETIRED_SECURITY_TOOL_EVENTS)
1239
+ removeCodiedevHook(hooks, eventName);
1450
1240
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover");
1451
- // Stop → backup (auto-back-up changed artifact files to your private library).
1452
1241
  upsertCodiedevHook(hooks, "Stop", "backup");
1453
- // StopFailure (matcher rate_limit) → limit (post rate-limit event to the team).
1454
1242
  upsertCodiedevHook(hooks, "StopFailure", "limit", "rate_limit");
1455
- // SessionStart → session-start (capture-coverage denominator: record that a
1456
- // session opened, independent of whether it later captures).
1457
1243
  upsertCodiedevHook(hooks, "SessionStart", "session-start");
1458
1244
  if (!fs.existsSync(CLAUDE_DIR)) {
1459
1245
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
1460
1246
  }
1461
1247
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1462
1248
  }
1463
- // Self-heal: ensure the discovery (UserPromptSubmit) hook is present without a
1464
- // full `connect` re-run. Called from the capture hook on SessionEnd, so a user
1465
- // who upgrades the CLI gets the new hook wired on their next session. Only
1466
- // writes when missing, to avoid churning settings.json every run.
1467
1249
  function ensureDiscoveryHookInstalled() {
1468
1250
  try {
1469
1251
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
1470
- return; // not a Claude Code user
1252
+ return;
1471
1253
  const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8");
1472
1254
  const settings = JSON.parse(raw);
1473
1255
  const hooks = (settings.hooks ?? {});
@@ -1484,13 +1266,8 @@ function ensureDiscoveryHookInstalled() {
1484
1266
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1485
1267
  }
1486
1268
  catch {
1487
- // Best-effort — never block a session on self-heal.
1488
1269
  }
1489
1270
  }
1490
- // Self-heal: ensure the Stop → backup hook is present without a full `connect`
1491
- // re-run, mirroring ensureDiscoveryHookInstalled. Called from the capture hook
1492
- // so an upgraded-but-not-reconnected user gets auto-backup on their next
1493
- // session. Only writes when missing, to avoid churning settings.json every run.
1494
1271
  function ensureBackupHookInstalled() {
1495
1272
  try {
1496
1273
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1508,12 +1285,8 @@ function ensureBackupHookInstalled() {
1508
1285
  upsertCodiedevHook(hooks, "Stop", "backup");
1509
1286
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1510
1287
  }
1511
- catch { /* best-effort */ }
1288
+ catch { }
1512
1289
  }
1513
- // Self-heal: ensure the StopFailure (matcher rate_limit) → limit hook is
1514
- // present without a full `connect` re-run, mirroring ensureBackupHookInstalled.
1515
- // Called from the capture hook so an upgraded-but-not-reconnected user gets the
1516
- // limit-event hook wired on their next session. Only writes when missing.
1517
1290
  function ensureLimitHookInstalled() {
1518
1291
  try {
1519
1292
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1531,26 +1304,12 @@ function ensureLimitHookInstalled() {
1531
1304
  upsertCodiedevHook(hooks, "StopFailure", "limit", "rate_limit");
1532
1305
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1533
1306
  }
1534
- catch { /* best-effort */ }
1535
- }
1536
- // Self-heal ALL codiedev hooks in one read → one write. This replaces calling
1537
- // the four per-hook ensure* functions in sequence, and fixes two real losses
1538
- // seen in the field (Sonifi, 2026-07):
1539
- // 1. The SessionEnd → capture hook — the one that actually uploads sessions
1540
- // — had NO self-heal at all: it was written once at `connect` and never
1541
- // repaired. Anything that stripped it (IT-managed dotfiles baseline, the
1542
- // race below) left SessionStart pinging forever while captures silently
1543
- // stopped — the exact "start pings arrive, captures never" signature.
1544
- // 2. Each per-hook ensure* did its own read-modify-write of settings.json
1545
- // with no locking; two concurrent Claude Code sessions repairing
1546
- // different hooks could lose each other's writes. One read + one write
1547
- // shrinks that window and removes the intra-process version entirely.
1548
- // The per-hook ensure* functions below are kept for tests/back-compat but are
1549
- // no longer called from the hook entrypoint.
1307
+ catch { }
1308
+ }
1550
1309
  function ensureAllCodiedevHooksInstalled() {
1551
1310
  try {
1552
1311
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
1553
- return; // not a Claude Code user
1312
+ return;
1554
1313
  const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
1555
1314
  const hooks = (settings.hooks ?? {});
1556
1315
  const wanted = [
@@ -1560,9 +1319,13 @@ function ensureAllCodiedevHooksInstalled() {
1560
1319
  ["StopFailure", "limit", "rate_limit"],
1561
1320
  ["SessionStart", "session-start"],
1562
1321
  ];
1322
+ let changed = false;
1323
+ for (const eventName of RETIRED_SECURITY_TOOL_EVENTS) {
1324
+ changed = removeCodiedevHook(hooks, eventName) || changed;
1325
+ }
1563
1326
  const missing = wanted.filter(([eventName]) => !hasCodiedevHook(hooks, eventName));
1564
- if (missing.length === 0)
1565
- return; // steady state: no write, no churn
1327
+ if (missing.length === 0 && !changed)
1328
+ return;
1566
1329
  settings.hooks = hooks;
1567
1330
  for (const [eventName, subcommand, matcher] of missing) {
1568
1331
  upsertCodiedevHook(hooks, eventName, subcommand, matcher);
@@ -1570,33 +1333,23 @@ function ensureAllCodiedevHooksInstalled() {
1570
1333
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1571
1334
  }
1572
1335
  catch {
1573
- // Best-effort — never block a session on self-heal.
1574
1336
  }
1575
1337
  }
1576
- // Matches a hook config that bakes an install's own dist/hook.js rather than
1577
- // the stable launcher (see launcher.ts). Runs on raw file text, where JSON
1578
- // doubles Windows separators (codiedev\\dist\\hook.js), hence `+`.
1579
1338
  const DIRECT_HOOK_PATH = /codiedev[^"'\s/\\]*[\\/]+dist[\\/]+hook\.js/;
1580
- /** Exported for unit testing. */
1581
1339
  function hasDirectHookPath(configText) {
1582
1340
  return DIRECT_HOOK_PATH.test(configText);
1583
1341
  }
1584
- // Move configs written before the launcher existed onto it, by re-running each
1585
- // harness's own installer (which, for Codex, also re-trusts the new commands).
1586
- // Only harnesses that already carry a direct-path codiedev hook are touched,
1587
- // each in isolation. Bails when the launcher can't be written, so a read-only
1588
- // state dir doesn't rewrite every config on every hook call.
1589
1342
  function migrateHooksToLauncher() {
1590
1343
  const probe = resolveHookInvocation("session-start");
1591
1344
  if (!(0, launcher_1.usesLauncher)(probe.args[0]))
1592
1345
  return [];
1593
1346
  const targets = [
1594
- ["claude-code", CLAUDE_SETTINGS_PATH, installHook],
1595
- ["codex", CODEX_HOOKS_PATH, installCodexHook],
1596
- ["cursor", CURSOR_HOOKS_PATH, installCursorHook],
1597
- ["copilot", copilotHooksPath(), installCopilotHook],
1598
- ["opencode", OPENCODE_PLUGIN_PATH, installOpenCodeCapturePlugin],
1599
- ["pi", (0, piHooks_1.piExtensionPath)(), installPiExtension],
1347
+ ["claude-code", CLAUDE_SETTINGS_PATH, () => installHook()],
1348
+ ["codex", CODEX_HOOKS_PATH, () => installCodexHook()],
1349
+ ["cursor", CURSOR_HOOKS_PATH, () => installCursorHook()],
1350
+ ["copilot", copilotHooksPath(), () => installCopilotHook()],
1351
+ ["opencode", OPENCODE_PLUGIN_PATH, () => installOpenCodeCapturePlugin()],
1352
+ ["pi", (0, piHooks_1.piExtensionPath)(), () => installPiExtension()],
1600
1353
  ];
1601
1354
  const migrated = [];
1602
1355
  for (const [name, file, install] of targets) {
@@ -1607,18 +1360,12 @@ function migrateHooksToLauncher() {
1607
1360
  migrated.push(name);
1608
1361
  }
1609
1362
  catch {
1610
- // Best-effort per harness; the next direct-path hook call retries.
1611
1363
  }
1612
1364
  }
1613
1365
  return migrated;
1614
1366
  }
1615
1367
  function hasCodiedevHook(hooks, eventName) {
1616
1368
  const entries = hooks[eventName] ?? [];
1617
- // A malformed (non-array) event value would make `.some` throw and — under
1618
- // ensureAll's single try/catch — abort EVERY heal, including the capture
1619
- // heal. Report "present" instead: leave the foreign/corrupt key alone and
1620
- // keep healing the other events (the per-hook fault isolation the old
1621
- // ensure* functions had).
1622
1369
  if (!Array.isArray(entries))
1623
1370
  return true;
1624
1371
  return entries.some((hook) => {
@@ -1627,10 +1374,6 @@ function hasCodiedevHook(hooks, eventName) {
1627
1374
  inner.some((h) => (0, detection_1.isCodiedevHookCommand)(h.command)));
1628
1375
  });
1629
1376
  }
1630
- // Self-heal: ensure the SessionStart → session-start hook is present without a
1631
- // full `connect` re-run, mirroring ensureLimitHookInstalled. Called from the
1632
- // capture hook so an upgraded-but-not-reconnected user starts emitting the
1633
- // capture-coverage denominator on their next session. Only writes when missing.
1634
1377
  function ensureSessionStartHookInstalled() {
1635
1378
  try {
1636
1379
  if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
@@ -1648,12 +1391,8 @@ function ensureSessionStartHookInstalled() {
1648
1391
  upsertCodiedevHook(hooks, "SessionStart", "session-start");
1649
1392
  fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
1650
1393
  }
1651
- catch { /* best-effort */ }
1394
+ catch { }
1652
1395
  }
1653
- // ─────────────────────────────────────────────────────────────────────────────
1654
- // MCP server installation — writes the codiedev MCP server config into each
1655
- // agent CLI's expected location so "npx codiedev connect" wires it up once.
1656
- // ─────────────────────────────────────────────────────────────────────────────
1657
1396
  const CLAUDE_USER_CONFIG_PATH = (0, harnessDirs_1.claudeUserConfigPath)();
1658
1397
  const CLAUDE_USER_INSTRUCTIONS_PATH = path.join((0, harnessDirs_1.claudeConfigDir)(), "CLAUDE.md");
1659
1398
  const CODEX_USER_INSTRUCTIONS_PATH = path.join((0, harnessDirs_1.codexHome)(), "AGENTS.md");
@@ -1668,6 +1407,11 @@ decisions, and messages via a CLI on every dev's machine.
1668
1407
  **When the user asks to share, push, ping, check messages, or capture a
1669
1408
  thought, use the \`codiedev\` CLI via Bash:**
1670
1409
 
1410
+ If \`codiedev\` is not on PATH, run the same subcommand with
1411
+ \`npx codiedev@latest\` instead (for example,
1412
+ \`npx codiedev@latest inbox\`). The \`connect\` command installs hooks
1413
+ but does not install a global \`codiedev\` executable.
1414
+
1671
1415
  | Intent | Command |
1672
1416
  |---|---|
1673
1417
  | "push this spec" / "save this as a skill" | \`codiedev push <file.md>\` |
@@ -1736,12 +1480,6 @@ Tell the user the portal URL when the submit returns.
1736
1480
  API token from https://codiedev.com/portal/integrations/claude-code
1737
1481
  - "ambiguous recipient" → surface candidates to the user
1738
1482
  `;
1739
- /**
1740
- * Write the codiedev agent-facing docs block to the user-level Claude
1741
- * instructions file so Claude Code picks up the commands in every session
1742
- * (no per-repo CLAUDE.md needed). Idempotent — replaces the existing block
1743
- * between its begin/end markers on re-run.
1744
- */
1745
1483
  function writeInstructionsBlock(targetPath, body = CODIEDEV_INSTRUCTIONS_BODY) {
1746
1484
  const dir = path.dirname(targetPath);
1747
1485
  if (!fs.existsSync(dir)) {
@@ -1758,14 +1496,12 @@ function writeInstructionsBlock(targetPath, body = CODIEDEV_INSTRUCTIONS_BODY) {
1758
1496
  const endIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_END);
1759
1497
  let next;
1760
1498
  if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
1761
- // Replace the existing block in place.
1762
1499
  next =
1763
1500
  existing.slice(0, beginIdx) +
1764
1501
  block +
1765
1502
  existing.slice(endIdx + CODIEDEV_INSTRUCTIONS_END.length);
1766
1503
  }
1767
1504
  else if (existing.trim()) {
1768
- // Append with a blank-line separator.
1769
1505
  next = existing.replace(/\n*$/, "\n\n") + block;
1770
1506
  }
1771
1507
  else {
@@ -1782,20 +1518,15 @@ function installCodexInstructions() {
1782
1518
  function copilotInstructionsPath() {
1783
1519
  return path.join((0, copilotHooks_1.copilotHome)(), "copilot-instructions.md");
1784
1520
  }
1785
- /** pi's global context file, loaded into every session's system prompt. */
1786
1521
  function piInstructionsPath() {
1787
1522
  return path.join((0, harnessDirs_1.piAgentDir)(), "AGENTS.md");
1788
1523
  }
1789
- /** pi has no session-start output channel, so team skill context rides in
1790
- * AGENTS.md the way it does for Copilot. */
1791
1524
  function installPiInstructions() {
1792
1525
  writeInstructionsBlock(piInstructionsPath(), `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}`);
1793
1526
  }
1794
1527
  function installCopilotInstructions() {
1795
1528
  writeInstructionsBlock(copilotInstructionsPath(), `${teamSkillContext_1.TEAM_SKILL_CONTEXT}\n\n${CODIEDEV_INSTRUCTIONS_BODY}`);
1796
1529
  }
1797
- // Cursor uses .mdc rule files with YAML frontmatter (description, alwaysApply,
1798
- // globs). `.cursorrules` is deprecated and ignored by Agent mode as of 2026.
1799
1530
  const CURSOR_INSTRUCTIONS_FRONTMATTER = `---
1800
1531
  description: Codiedev team artifact layer — push, pull, search, ping, and share artifacts with teammates
1801
1532
  alwaysApply: true
@@ -1817,8 +1548,6 @@ function installCursorInstructions() {
1817
1548
  if (fs.existsSync(CURSOR_RULES_PATH)) {
1818
1549
  existing = fs.readFileSync(CURSOR_RULES_PATH, "utf8");
1819
1550
  }
1820
- // If the file already has our markers, replace just the block — preserve
1821
- // any frontmatter edits the user made.
1822
1551
  const beginIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_BEGIN);
1823
1552
  const endIdx = existing.indexOf(CODIEDEV_INSTRUCTIONS_END);
1824
1553
  let next;
@@ -1847,13 +1576,6 @@ function installedInstructionsTargets() {
1847
1576
  { path: piInstructionsPath(), body: withTeamContext },
1848
1577
  ];
1849
1578
  }
1850
- /**
1851
- * Re-render the codiedev block in every instructions file that already has
1852
- * one, so an upgrade (background self-update or a manual `npm i`) stops
1853
- * teaching retired commands without the user re-running `connect`. Never
1854
- * creates a file or adds a block: a user who deleted it, or never connected
1855
- * that harness, is left alone. Returns the paths it rewrote.
1856
- */
1857
1579
  function refreshInstalledInstructions(targets = installedInstructionsTargets()) {
1858
1580
  const refreshed = [];
1859
1581
  for (const target of targets) {
@@ -1872,7 +1594,6 @@ function refreshInstalledInstructions(targets = installedInstructionsTargets())
1872
1594
  refreshed.push(target.path);
1873
1595
  }
1874
1596
  catch {
1875
- // Missing or unreadable file: nothing of ours to refresh.
1876
1597
  }
1877
1598
  }
1878
1599
  return refreshed;
@@ -1893,10 +1614,6 @@ function readMcpConfigSafely(path) {
1893
1614
  return {};
1894
1615
  }
1895
1616
  }
1896
- /**
1897
- * Install the Codiedev MCP server into Claude Code's user-scope config.
1898
- * Safe to call multiple times — updates the existing entry if present.
1899
- */
1900
1617
  function installClaudeCodeMcp() {
1901
1618
  const config = readMcpConfigSafely(CLAUDE_USER_CONFIG_PATH);
1902
1619
  const mcpServers = config.mcpServers ?? {};
@@ -1909,12 +1626,6 @@ function installClaudeCodeMcp() {
1909
1626
  config.mcpServers = mcpServers;
1910
1627
  fs.writeFileSync(CLAUDE_USER_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
1911
1628
  }
1912
- /**
1913
- * Install the Codiedev MCP server into Cursor's user-scope config.
1914
- * Cursor uses the same `mcpServers` schema as Claude Code; just a different
1915
- * file path (~/.cursor/mcp.json). Project-scoped servers can also live at
1916
- * <project>/.cursor/mcp.json — we only manage the user-scope one.
1917
- */
1918
1629
  function installCursorMcp() {
1919
1630
  if (!fs.existsSync(CURSOR_DIR)) {
1920
1631
  fs.mkdirSync(CURSOR_DIR, { recursive: true });
@@ -1930,13 +1641,6 @@ function installCursorMcp() {
1930
1641
  config.mcpServers = mcpServers;
1931
1642
  fs.writeFileSync(CURSOR_MCP_PATH, JSON.stringify(config, null, 2), "utf8");
1932
1643
  }
1933
- /**
1934
- * Install the Codiedev MCP server into VS Code's user-scope MCP config so
1935
- * GitHub Copilot Chat picks it up. VS Code uses a different schema than
1936
- * Claude / Cursor: top-level key is `servers` (not `mcpServers`). On first
1937
- * Copilot Chat run after install, VS Code prompts the user to trust the
1938
- * server — that's expected and documented in the connect success message.
1939
- */
1940
1644
  function installVSCodeMcp() {
1941
1645
  if (!fs.existsSync(VSCODE_USER_DIR)) {
1942
1646
  fs.mkdirSync(VSCODE_USER_DIR, { recursive: true });
@@ -1953,14 +1657,6 @@ function installVSCodeMcp() {
1953
1657
  config.servers = servers;
1954
1658
  fs.writeFileSync(VSCODE_USER_MCP_PATH, JSON.stringify(config, null, 2), "utf8");
1955
1659
  }
1956
- // ─────────────────────────────────────────────────────────────────────────────
1957
- // Migration cleanup — strips MCP server entries written by pre-CLI-only
1958
- // versions of `codiedev connect`. Those entries pointed at `npx codiedev-mcp`,
1959
- // a binary that was removed from package.json in 0.3.4. Without these
1960
- // cleanups, customers who upgrade to 0.7.11+ keep seeing "MCP server failed
1961
- // to start" errors in their agent UIs even though the CLI works fine.
1962
- // All cleanup helpers are idempotent and safe to run when no entry exists.
1963
- // ─────────────────────────────────────────────────────────────────────────────
1964
1660
  function removeJsonKey(filePath, parentKey) {
1965
1661
  if (!fs.existsSync(filePath))
1966
1662
  return;
@@ -1969,7 +1665,7 @@ function removeJsonKey(filePath, parentKey) {
1969
1665
  parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
1970
1666
  }
1971
1667
  catch {
1972
- return; // Don't damage a file we can't parse.
1668
+ return;
1973
1669
  }
1974
1670
  const parent = parsed[parentKey];
1975
1671
  if (!parent || typeof parent !== "object")
@@ -1979,33 +1675,15 @@ function removeJsonKey(filePath, parentKey) {
1979
1675
  delete parent.codiedev;
1980
1676
  fs.writeFileSync(filePath, JSON.stringify(parsed, null, 2), "utf8");
1981
1677
  }
1982
- /**
1983
- * Removes `mcpServers.codiedev` from `~/.claude.json` if present. Leaves
1984
- * other MCP servers and unrelated top-level settings untouched.
1985
- */
1986
1678
  function cleanupClaudeCodeMcp() {
1987
1679
  removeJsonKey(CLAUDE_USER_CONFIG_PATH, "mcpServers");
1988
1680
  }
1989
- /**
1990
- * Removes `mcpServers.codiedev` from `~/.cursor/mcp.json` if present.
1991
- */
1992
1681
  function cleanupCursorMcp() {
1993
1682
  removeJsonKey(CURSOR_MCP_PATH, "mcpServers");
1994
1683
  }
1995
- /**
1996
- * Removes `servers.codiedev` from VS Code's user-scope mcp.json. VS Code
1997
- * uses a different schema (`servers` not `mcpServers`), so this is a
1998
- * separate helper.
1999
- */
2000
1684
  function cleanupVSCodeMcp() {
2001
1685
  removeJsonKey(VSCODE_USER_MCP_PATH, "servers");
2002
1686
  }
2003
- /**
2004
- * Removes the `# codiedev-mcp` block from `~/.codex/config.toml`. The
2005
- * install side appends a well-marked block; we strip it the same way —
2006
- * the marker line through the end of its TOML section. Whitespace-only
2007
- * gaps between sections are preserved.
2008
- */
2009
1687
  function cleanupCodexMcp() {
2010
1688
  if (!fs.existsSync(CODEX_CONFIG_PATH))
2011
1689
  return;
@@ -2022,31 +1700,22 @@ function cleanupCodexMcp() {
2022
1700
  continue;
2023
1701
  }
2024
1702
  if (skipping) {
2025
- // Stay in skip mode through the [mcp_servers.codiedev] block until
2026
- // we hit a blank line or a different TOML section header.
2027
1703
  const trimmed = line.trim();
2028
1704
  if (trimmed === "") {
2029
1705
  skipping = false;
2030
- continue; // also drop the blank that immediately follows the block
1706
+ continue;
2031
1707
  }
2032
1708
  if (trimmed.startsWith("[") && !trimmed.startsWith("[mcp_servers.codiedev")) {
2033
1709
  skipping = false;
2034
1710
  out.push(line);
2035
1711
  continue;
2036
1712
  }
2037
- // Skip lines belonging to the codiedev block.
2038
1713
  continue;
2039
1714
  }
2040
1715
  out.push(line);
2041
1716
  }
2042
1717
  fs.writeFileSync(CODEX_CONFIG_PATH, out.join("\n"), "utf8");
2043
1718
  }
2044
- /**
2045
- * Best-effort append of the Codiedev MCP server block to ~/.codex/config.toml.
2046
- *
2047
- * Codex uses TOML and we don't want a full parser for such a small addition,
2048
- * so we append a well-marked block if one isn't already present. Idempotent.
2049
- */
2050
1719
  function installCodexMcp() {
2051
1720
  if (!fs.existsSync(CODEX_DIR)) {
2052
1721
  fs.mkdirSync(CODEX_DIR, { recursive: true });
@@ -2069,9 +1738,6 @@ function installCodexMcp() {
2069
1738
  fs.writeFileSync(CODEX_CONFIG_PATH, existing + block, "utf8");
2070
1739
  return true;
2071
1740
  }
2072
- // Codex doesn't have a SessionEnd event yet — Stop fires after every turn.
2073
- // The backend dedupes on (companyId, sessionId) and replaces the stored
2074
- // transcript on re-upload, so the latest turn's snapshot wins.
2075
1741
  function installCodexHook() {
2076
1742
  let hooksFile = {};
2077
1743
  try {
@@ -2081,15 +1747,13 @@ function installCodexHook() {
2081
1747
  }
2082
1748
  }
2083
1749
  catch {
2084
- // Start fresh if missing or invalid
2085
1750
  }
2086
1751
  if (!hooksFile.hooks) {
2087
1752
  hooksFile.hooks = {};
2088
1753
  }
2089
1754
  const hooks = hooksFile.hooks;
2090
- if (!hooks.Stop) {
1755
+ if (!hooks.Stop)
2091
1756
  hooks.Stop = [];
2092
- }
2093
1757
  const existing = hooks.Stop;
2094
1758
  const filtered = existing.filter((hook) => {
2095
1759
  const inner = hook.hooks;
@@ -2115,7 +1779,6 @@ function installCodexHook() {
2115
1779
  },
2116
1780
  ],
2117
1781
  });
2118
- // Stop → backup: auto-back-up changed artifact files to the private library.
2119
1782
  filtered.push({
2120
1783
  hooks: [
2121
1784
  {
@@ -2126,8 +1789,8 @@ function installCodexHook() {
2126
1789
  ],
2127
1790
  });
2128
1791
  hooks.Stop = filtered;
2129
- // Codex models UserPromptSubmit as a non-matcher event (the trust-key model
2130
- // intentionally excludes it from MATCHER_EVENTS), so omit the matcher key.
1792
+ for (const eventName of RETIRED_SECURITY_TOOL_EVENTS)
1793
+ removeCodiedevHook(hooks, eventName);
2131
1794
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover-codex", null);
2132
1795
  if (!fs.existsSync(CODEX_DIR)) {
2133
1796
  fs.mkdirSync(CODEX_DIR, { recursive: true });
@@ -2135,8 +1798,6 @@ function installCodexHook() {
2135
1798
  fs.writeFileSync(CODEX_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
2136
1799
  (0, codexHookTrust_1.writeCodexHookTrust)(CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, hooksFile);
2137
1800
  }
2138
- // Existing Codex users already have capture-codex on Stop. After a CLI
2139
- // upgrade, that hook repairs newer lifecycle hooks without requiring connect.
2140
1801
  function ensureCodexLimitHookInstalled() {
2141
1802
  try {
2142
1803
  if (!fs.existsSync(CODEX_HOOKS_PATH))
@@ -2153,6 +1814,9 @@ function ensureCodexLimitHookInstalled() {
2153
1814
  return Array.isArray(inner) && inner.some((hook) => typeof hook.command === "string" && hook.command.trim().endsWith(subcommand));
2154
1815
  });
2155
1816
  let changed = false;
1817
+ for (const eventName of RETIRED_SECURITY_TOOL_EVENTS) {
1818
+ changed = removeCodiedevHook(hooks, eventName) || changed;
1819
+ }
2156
1820
  if (!hasCommand(stop, "limit-codex")) {
2157
1821
  stop.unshift({
2158
1822
  hooks: [{ type: "command", command: resolveHookCommand("limit-codex"), timeout: 10 }],
@@ -2171,18 +1835,11 @@ function ensureCodexLimitHookInstalled() {
2171
1835
  return;
2172
1836
  hooksFile.hooks = hooks;
2173
1837
  fs.writeFileSync(CODEX_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
2174
- // Trust keys include each hook's positional index. Adding the new hook at
2175
- // the front shifts capture/backup, so refresh every Codiedev hash together.
2176
1838
  (0, codexHookTrust_1.writeCodexHookTrust)(CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, hooksFile);
2177
1839
  }
2178
1840
  catch {
2179
- // Best-effort: capture must never fail because hook repair did.
2180
1841
  }
2181
1842
  }
2182
- // Cursor's hooks.json uses { version: 1, hooks: { sessionEnd: [{ command, type, timeout }] } }.
2183
- // `sessionEnd` fires when a conversation closes (completed, aborted, error,
2184
- // window_close, user_close); the base stdin payload includes transcript_path
2185
- // and session_id which `codiedev-hook capture-cursor` reads to upload.
2186
1843
  function installCursorHook() {
2187
1844
  if (!fs.existsSync(CURSOR_DIR)) {
2188
1845
  fs.mkdirSync(CURSOR_DIR, { recursive: true });
@@ -2222,34 +1879,28 @@ function installCursorHook() {
2222
1879
  replaceManaged("sessionStart", ["session-start-cursor"]);
2223
1880
  replaceManaged("sessionEnd", ["capture-cursor"]);
2224
1881
  replaceManaged("stop", ["backup-cursor"]);
2225
- for (const event of ["postToolUse", "postToolUseFailure", "subagentStart", "subagentStop"])
2226
- replaceManaged(event, ["observe-cursor"]);
2227
- // Cursor cannot inject dynamic context from beforeSubmitPrompt today.
2228
- // Remove older Codiedev discovery entries so prompts are not uploaded for
2229
- // a response the host will discard; preserve any third-party hooks.
1882
+ replaceManaged("subagentStart", ["observe-cursor"]);
1883
+ replaceManaged("subagentStop", ["observe-cursor"]);
2230
1884
  replaceManaged("beforeSubmitPrompt", []);
1885
+ replaceManaged("preToolUse", []);
1886
+ replaceManaged("postToolUse", ["observe-cursor"]);
1887
+ replaceManaged("postToolUseFailure", ["observe-cursor"]);
2231
1888
  fs.writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
2232
1889
  }
2233
- // GitHub Copilot reads every `$COPILOT_HOME/hooks/*.json`, so Codiedev owns
2234
- // one file and never edits anyone else's. camelCase events only: VS Code also
2235
- // accepts Claude-style PascalCase names from the same file, and configuring
2236
- // both would fire every hook twice.
2237
1890
  function copilotHooksPath() {
2238
1891
  return path.join((0, copilotHooks_1.copilotHome)(), "hooks", "codiedev.json");
2239
1892
  }
2240
- function copilotManagedHooks(agentSecurityEnabled) {
1893
+ function copilotManagedHooks() {
2241
1894
  return [
2242
1895
  ["sessionStart", "session-start-copilot", 10],
2243
1896
  ["userPromptSubmitted", "discover-copilot", 10],
2244
- // agentStop = end of a turn: a non-final snapshot. VS Code sessions live
2245
- // until the chat is closed, so this is what keeps them captured.
2246
1897
  ["agentStop", "backup-copilot", 30],
2247
1898
  ["sessionEnd", "capture-copilot", 30],
2248
1899
  ["errorOccurred", "limit-copilot", 10],
2249
1900
  ];
2250
1901
  }
2251
1902
  const COPILOT_EVENTS = ["sessionStart", "userPromptSubmitted", "agentStop", "sessionEnd", "preToolUse", "postToolUse", "errorOccurred"];
2252
- function installCopilotHook(agentSecurityEnabled = false) {
1903
+ function installCopilotHook() {
2253
1904
  const hooksPath = copilotHooksPath();
2254
1905
  let hooksFile = {};
2255
1906
  try {
@@ -2263,10 +1914,9 @@ function installCopilotHook(agentSecurityEnabled = false) {
2263
1914
  }
2264
1915
  hooksFile.version = 1;
2265
1916
  const hooks = (hooksFile.hooks && typeof hooksFile.hooks === "object" ? hooksFile.hooks : {});
2266
- const managed = copilotManagedHooks(agentSecurityEnabled);
1917
+ const managed = copilotManagedHooks();
2267
1918
  for (const eventName of COPILOT_EVENTS) {
2268
1919
  const existing = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
2269
- // Anything a user added to this file by hand stays.
2270
1920
  const preserved = existing.filter((hook) => !(0, detection_1.isCodiedevHookCommand)((hook.bash ?? hook.powershell ?? hook.command)));
2271
1921
  const ours = managed
2272
1922
  .filter(([event]) => event === eventName)
@@ -2283,9 +1933,7 @@ function installCopilotHook(agentSecurityEnabled = false) {
2283
1933
  fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
2284
1934
  fs.writeFileSync(hooksPath, JSON.stringify(hooksFile, null, 2), "utf8");
2285
1935
  }
2286
- /** Self-heal the Copilot hook file after upgrades or drift (like Cursor's). */
2287
1936
  function ensureCopilotCodiedevHooksInstalled() {
2288
- const agentSecurityEnabled = false; // production line: no security hooks
2289
1937
  try {
2290
1938
  const hooksPath = copilotHooksPath();
2291
1939
  if (!fs.existsSync(hooksPath))
@@ -2295,26 +1943,31 @@ function ensureCopilotCodiedevHooksInstalled() {
2295
1943
  const commandsFor = (eventName) => (Array.isArray(hooks[eventName]) ? hooks[eventName] : [])
2296
1944
  .map((entry) => (entry.bash ?? entry.powershell ?? entry.command))
2297
1945
  .filter((command) => typeof command === "string" && (0, detection_1.isCodiedevHookCommand)(command));
2298
- const required = copilotManagedHooks(agentSecurityEnabled);
1946
+ const required = copilotManagedHooks();
2299
1947
  const complete = required.every(([eventName, subcommand]) => commandsFor(eventName).filter((command) => command.trim().endsWith(subcommand)).length === 1);
2300
1948
  const stale = COPILOT_EVENTS.some((eventName) => commandsFor(eventName).length !== required.filter(([event]) => event === eventName).length);
2301
1949
  if (complete && !stale)
2302
1950
  return;
2303
- installCopilotHook(agentSecurityEnabled);
1951
+ installCopilotHook();
2304
1952
  }
2305
1953
  catch {
2306
- // Best-effort — a malformed hook file must never block capture.
2307
1954
  }
2308
1955
  }
2309
- /** Repair Cursor's complete flat hook set after upgrades or configuration
2310
- * drift. This is deliberately separate from Claude Code's nested-hook healer:
2311
- * the schemas and event names differ, and mixing them risks corrupting both. */
2312
1956
  function ensureCursorCodiedevHooksInstalled() {
2313
1957
  try {
2314
1958
  if (!fs.existsSync(CURSOR_HOOKS_PATH))
2315
1959
  return;
2316
1960
  const parsed = JSON.parse(fs.readFileSync(CURSOR_HOOKS_PATH, "utf8"));
2317
1961
  const hooks = parsed.hooks ?? {};
1962
+ const has = (eventName, subcommand) => {
1963
+ const entries = hooks[eventName];
1964
+ return Array.isArray(entries) && entries.some((entry) => {
1965
+ const command = entry.command;
1966
+ return typeof command === "string"
1967
+ && (0, detection_1.isCodiedevHookCommand)(command)
1968
+ && command.trim().endsWith(subcommand);
1969
+ });
1970
+ };
2318
1971
  const required = [
2319
1972
  ["sessionStart", "session-start-cursor"],
2320
1973
  ["sessionEnd", "capture-cursor"],
@@ -2324,24 +1977,19 @@ function ensureCursorCodiedevHooksInstalled() {
2324
1977
  ["subagentStart", "observe-cursor"],
2325
1978
  ["subagentStop", "observe-cursor"],
2326
1979
  ];
2327
- const has = (eventName, subcommand) => {
1980
+ const hasStaleSecurity = [
1981
+ "beforeSubmitPrompt", "preToolUse", "postToolUse", "postToolUseFailure",
1982
+ ].some((eventName) => {
2328
1983
  const entries = hooks[eventName];
2329
- return Array.isArray(entries) && entries.some((entry) => {
2330
- const command = entry.command;
2331
- return typeof command === "string"
2332
- && (0, detection_1.isCodiedevHookCommand)(command)
2333
- && command.trim().endsWith(subcommand);
2334
- });
2335
- };
2336
- const promptEntries = hooks.beforeSubmitPrompt;
2337
- const hasStalePromptHook = Array.isArray(promptEntries) && promptEntries.some((entry) => (0, detection_1.isCodiedevHookCommand)(entry.command));
2338
- if (!required.every(([eventName, subcommand]) => has(eventName, subcommand)) ||
2339
- hasStalePromptHook) {
2340
- installCursorHook();
1984
+ return Array.isArray(entries) && entries.some((entry) => (0, detection_1.isCodiedevHookCommand)(entry.command) &&
1985
+ !String(entry.command).trim().endsWith("observe-cursor"));
1986
+ });
1987
+ if (required.every(([eventName, subcommand]) => has(eventName, subcommand)) && !hasStaleSecurity) {
1988
+ return;
2341
1989
  }
1990
+ installCursorHook();
2342
1991
  }
2343
1992
  catch {
2344
- // Best-effort repair only.
2345
1993
  }
2346
1994
  }
2347
1995
  function parseClaudeSteering(content) {
@@ -2350,10 +1998,8 @@ function parseClaudeSteering(content) {
2350
1998
  for (const line of content.split("\n")) {
2351
1999
  if (!line.trim())
2352
2000
  continue;
2353
- // "[Request interrupted by user]" / "...for tool use" — Claude Code markers
2354
2001
  if (line.includes("[Request interrupted by user"))
2355
2002
  interrupts++;
2356
- // Standard Claude Code tool-rejection sentinels in tool_result content
2357
2003
  if (line.includes("doesn't want to take this action") ||
2358
2004
  line.includes("user doesn't want to proceed"))
2359
2005
  rejections++;
@@ -2378,7 +2024,6 @@ function parseCodexSteering(content) {
2378
2024
  }
2379
2025
  }
2380
2026
  catch {
2381
- // skip malformed
2382
2027
  }
2383
2028
  }
2384
2029
  return { interrupts, rejections };
@@ -2399,7 +2044,7 @@ function parseClaudeUsage(content) {
2399
2044
  if (typeof value === "string" && value.trim() && !value.startsWith("<"))
2400
2045
  model = value;
2401
2046
  }
2402
- catch { /* partial records */ }
2047
+ catch { }
2403
2048
  }
2404
2049
  const sum = (field) => models.reduce((total, usage) => total + usage[field], 0);
2405
2050
  const cacheReadTokens = sum("cacheReadTokens"), cacheCreationTokens = sum("cacheCreationTokens");
@@ -2407,9 +2052,6 @@ function parseClaudeUsage(content) {
2407
2052
  cacheTokens: cacheReadTokens + cacheCreationTokens, cacheReadTokens, cacheCreationTokens, model };
2408
2053
  return model ? stats : fromClaudeCostState(content, stats);
2409
2054
  }
2410
- // Some Claude Code transcripts carry no assistant records, only a closing
2411
- // `cost-state` whose `modelUsage` is keyed by model id. Without it these
2412
- // sessions upload with no model and trip harness health model_missing.
2413
2055
  function fromClaudeCostState(content, stats) {
2414
2056
  let usage;
2415
2057
  for (const line of content.split("\n")) {
@@ -2420,15 +2062,13 @@ function fromClaudeCostState(content, stats) {
2420
2062
  if (e?.type === "cost-state" && e.modelUsage && typeof e.modelUsage === "object")
2421
2063
  usage = e.modelUsage;
2422
2064
  }
2423
- catch { /* partial records */ }
2065
+ catch { }
2424
2066
  }
2425
2067
  const n = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
2426
2068
  const entries = Object.entries(usage ?? {}).filter(([id, u]) => id.trim() && !id.startsWith("<") && u && typeof u === "object");
2427
2069
  if (!entries.length)
2428
2070
  return stats;
2429
- // A session that switched models names the one that did most of the work.
2430
2071
  const [model] = entries.reduce((best, entry) => n(entry[1].outputTokens) > n(best[1].outputTokens) ? entry : best);
2431
- // Assistant records, when present, already counted tokens; never add twice.
2432
2072
  if (stats.inputTokens || stats.outputTokens || stats.cacheTokens)
2433
2073
  return { ...stats, model };
2434
2074
  const total = (field) => entries.reduce((sum, [, u]) => sum + n(u[field]), 0);
@@ -2437,8 +2077,6 @@ function fromClaudeCostState(content, stats) {
2437
2077
  cacheTokens: cacheReadTokens + cacheCreationTokens, cacheReadTokens, cacheCreationTokens, model };
2438
2078
  }
2439
2079
  function parseCodexUsage(content) {
2440
- // Codex reports a CUMULATIVE token_count per turn — the last one is the
2441
- // session total. `input_tokens` includes the cached portion, so subtract it.
2442
2080
  let model = null;
2443
2081
  let last = null;
2444
2082
  for (const line of content.split("\n")) {
@@ -2456,7 +2094,6 @@ function parseCodexUsage(content) {
2456
2094
  }
2457
2095
  }
2458
2096
  catch {
2459
- // skip malformed
2460
2097
  }
2461
2098
  }
2462
2099
  if (!last) {
@@ -2466,31 +2103,19 @@ function parseCodexUsage(content) {
2466
2103
  const input = last.input_tokens ?? 0;
2467
2104
  return {
2468
2105
  inputTokens: Math.max(0, input - cached),
2469
- // Reasoning is included in output_tokens, not additional output.
2470
2106
  outputTokens: last.output_tokens ?? 0,
2471
- // Codex only reports cached reads; creation is structurally zero.
2472
2107
  cacheTokens: cached,
2473
2108
  cacheReadTokens: cached,
2474
2109
  cacheCreationTokens: 0,
2475
2110
  model,
2476
2111
  };
2477
2112
  }
2478
- // opencode SDK transcripts (the plugin's client.session.messages dump) are a
2479
- // single JSON ARRAY of {info, parts} — not Claude Code's JSONL. Sum assistant
2480
- // token/cost metadata; model = providerID/modelID (matches modelPricing slugs
2481
- // like "openrouter/…" never resolving — stored cost is authoritative here).
2482
2113
  function parseOpencodeUsage(content) {
2483
2114
  let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheCreationTokens = 0, costUsd = 0;
2484
- // A session can switch models (user picks another, or a provider falls back).
2485
- // Attribute it to the model that did the most work rather than whichever
2486
- // spoke last, since this label drives pricing and the model-mix card.
2487
2115
  const outputByModel = new Map();
2488
2116
  let lastModel = null;
2489
2117
  try {
2490
2118
  const parsed = JSON.parse(content);
2491
- // Accept both shapes the SDK can produce: a bare array, or the
2492
- // {data: [...]} envelope normalizeOpenCodeTranscript already unwraps.
2493
- // Disagreeing with that parser would silently zero out a session's usage.
2494
2119
  const list = Array.isArray(parsed)
2495
2120
  ? parsed
2496
2121
  : (parsed && typeof parsed === "object" && Array.isArray(parsed.data)
@@ -2520,14 +2145,10 @@ function parseOpencodeUsage(content) {
2520
2145
  }
2521
2146
  }
2522
2147
  }
2523
- catch { /* malformed transcript — zero-safe */ }
2524
- // Ties (e.g. every turn reporting zero output) fall back to the last model
2525
- // seen, preserving the previous behaviour for single-model sessions.
2148
+ catch { }
2526
2149
  let model = lastModel;
2527
2150
  let best = -1;
2528
2151
  for (const [id, out] of outputByModel) {
2529
- // >= so the later model wins a tie, matching the previous last-wins
2530
- // behaviour when no turn reports output tokens.
2531
2152
  if (out >= best) {
2532
2153
  best = out;
2533
2154
  model = id;
@@ -2543,8 +2164,6 @@ function parseUsage(content, mode) {
2543
2164
  return parseOpencodeUsage(content);
2544
2165
  return mode === "codex" ? parseCodexUsage(content) : parseClaudeUsage(content);
2545
2166
  }
2546
- // Lead-time start proxy: the earliest top-level `timestamp` in the transcript.
2547
- // Both Claude Code and Codex stamp every line, so this is mode-agnostic.
2548
2167
  function parseSessionStart(content) {
2549
2168
  let min = null;
2550
2169
  for (const line of content.split("\n")) {
@@ -2560,7 +2179,6 @@ function parseSessionStart(content) {
2560
2179
  }
2561
2180
  }
2562
2181
  catch {
2563
- // skip malformed
2564
2182
  }
2565
2183
  }
2566
2184
  return min;
@@ -2596,15 +2214,10 @@ function parseClaudeCodeStats(content) {
2596
2214
  }
2597
2215
  }
2598
2216
  catch {
2599
- // Skip malformed lines
2600
2217
  }
2601
2218
  }
2602
2219
  return { messageCount, toolCallCount };
2603
2220
  }
2604
- /** Convert OpenCode's SDK message array (`{info, parts}[]`) into the same
2605
- * newline-delimited shape used by Claude Code. The rest of capture—privacy
2606
- * exclusions, git keys, token parsing, scrubbing, storage, and merge joins—can
2607
- * therefore stay one shared pipeline instead of becoming a parallel product. */
2608
2221
  function normalizeOpenCodeTranscript(content) {
2609
2222
  let parsed;
2610
2223
  try {
@@ -2677,13 +2290,6 @@ function normalizeOpenCodeTranscript(content) {
2677
2290
  }
2678
2291
  return lines.join("\n") + (lines.length ? "\n" : "");
2679
2292
  }
2680
- // Codex session JSONL format (rollout-*.jsonl):
2681
- // {type:"session_meta", payload:{id, cwd, git:{repository_url,...}, ...}}
2682
- // {type:"response_item", payload:{type:"message", role, content:[{type:"input_text",text}|{type:"output_text",text}]}}
2683
- // {type:"response_item", payload:{type:"function_call", name, arguments, call_id}}
2684
- // {type:"response_item", payload:{type:"function_call_output", call_id, output}}
2685
- // {type:"event_msg", payload:{type:"token_count"|"agent_reasoning"|...}}
2686
- // {type:"turn_context", payload:{cwd, model, ...}}
2687
2293
  function parseCodexStats(content) {
2688
2294
  let messageCount = 0;
2689
2295
  let toolCallCount = 0;
@@ -2704,13 +2310,10 @@ function parseCodexStats(content) {
2704
2310
  }
2705
2311
  }
2706
2312
  catch {
2707
- // Skip malformed lines
2708
2313
  }
2709
2314
  }
2710
2315
  return { messageCount, toolCallCount };
2711
2316
  }
2712
- // Pull repository URL from the Codex transcript's session_meta line so we
2713
- // don't depend on the hook's cwd being a git repo.
2714
2317
  function extractCodexRepoUrl(content) {
2715
2318
  const lines = content.split("\n");
2716
2319
  for (const line of lines) {
@@ -2723,24 +2326,16 @@ function extractCodexRepoUrl(content) {
2723
2326
  const url = entry.payload?.git?.repository_url;
2724
2327
  if (typeof url === "string" && url.length > 0)
2725
2328
  return url;
2726
- return null; // session_meta is first — no point scanning further
2329
+ return null;
2727
2330
  }
2728
2331
  }
2729
2332
  catch {
2730
- // Keep scanning
2731
2333
  }
2732
2334
  }
2733
2335
  return null;
2734
2336
  }
2735
- // Give up after this many failed uploads (across sessions) so a permanently
2736
- // failing job never litters /tmp forever. Paired with UPLOAD_TTL_MS.
2737
2337
  exports.MAX_UPLOAD_ATTEMPTS = 5;
2738
2338
  exports.UPLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
2739
- // Open the capture log for appending so a detached child's stderr lands there
2740
- // instead of vanishing into stdio:"ignore". Returns the stdio value to spawn
2741
- // with plus the parent's fd (to close after spawn — the child gets its own
2742
- // duplicate). Falls back to plain "ignore" when openSync is absent or throws,
2743
- // so a missing/unwritable log dir never breaks a dispatch.
2744
2339
  function openCaptureLogStdio(openSync) {
2745
2340
  if (!openSync)
2746
2341
  return { stdio: "ignore" };
@@ -2752,70 +2347,36 @@ function openCaptureLogStdio(openSync) {
2752
2347
  return { stdio: "ignore" };
2753
2348
  }
2754
2349
  }
2755
- // Best-effort close of the parent's log-fd copy. No-op when there's no fd or
2756
- // no closeSync dep — never throws into the capture path.
2757
2350
  function closeCaptureLogFd(fd, closeSync) {
2758
2351
  if (fd === undefined || !closeSync)
2759
2352
  return;
2760
2353
  try {
2761
2354
  closeSync(fd);
2762
2355
  }
2763
- catch { /* best-effort */ }
2764
- }
2765
- // Hand the SessionEnd capture off to a detached child so the inline hook can
2766
- // exit 0 immediately. Detaching only the network upload (dispatchCaptureDetached)
2767
- // wasn't enough: the synchronous transcript read + parse and — the killer on
2768
- // Windows — one `git rev-parse` per SHA in the transcript (parseAuthoredShas)
2769
- // still ran inline, overrunning Claude Code's SessionEnd shutdown window and
2770
- // triggering "Hook cancelled" on long sessions. This moves ALL of that work
2771
- // into the child. The job carries only the raw hook inputs (the child re-reads
2772
- // the transcript and re-runs git), so the parent's inline work is a single
2773
- // small file write plus a spawn.
2356
+ catch { }
2357
+ }
2774
2358
  function dispatchCaptureFinish(input, hookScriptPath, deps) {
2775
2359
  deps.writeFileSync(deps.tmpFile, JSON.stringify(input), { mode: 0o600 });
2776
- // Route the child's stderr to the capture log so any crash output is
2777
- // visible to `codiedev doctor`. The caller must explicitly pass openSync
2778
- // (e.g. fs.openSync in hook.ts) to enable this; when omitted (tests,
2779
- // older call-sites) we fall back to "ignore" so existing behaviour is
2780
- // unchanged and no real fd is opened.
2781
2360
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2782
2361
  try {
2783
2362
  const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "capture-finish", deps.tmpFile], { detached: true, stdio });
2784
2363
  child.unref();
2785
2364
  }
2786
2365
  finally {
2787
- // Close the PARENT's fd copy even when spawn throws — the child (if
2788
- // spawned) holds its own duplicate, so this never cuts off its stderr.
2789
2366
  closeCaptureLogFd(fd, deps.closeSync);
2790
2367
  }
2791
2368
  }
2792
- // Fire-and-forget the session-capture upload from a fully detached child so the
2793
- // SessionEnd hook can exit 0 immediately. Awaiting the upload inline let Claude
2794
- // Code's shutdown window elapse and kill the hook ("Hook cancelled") on large
2795
- // transcripts. The job (incl. bearer token) is written to a file rather than
2796
- // passed via argv so it never shows up in the process list.
2797
2369
  function dispatchCaptureDetached(job, hookScriptPath, deps) {
2798
2370
  deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
2799
- // Route the child's stderr to the capture log (same pattern as
2800
- // dispatchCaptureFinish). The caller must explicitly pass openSync to
2801
- // enable this; when omitted we fall back to "ignore" so existing callers
2802
- // keep working unchanged.
2803
2371
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2804
2372
  try {
2805
2373
  const child = deps.spawn(deps.nodePath ?? process.execPath, [hookScriptPath, "upload", deps.tmpFile], { detached: true, stdio });
2806
2374
  child.unref();
2807
2375
  }
2808
2376
  finally {
2809
- // Close the PARENT's fd copy even when spawn throws — the child (if
2810
- // spawned) holds its own duplicate, so this never cuts off its stderr.
2811
2377
  closeCaptureLogFd(fd, deps.closeSync);
2812
2378
  }
2813
2379
  }
2814
- // Deferred twin of dispatchCaptureDetached: persist the job, then spawn the
2815
- // `upload-deferred` child which sleeps for delayMs before claiming + uploading
2816
- // (or finding itself superseded by a newer turn). Used when decideDispatch
2817
- // says a non-final per-turn capture is inside the throttle window. Identical
2818
- // stdio/fd-hygiene contract to the other dispatch fns.
2819
2380
  function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
2820
2381
  deps.writeFileSync(deps.tmpFile, JSON.stringify(job), { mode: 0o600 });
2821
2382
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
@@ -2824,29 +2385,11 @@ function dispatchDeferredUpload(job, hookScriptPath, deps, delayMs) {
2824
2385
  child.unref();
2825
2386
  }
2826
2387
  finally {
2827
- // Close the PARENT's fd copy even when spawn throws — the child (if
2828
- // spawned) holds its own duplicate, so this never cuts off its stderr.
2829
2388
  closeCaptureLogFd(fd, deps.closeSync);
2830
2389
  }
2831
2390
  }
2832
- // A claimed job whose uploader hasn't finished after this long is presumed
2833
- // crashed and gets renamed back to its canonical name for a later retry.
2834
2391
  exports.CLAIM_STALE_MS = 15 * 60 * 1000;
2835
- // A finish-input file older than this is an orphan: its capture-finish child
2836
- // normally consumes + unlinks it within seconds, so an hour-old file means the
2837
- // child died (spawn failure, OOM/AV kill, machine sleep). Generous so we never
2838
- // race a legitimately slow child.
2839
2392
  exports.FINISH_ORPHAN_AGE_MS = 60 * 60 * 1000;
2840
- // Retry kept-on-failure capture jobs: re-dispatch each pending
2841
- // `codiedev-capture-*.json` to the detached uploader (which bumps attempts /
2842
- // gives up at the cap). Best-effort — never throws into the hook.
2843
- //
2844
- // Each job is CLAIMED with an atomic rename before dispatch. Several
2845
- // session-end hooks can run this sweep over the same tmpdir at once, and
2846
- // without the claim each of them dispatched an uploader for the same job —
2847
- // every duplicate upload triggered a full server-side re-summarize of the
2848
- // session (real LLM spend per duplicate). Rename is atomic, so exactly one
2849
- // sweeper wins each file; the losers' renames throw and they skip it.
2850
2393
  function sweepPendingCaptures(deps) {
2851
2394
  let files;
2852
2395
  try {
@@ -2856,10 +2399,6 @@ function sweepPendingCaptures(deps) {
2856
2399
  return;
2857
2400
  }
2858
2401
  const now = deps.now?.() ?? Date.now();
2859
- // Recover claims orphaned by a crashed/killed uploader: a stale claim is
2860
- // renamed back to its canonical name so a LATER sweep retries it. (Not this
2861
- // one — the uploader may legitimately still be running near the threshold,
2862
- // and dispatching reclaimed work immediately would race it.)
2863
2402
  for (const f of files) {
2864
2403
  const m = /^(codiedev-capture-.*\.json)\.claim-\d+$/.exec(f);
2865
2404
  if (!m)
@@ -2871,14 +2410,8 @@ function sweepPendingCaptures(deps) {
2871
2410
  }
2872
2411
  }
2873
2412
  catch {
2874
- // raced another sweeper or the uploader just finished — fine
2875
2413
  }
2876
2414
  }
2877
- // Orphaned finish-inputs: the FIRST detached child (capture-finish) died
2878
- // before it could build an upload job. That path has no retry queue — the
2879
- // payload is gone — but the loss must not stay invisible: report the
2880
- // sessionId (filename embeds it: codiedev-finish-<sid>-<pid>-<ts>.json) so
2881
- // it lands as a skip-reason, then clean the file up.
2882
2415
  if (deps.unlinkSync) {
2883
2416
  for (const f of files) {
2884
2417
  const m = /^codiedev-finish-(.+)-\d+-\d+\.json$/.exec(f);
@@ -2889,19 +2422,13 @@ function sweepPendingCaptures(deps) {
2889
2422
  if (now - deps.statSync(orphanPath).mtimeMs < exports.FINISH_ORPHAN_AGE_MS)
2890
2423
  continue;
2891
2424
  deps.unlinkSync(orphanPath);
2892
- // finishInputTmpFile falls back to the PID when the hook had no
2893
- // session_id (codiedev-finish-<pid>-<pid>-<ts>.json). A pure-digit
2894
- // capture is that fallback, not a session id — reporting it would
2895
- // create a phantom session row server-side. Real session ids are
2896
- // UUIDs and never all-digits.
2897
2425
  const sid = /^\d+$/.test(m[1]) ? undefined : m[1];
2898
2426
  try {
2899
2427
  deps.reportOrphan?.(sid);
2900
2428
  }
2901
- catch { /* report is best-effort */ }
2429
+ catch { }
2902
2430
  }
2903
2431
  catch {
2904
- // raced its owner or another sweeper — fine
2905
2432
  }
2906
2433
  }
2907
2434
  }
@@ -2909,12 +2436,6 @@ function sweepPendingCaptures(deps) {
2909
2436
  .filter((f) => /^codiedev-capture-.*\.json$/.test(f))
2910
2437
  .map((f) => path.join(deps.dir, f))
2911
2438
  .filter((p) => p !== deps.exclude)
2912
- // Min-age guard: a YOUNG job file may be a sleeping deferred upload whose
2913
- // own upload-deferred child will consume it within CAPTURE_THROTTLE_MS of
2914
- // its mtime. The sweep must not steal it — that would upload per-turn and
2915
- // defeat the throttle with 2+ concurrent sessions. Only genuinely stuck
2916
- // jobs (older than SWEEP_MIN_AGE_MS) are retried. A stat failure means the
2917
- // job was claimed/consumed mid-sweep — skip it.
2918
2439
  .filter((p) => {
2919
2440
  try {
2920
2441
  return now - deps.statSync(p).mtimeMs >= exports.SWEEP_MIN_AGE_MS;
@@ -2924,8 +2445,6 @@ function sweepPendingCaptures(deps) {
2924
2445
  }
2925
2446
  })
2926
2447
  .slice(0, deps.max ?? 10);
2927
- // One log fd shared by every re-dispatched uploader in this batch (each
2928
- // spawn duplicates it); the parent's copy is closed after the loop.
2929
2448
  const { stdio, fd } = openCaptureLogStdio(deps.openSync);
2930
2449
  for (const jobFile of jobs) {
2931
2450
  const claimed = `${jobFile}.claim-${process.pid}`;
@@ -2933,61 +2452,29 @@ function sweepPendingCaptures(deps) {
2933
2452
  deps.renameSync(jobFile, claimed);
2934
2453
  }
2935
2454
  catch {
2936
- continue; // another sweeper claimed it first
2455
+ continue;
2937
2456
  }
2938
2457
  try {
2939
2458
  const child = deps.spawn(deps.nodePath ?? process.execPath, [deps.hookScriptPath, "upload", claimed], { detached: true, stdio });
2940
2459
  child.unref();
2941
2460
  }
2942
2461
  catch {
2943
- // Couldn't fork the uploader — release the claim so a later sweep
2944
- // retries instead of the job dying invisible under the claimed name.
2945
2462
  try {
2946
2463
  deps.renameSync(claimed, jobFile);
2947
2464
  }
2948
2465
  catch {
2949
- // best-effort
2950
2466
  }
2951
2467
  }
2952
2468
  }
2953
2469
  closeCaptureLogFd(fd, deps.closeSync);
2954
- // Housekeeping: prune per-session upload-state files older than the upload
2955
- // TTL so capture-state doesn't grow forever. Best-effort, never throws.
2956
2470
  try {
2957
2471
  (deps.pruneState ?? pruneCaptureStateDir)();
2958
2472
  }
2959
2473
  catch {
2960
- // best-effort
2961
2474
  }
2962
2475
  }
2963
- // ─────────────────────────────────────────────────────────────────────────────
2964
- // Per-session upload throttle — defers mid-session Stop-hook uploads so we
2965
- // don't hammer the server (and re-summarize) after every single turn.
2966
- // ─────────────────────────────────────────────────────────────────────────────
2967
- // Minimum interval between non-final uploads for the same session. 10 minutes:
2968
- // fine-grained enough to capture recent work while avoiding per-turn churn.
2969
2476
  exports.CAPTURE_THROTTLE_MS = 10 * 60 * 1000;
2970
- // Minimum AGE of a job file before the retry sweep may claim it. An actively
2971
- // deferred job is consumed by its own upload-deferred child within
2972
- // CAPTURE_THROTTLE_MS of its mtime — a sweep stealing it early would upload
2973
- // per-turn and defeat the throttle whenever 2+ sessions run concurrently.
2974
- // The 2-min slack covers the child's wake-up + claim. Side effect: failed
2975
- // uploads (whose retry bump refreshes mtime) now wait ~12 min between retry
2976
- // attempts — fine for a 7-day-TTL retry queue, and a free rate-limit on
2977
- // retry storms.
2978
2477
  exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS + 2 * 60 * 1000;
2979
- // Map the hook's capture argv to the payload's `final` flag. EXPLICIT
2980
- // ALLOWLIST: only claude-code's SessionEnd entrypoints ("capture", or the bare
2981
- // hook with no subcommand) are a true final signal. capture-opencode/
2982
- // capture-desktop (per-turn snapshot) and capture-opencode-final/
2983
- // capture-desktop-final (real session end) get an explicit false/true.
2984
- // Cursor's capture-cursor is wired only to sessionEnd, so it is also a true
2985
- // final signal. Everything else — capture-codex (Codex's Stop fires per-turn;
2986
- // final:true would close the server-side session lease every turn) and any
2987
- // FUTURE mode (e.g. a capture-windsurf added to resolveMode but not audited
2988
- // here) — gets undefined, i.e. legacy server behaviour. Defaulting unknowns
2989
- // to true would silently re-introduce the per-turn lease-close bug for every
2990
- // new agent.
2991
2478
  function resolveCaptureFinal(argv2) {
2992
2479
  if (argv2 === "capture-opencode" || argv2 === "capture-desktop" || argv2 === "capture-pi")
2993
2480
  return false;
@@ -2997,16 +2484,6 @@ function resolveCaptureFinal(argv2) {
2997
2484
  ? true
2998
2485
  : undefined;
2999
2486
  }
3000
- // Build a UNIQUE finish-input tmpFile name per dispatch. Both the Stop hook and
3001
- // SessionEnd dispatch capture-finish children, and the child unlinks its input
3002
- // in `finally` — a SHARED per-session name let interleavings drop the final
3003
- // dispatch (Stop's child unlinks the file SessionEnd just wrote) or
3004
- // double-dispatch. Unique names mean each child owns its file exclusively; at
3005
- // worst concurrent Stop+SessionEnd children both upload, which is harmless
3006
- // (the server upserts by sessionId). Orphans (child dies before unlink) sit in
3007
- // tmpdir OUTSIDE the sweep regex (codiedev-finish- ≠ codiedev-capture-);
3008
- // negligible volume (one tiny JSON per crashed child), though note Windows
3009
- // %TEMP% is not auto-cleaned — acceptable.
3010
2487
  function finishInputTmpFile(sessionId) {
3011
2488
  const safeId = String(sessionId ?? process.pid).replace(/[^A-Za-z0-9._-]/g, "_");
3012
2489
  return path.join(os.tmpdir(), `codiedev-finish-${safeId}-${process.pid}-${Date.now()}.json`);
@@ -3017,46 +2494,29 @@ function captureUploadTmpFile(sessionId, mode, pid = process.pid, now = Date.now
3017
2494
  ? `codiedev-capture-${id}-${pid}-${now}.json`
3018
2495
  : `codiedev-capture-${id}.json`);
3019
2496
  }
3020
- // Best-effort cancellation for a scheduled per-turn upload when a real final
3021
- // capture arrives. Deferred jobs deliberately share one canonical filename;
3022
- // removing it makes every sleeping uploader lose its later claim-by-rename.
3023
- // The backend's capturedAt guard covers the narrow race where a child claimed
3024
- // the file immediately before this unlink.
3025
2497
  function cancelDeferredCaptureUpload(sessionId, deps = fs) {
3026
2498
  try {
3027
2499
  deps.unlinkSync(captureUploadTmpFile(sessionId, "deferred"));
3028
2500
  return true;
3029
2501
  }
3030
2502
  catch {
3031
- // Missing means there was nothing pending (or another process won).
3032
2503
  return false;
3033
2504
  }
3034
2505
  }
3035
- // Decide whether to upload immediately or defer until the throttle window expires.
3036
- // final=true → always immediate (SessionEnd: close the session now)
3037
- // null → immediate (first upload for this session)
3038
- // outside window → immediate
3039
- // inside window → deferred with exact remaining delay
3040
- // future clock skew → deferred clamped to CAPTURE_THROTTLE_MS
3041
2506
  function decideDispatch(args) {
3042
2507
  if (args.final || args.lastUploadAt === null)
3043
2508
  return { mode: "immediate" };
3044
2509
  const elapsed = args.now - args.lastUploadAt;
3045
- // Clock-skew guard: elapsed < 0 means lastUploadAt is in the future. Clamping
3046
- // to 0 also bounds delayMs to at most CAPTURE_THROTTLE_MS (safeElapsed >= 0).
3047
2510
  const safeElapsed = Math.max(0, elapsed);
3048
2511
  if (safeElapsed >= exports.CAPTURE_THROTTLE_MS)
3049
2512
  return { mode: "immediate" };
3050
2513
  return { mode: "deferred", delayMs: exports.CAPTURE_THROTTLE_MS - safeElapsed };
3051
2514
  }
3052
- // Per-session state dir: ~/.codiedev/capture-state/<sanitized-session-id>.json
3053
2515
  const CAPTURE_STATE_DIR = path.join(CONFIG_DIR, "capture-state");
3054
2516
  function captureStatePath(sessionId) {
3055
2517
  const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_");
3056
2518
  return path.join(CAPTURE_STATE_DIR, `${safe}.json`);
3057
2519
  }
3058
- // Read the per-session upload state from ~/.codiedev/capture-state/<id>.json.
3059
- // Returns null when missing or unreadable. Never throws.
3060
2520
  function readSessionUploadState(sessionId, deps = fs) {
3061
2521
  try {
3062
2522
  const p = captureStatePath(sessionId);
@@ -3068,8 +2528,6 @@ function readSessionUploadState(sessionId, deps = fs) {
3068
2528
  return null;
3069
2529
  }
3070
2530
  }
3071
- // Write the per-session upload state to ~/.codiedev/capture-state/<id>.json.
3072
- // Best-effort: never throws into the capture path.
3073
2531
  function writeSessionUploadState(sessionId, state, deps = fs) {
3074
2532
  try {
3075
2533
  if (!deps.existsSync(CAPTURE_STATE_DIR)) {
@@ -3079,13 +2537,8 @@ function writeSessionUploadState(sessionId, state, deps = fs) {
3079
2537
  deps.writeFileSync(p, JSON.stringify(state), { mode: 0o600 });
3080
2538
  }
3081
2539
  catch {
3082
- // best-effort — never break the capture path
3083
2540
  }
3084
2541
  }
3085
- // Prune per-session upload-state files older than UPLOAD_TTL_MS (7 days — same
3086
- // TTL as the upload retry queue) so ~/.codiedev/capture-state (one tiny file
3087
- // per session) doesn't grow forever. Called from sweepPendingCaptures, i.e. on
3088
- // the cadence captures already run housekeeping. Best-effort: never throws.
3089
2542
  function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now()) {
3090
2543
  try {
3091
2544
  for (const f of deps.readdirSync(dir)) {
@@ -3095,25 +2548,13 @@ function pruneCaptureStateDir(deps = fs, dir = CAPTURE_STATE_DIR, now = Date.now
3095
2548
  deps.unlinkSync(p);
3096
2549
  }
3097
2550
  catch {
3098
- // raced another pruner or unreadable — skip
3099
2551
  }
3100
2552
  }
3101
2553
  }
3102
2554
  catch {
3103
- // dir missing/unreadable — nothing to prune
3104
2555
  }
3105
2556
  }
3106
- // Base64 length above which the transcript goes straight to storage instead
3107
- // of inline in the captureSession JSON body. The backend's HTTP-action
3108
- // isolate has a 64MB cap and inline base64 amplifies ~8x in memory (request
3109
- // text + parsed JSON + decode output all resident) — ~7MB transcripts OOM it
3110
- // (observed live 2026-07-02). 2M base64 chars ≈ 1.5MB raw: far below the
3111
- // cliff, while per-turn snapshots (typically KBs) skip the extra roundtrips.
3112
2557
  exports.DIRECT_UPLOAD_THRESHOLD_CHARS = 2_000_000;
3113
- // "Last session captured" breadcrumb. Written next to config.json on a
3114
- // successful capture upload; read by `codiedev doctor` so a user can confirm
3115
- // their most recent session actually landed (the single hardest thing to
3116
- // self-verify today — it previously required eyeballing the portal).
3117
2558
  const LAST_CAPTURE_PATH = path.join(CONFIG_DIR, "last-capture.json");
3118
2559
  function buildLastCaptureRecord(job, now) {
3119
2560
  const b = job.body;
@@ -3136,7 +2577,6 @@ function writeLastCapture(record, deps = fs, filePath = LAST_CAPTURE_PATH) {
3136
2577
  deps.writeFileSync(filePath, JSON.stringify(record), { mode: 0o600 });
3137
2578
  }
3138
2579
  catch {
3139
- // breadcrumb is best-effort — never throw on the capture path.
3140
2580
  }
3141
2581
  }
3142
2582
  function readLastCapture(deps = fs, filePath = LAST_CAPTURE_PATH) {
@@ -3149,21 +2589,16 @@ function readLastCapture(deps = fs, filePath = LAST_CAPTURE_PATH) {
3149
2589
  return null;
3150
2590
  }
3151
2591
  }
3152
- // Runs inside the detached child spawned by `dispatchCaptureDetached`: read the
3153
- // persisted job, POST it, and ALWAYS delete the temp file afterwards (even on
3154
- // failure) so we don't litter the tmp dir. Never throws — capture is
3155
- // best-effort.
3156
2592
  async function runCaptureUpload(jobFile, deps) {
3157
2593
  let job = null;
3158
2594
  try {
3159
2595
  job = JSON.parse(deps.readFileSync(jobFile, "utf8"));
3160
2596
  }
3161
2597
  catch {
3162
- // unreadable/corrupt job — delete it and stop.
3163
2598
  try {
3164
2599
  deps.unlinkSync(jobFile);
3165
2600
  }
3166
- catch { /* gone */ }
2601
+ catch { }
3167
2602
  return;
3168
2603
  }
3169
2604
  const sessionId = job.body?.sessionId;
@@ -3172,18 +2607,15 @@ async function runCaptureUpload(jobFile, deps) {
3172
2607
  try {
3173
2608
  deps.log({ stage: "upload", sessionId, outcome: "skip", detail: "ignored-path" });
3174
2609
  }
3175
- catch { /* log is best-effort */ }
2610
+ catch { }
3176
2611
  }
3177
2612
  try {
3178
2613
  deps.unlinkSync(jobFile);
3179
2614
  }
3180
- catch { /* gone */ }
2615
+ catch { }
3181
2616
  return;
3182
2617
  }
3183
2618
  try {
3184
- // Big transcript + capable deps → direct-to-storage: fetch a one-time
3185
- // upload URL, PUT the raw bytes, send captureSession the storageId. Any
3186
- // step failing throws into the retry path below, same as a failed POST.
3187
2619
  let body = job.body;
3188
2620
  const transcript = body?.transcript;
3189
2621
  if (transcript &&
@@ -3195,41 +2627,39 @@ async function runCaptureUpload(jobFile, deps) {
3195
2627
  const { storageId } = await deps.uploadBytes(uploadUrl, Buffer.from(transcript, "base64"), "application/json");
3196
2628
  body = { ...body, transcriptStorageId: storageId };
3197
2629
  delete body.transcript;
2630
+ job.body = body;
2631
+ if (deps.writeFileSync) {
2632
+ deps.writeFileSync(jobFile, JSON.stringify(job), { mode: 0o600 });
2633
+ }
3198
2634
  }
3199
2635
  await deps.post(job.url, body, job.token);
3200
- // success — drop a "last session captured" breadcrumb (before removing the
3201
- // job) so doctor can confirm the most recent session actually landed.
3202
2636
  if (deps.recordSuccess) {
3203
2637
  try {
3204
2638
  deps.recordSuccess(job);
3205
2639
  }
3206
- catch { /* breadcrumb is best-effort */ }
2640
+ catch { }
3207
2641
  }
3208
- // Write the per-session upload state so the throttle can defer the next
3209
- // non-final (per-turn) upload until the window expires.
3210
2642
  const uploadedSessionId = job.body?.sessionId;
3211
2643
  if (uploadedSessionId && deps.writeSessionState) {
3212
2644
  try {
3213
2645
  deps.writeSessionState(uploadedSessionId, { lastUploadAt: deps.now ? deps.now() : Date.now() });
3214
2646
  }
3215
- catch { /* best-effort */ }
2647
+ catch { }
3216
2648
  }
3217
2649
  const attempt = (job.attempts ?? 0) + 1;
3218
2650
  if (deps.log) {
3219
2651
  try {
3220
2652
  deps.log({ stage: "upload", sessionId, outcome: "ok", detail: `attempt=${attempt}` });
3221
2653
  }
3222
- catch { /* log is best-effort */ }
2654
+ catch { }
3223
2655
  }
3224
2656
  try {
3225
2657
  deps.unlinkSync(jobFile);
3226
2658
  }
3227
- catch { /* gone */ }
2659
+ catch { }
3228
2660
  return;
3229
2661
  }
3230
2662
  catch (err) {
3231
- // Failure: keep the job for a later sweep to retry, UNLESS we've hit the
3232
- // attempt cap or TTL — then give up so /tmp doesn't accumulate forever.
3233
2663
  const now = deps.now ? deps.now() : Date.now();
3234
2664
  const attempts = (job.attempts ?? 0) + 1;
3235
2665
  const queuedAt = job.queuedAt ?? now;
@@ -3246,19 +2676,12 @@ async function runCaptureUpload(jobFile, deps) {
3246
2676
  detail: `attempt=${attempts} age=${ageMs}ms`,
3247
2677
  });
3248
2678
  }
3249
- catch { /* log is best-effort */ }
2679
+ catch { }
3250
2680
  }
3251
- // Unlink FIRST: if the skip ping below hangs (no timeout on injected
3252
- // post impls), a still-present job file would be reclaimed by the
3253
- // 15-min stale-claim sweep and re-enter this give-up path forever.
3254
2681
  try {
3255
2682
  deps.unlinkSync(jobFile);
3256
2683
  }
3257
- catch { /* gone */ }
3258
- // Tell the server this capture is permanently lost and why. The full
3259
- // transcript POST may be what's blocked (proxy/DLP body inspection) —
3260
- // this tiny ping often still passes, turning an invisible loss into a
3261
- // per-reason bucket on the admin Capture Health panel.
2684
+ catch { }
3262
2685
  if (sessionId) {
3263
2686
  try {
3264
2687
  const skipUrl = job.url.replace(/\/api\/captureSession$/, "/api/cli/sessionSkip");
@@ -3270,52 +2693,42 @@ async function runCaptureUpload(jobFile, deps) {
3270
2693
  }, job.token);
3271
2694
  }
3272
2695
  }
3273
- catch { /* best-effort — the ping may be blocked too */ }
2696
+ catch { }
3274
2697
  }
3275
2698
  return;
3276
2699
  }
3277
- // Log the error before persisting the retry bump.
3278
2700
  if (deps.log) {
3279
2701
  try {
3280
2702
  const msg = err.message ?? String(err);
3281
- // Extract http=<status> when the error message carries one (format used
3282
- // by postJson: "HTTP <status>: <body>"). Truncate to 200 chars.
3283
2703
  const httpMatch = msg.match(/\bhttp[= ](\d{3})\b/i);
3284
2704
  const httpPart = httpMatch ? `http=${httpMatch[1]} ` : "";
3285
2705
  const detail = `${httpPart}${msg.slice(0, 200)} attempt=${attempts}`;
3286
2706
  deps.log({ stage: "upload", sessionId, outcome: "ERROR", detail });
3287
2707
  }
3288
- catch { /* log is best-effort */ }
2708
+ catch { }
3289
2709
  }
3290
- // The sweep hands us a CLAIMED path (`<job>.claim-<pid>`). Persist the
3291
- // retry bump under the canonical name — the sweep only matches
3292
- // `codiedev-capture-*.json`, so a bump left on the claimed path would
3293
- // never be retried (a silently dropped capture).
3294
2710
  const canonical = jobFile.replace(/\.claim-\d+$/, "");
3295
- const writeFn = deps.writeFileSync; // always set here: giveUp branch checked !deps.writeFileSync
2711
+ const writeFn = deps.writeFileSync;
3296
2712
  try {
3297
2713
  writeFn(canonical, JSON.stringify({ ...job, attempts, queuedAt }), { mode: 0o600 });
3298
2714
  if (canonical !== jobFile) {
3299
2715
  try {
3300
2716
  deps.unlinkSync(jobFile);
3301
2717
  }
3302
- catch { /* gone */ }
2718
+ catch { }
3303
2719
  }
3304
2720
  }
3305
2721
  catch {
3306
- // couldn't persist the bump — drop it rather than risk a stuck file.
3307
2722
  try {
3308
2723
  deps.unlinkSync(jobFile);
3309
2724
  }
3310
- catch { /* gone */ }
2725
+ catch { }
3311
2726
  }
3312
2727
  }
3313
2728
  }
3314
2729
  async function runDeferredUpload(jobFile, delayMs, deps) {
3315
2730
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
3316
2731
  await sleep(delayMs);
3317
- // Claim via rename. If the file is gone or already claimed, a newer turn's
3318
- // upload superseded this one: log and exit silently.
3319
2732
  const claimed = `${jobFile}.claim-${process.pid}`;
3320
2733
  try {
3321
2734
  deps.renameSync(jobFile, claimed);
@@ -3323,40 +2736,24 @@ async function runDeferredUpload(jobFile, delayMs, deps) {
3323
2736
  catch {
3324
2737
  if (deps.log) {
3325
2738
  try {
3326
- // The job file is gone, so its body (and sessionId) is unreadable —
3327
- // recover the session id from the per-session filename so doctor can
3328
- // still tie this line to a session.
3329
2739
  const sid = /^codiedev-capture-(.+)\.json/.exec(path.basename(jobFile))?.[1];
3330
2740
  deps.log({ stage: "upload-deferred", sessionId: sid, outcome: "superseded" });
3331
2741
  }
3332
- catch { /* log is best-effort */ }
2742
+ catch { }
3333
2743
  }
3334
2744
  return;
3335
2745
  }
3336
2746
  await runCaptureUpload(claimed, deps);
3337
2747
  }
3338
- // Classify a transcript read failure at capture time. ENOENT = the transcript
3339
- // never existed: a desktop-app phantom helper session (SessionStart/End hooks
3340
- // fire, no conversation ever happens), not lost work — the backend excludes
3341
- // "no-transcript" from coverage. Anything else means a real transcript was
3342
- // there but the read failed: a genuine loss, tagged with the errno so it stays
3343
- // diagnosable from the server side.
3344
2748
  function transcriptReadSkipReason(err) {
3345
2749
  const code = err?.code;
3346
2750
  if (code === "ENOENT")
3347
2751
  return "no-transcript";
3348
2752
  return `transcript-unreadable:${code || "unknown"}`;
3349
2753
  }
3350
- // Codex's Stop hook also runs the plain `backup` command for artifact backup.
3351
- // Its transcript is a Codex rollout, already captured per turn by
3352
- // capture-codex; parsing it as Claude Code would upload an empty duplicate
3353
- // session with no model under source claude-code.
3354
2754
  function isCodexRolloutPath(transcriptPath) {
3355
2755
  return Boolean(transcriptPath && /^rollout-.*\.jsonl$/.test(path.basename(transcriptPath)));
3356
2756
  }
3357
- // Per-turn snapshots are backups, not session endings. Reporting their local
3358
- // read/empty condition as a session skip would hide or close coverage while the
3359
- // agent is still running; only final/legacy capture paths emit the skip ping.
3360
2757
  function shouldReportCaptureSkip(final) {
3361
2758
  return final !== false;
3362
2759
  }