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