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