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