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.
- package/README.md +12 -5
- package/dist/backup/cache.js +1 -5
- package/dist/backup/match.js +0 -6
- package/dist/backup/run.js +3 -35
- package/dist/captureExclusions.js +0 -12
- package/dist/captureLog.js +0 -28
- package/dist/captureReadiness.js +0 -58
- package/dist/captureSignals.js +0 -11
- package/dist/cli.js +11 -33
- package/dist/cliSession.js +3 -60
- package/dist/codexHookTrust.js +0 -28
- package/dist/codexLimitScan.js +0 -107
- package/dist/codie.js +64 -57
- package/dist/codieBrandInstall.js +0 -18
- package/dist/codieLauncher.js +1 -72
- package/dist/codieRuntime.js +27 -32
- package/dist/codieRuntimeInstall.js +11 -43
- package/dist/codieUsage.js +0 -49
- package/dist/commands/agentTemplates.generated.js +136 -0
- package/dist/commands/agents.js +1088 -0
- package/dist/commands/agentsSdk.generated.js +7 -0
- package/dist/commands/ask.js +0 -3
- package/dist/commands/capture.js +0 -2
- package/dist/commands/createTicket.js +0 -10
- package/dist/commands/doctor.js +2 -72
- package/dist/commands/evals.js +3 -9
- package/dist/commands/inbox.js +0 -2
- package/dist/commands/library.js +0 -3
- package/dist/commands/pack.js +0 -29
- package/dist/commands/ping.js +0 -2
- package/dist/commands/pull.js +0 -9
- package/dist/commands/push.js +0 -12
- package/dist/commands/search.js +0 -4
- package/dist/commands/send.js +0 -5
- package/dist/commands/share.js +0 -4
- package/dist/commands/shared.js +0 -35
- package/dist/commands/traces.js +0 -13
- package/dist/commands/tracesLabel.js +0 -8
- package/dist/configDir.js +1 -18
- package/dist/connect.js +3 -75
- package/dist/connectFlow.js +0 -15
- package/dist/copilotHooks.js +0 -67
- package/dist/copilotInstallation.js +0 -1
- package/dist/cursorHooks.js +1 -11
- package/dist/cursorLifecycle.js +1 -7
- package/dist/detection.js +0 -30
- package/dist/harnessDirs.js +0 -19
- package/dist/hook.js +43 -293
- package/dist/inboxFormat.js +0 -24
- package/dist/launchContext.js +0 -50
- package/dist/launcher.js +0 -33
- package/dist/limitEvent.js +2 -34
- package/dist/managedPi.js +2 -50
- package/dist/mcp.js +0 -48
- package/dist/nativeSteering.js +1 -4
- package/dist/packs/codie.js +0 -15
- package/dist/packs/harnesses.js +1 -38
- package/dist/packs/hookGate.js +0 -33
- package/dist/packs/install.js +0 -20
- package/dist/packs/manifest.js +1 -15
- package/dist/packs/paths.js +0 -8
- package/dist/packs/shim.js +0 -8
- package/dist/packs/store.js +0 -12
- package/dist/packs/sync.js +0 -20
- package/dist/piHooks.js +4 -45
- package/dist/piRuntime.js +0 -18
- package/dist/repoResolver.js +0 -19
- package/dist/secret.js +0 -3
- package/dist/selfUpdate.js +2 -77
- package/dist/skillMarker.js +0 -14
- package/dist/subagents.js +0 -18
- package/dist/teamSkillContext.js +0 -1
- package/dist/transcriptModels.js +0 -8
- package/dist/upgradeNudge.js +1 -30
- package/dist/utils.js +165 -768
- package/dist/version.js +0 -10
- package/dist/versionCheck.js +0 -33
- package/package.json +13 -8
- package/dist/backup/cache.d.ts +0 -14
- package/dist/backup/match.d.ts +0 -2
- package/dist/backup/run.d.ts +0 -25
- package/dist/captureExclusions.d.ts +0 -30
- package/dist/captureLog.d.ts +0 -20
- package/dist/captureReadiness.d.ts +0 -65
- package/dist/captureSignals.d.ts +0 -47
- package/dist/cli.d.ts +0 -15
- package/dist/cliSession.d.ts +0 -108
- package/dist/codexHookTrust.d.ts +0 -49
- package/dist/codexLimitScan.d.ts +0 -64
- package/dist/codie.d.ts +0 -2
- package/dist/codieBrandInstall.d.ts +0 -11
- package/dist/codieLauncher.d.ts +0 -87
- package/dist/codieRuntime.d.ts +0 -35
- package/dist/codieRuntimeInstall.d.ts +0 -56
- package/dist/codieUsage.d.ts +0 -64
- package/dist/commands/ask.d.ts +0 -1
- package/dist/commands/capture.d.ts +0 -14
- package/dist/commands/createTicket.d.ts +0 -27
- package/dist/commands/delete.d.ts +0 -1
- package/dist/commands/doctor.d.ts +0 -9
- package/dist/commands/evals.d.ts +0 -3
- package/dist/commands/inbox.d.ts +0 -3
- package/dist/commands/library.d.ts +0 -2
- package/dist/commands/note.d.ts +0 -1
- package/dist/commands/pack.d.ts +0 -54
- package/dist/commands/ping.d.ts +0 -1
- package/dist/commands/pull.d.ts +0 -2
- package/dist/commands/push.d.ts +0 -33
- package/dist/commands/search.d.ts +0 -2
- package/dist/commands/send.d.ts +0 -2
- package/dist/commands/share.d.ts +0 -2
- package/dist/commands/shared.d.ts +0 -64
- package/dist/commands/traces.d.ts +0 -72
- package/dist/commands/tracesLabel.d.ts +0 -24
- package/dist/configDir.d.ts +0 -10
- package/dist/connect.d.ts +0 -1
- package/dist/connectFlow.d.ts +0 -34
- package/dist/copilotHooks.d.ts +0 -85
- package/dist/copilotInstallation.d.ts +0 -10
- package/dist/cursorHooks.d.ts +0 -13
- package/dist/cursorLifecycle.d.ts +0 -2
- package/dist/detection.d.ts +0 -30
- package/dist/harnessDirs.d.ts +0 -15
- package/dist/hook.d.ts +0 -6
- package/dist/inboxFormat.d.ts +0 -38
- package/dist/launchContext.d.ts +0 -46
- package/dist/launcher.d.ts +0 -25
- package/dist/limitEvent.d.ts +0 -15
- package/dist/limitEventReporter.d.ts +0 -21
- package/dist/managedPi.d.ts +0 -48
- package/dist/mcp.d.ts +0 -12
- package/dist/nativeSteering.d.ts +0 -5
- package/dist/packs/codie.d.ts +0 -45
- package/dist/packs/harnesses.d.ts +0 -74
- package/dist/packs/hookGate.d.ts +0 -53
- package/dist/packs/install.d.ts +0 -46
- package/dist/packs/manifest.d.ts +0 -52
- package/dist/packs/paths.d.ts +0 -17
- package/dist/packs/shim.d.ts +0 -26
- package/dist/packs/store.d.ts +0 -42
- package/dist/packs/sync.d.ts +0 -40
- package/dist/piHooks.d.ts +0 -22
- package/dist/piRuntime.d.ts +0 -26
- package/dist/repoResolver.d.ts +0 -17
- package/dist/secret.d.ts +0 -1
- package/dist/selfUpdate.d.ts +0 -64
- package/dist/skillMarker.d.ts +0 -6
- package/dist/subagents.d.ts +0 -36
- package/dist/teamSkillContext.d.ts +0 -9
- package/dist/transcriptModels.d.ts +0 -20
- package/dist/upgradeNudge.d.ts +0 -24
- package/dist/utils.d.ts +0 -396
- package/dist/version.d.ts +0 -1
- 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 {
|
|
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;
|
|
634
|
-
const rangeRe = /\b([0-9a-f]{7,40})\.\.([0-9a-f]{7,40})\b/g;
|
|
635
|
-
const fullRe = /\b([0-9a-f]{40})\b/g;
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1142
|
+
fs.writeFileSync(tmp, piExtensionSource(), { encoding: "utf8", mode: 0o600 });
|
|
1366
1143
|
fs.renameSync(tmp, target);
|
|
1367
1144
|
}
|
|
1368
|
-
|
|
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(
|
|
1148
|
+
if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === piExtensionSource())
|
|
1373
1149
|
return;
|
|
1374
|
-
installPiExtension(
|
|
1150
|
+
installPiExtension();
|
|
1375
1151
|
}
|
|
1376
|
-
catch {
|
|
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
|
-
|
|
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;
|
|
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 {
|
|
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 {
|
|
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;
|
|
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;
|
|
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 {
|
|
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;
|
|
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;
|
|
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
|
-
|
|
2130
|
-
|
|
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
|
-
|
|
2226
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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;
|
|
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 {
|
|
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 {
|
|
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;
|
|
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 {
|
|
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 {
|
|
2610
|
+
catch { }
|
|
3176
2611
|
}
|
|
3177
2612
|
try {
|
|
3178
2613
|
deps.unlinkSync(jobFile);
|
|
3179
2614
|
}
|
|
3180
|
-
catch {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
2654
|
+
catch { }
|
|
3223
2655
|
}
|
|
3224
2656
|
try {
|
|
3225
2657
|
deps.unlinkSync(jobFile);
|
|
3226
2658
|
}
|
|
3227
|
-
catch {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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;
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
}
|