codiedev 0.10.0-beta.1 → 0.10.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +11 -5
  2. package/assets/opencode-plugin.mjs +6 -0
  3. package/assets/session-key.mjs +48 -0
  4. package/assets/tui-brand-plugin.tsx +73 -0
  5. package/dist/backup/cache.js +1 -5
  6. package/dist/backup/match.js +0 -6
  7. package/dist/backup/run.js +3 -35
  8. package/dist/captureExclusions.js +0 -12
  9. package/dist/captureLog.js +0 -28
  10. package/dist/captureReadiness.js +0 -58
  11. package/dist/captureSignals.js +184 -0
  12. package/dist/cli.js +135 -72
  13. package/dist/cliSession.js +456 -0
  14. package/dist/codexHookTrust.js +125 -6
  15. package/dist/codexLimitScan.js +359 -0
  16. package/dist/codie.js +77 -130
  17. package/dist/codieBrandInstall.js +80 -0
  18. package/dist/codieLauncher.js +76 -79
  19. package/dist/codieRuntime.js +19 -23
  20. package/dist/codieRuntimeInstall.js +108 -23
  21. package/dist/codieUsage.js +181 -0
  22. package/dist/commands/agentTemplates.generated.js +136 -0
  23. package/dist/commands/agents.js +1088 -0
  24. package/dist/commands/agentsSdk.generated.js +7 -0
  25. package/dist/commands/ask.js +0 -3
  26. package/dist/commands/capture.js +0 -2
  27. package/dist/commands/createTicket.js +0 -10
  28. package/dist/commands/doctor.js +53 -120
  29. package/dist/commands/evals.js +187 -0
  30. package/dist/commands/inbox.js +0 -2
  31. package/dist/commands/library.js +0 -3
  32. package/dist/commands/pack.js +573 -0
  33. package/dist/commands/ping.js +0 -2
  34. package/dist/commands/pull.js +6 -10
  35. package/dist/commands/push.js +2 -12
  36. package/dist/commands/search.js +0 -4
  37. package/dist/commands/send.js +0 -5
  38. package/dist/commands/share.js +0 -4
  39. package/dist/commands/shared.js +0 -35
  40. package/dist/commands/traces.js +344 -0
  41. package/dist/commands/tracesLabel.js +348 -0
  42. package/dist/configDir.js +18 -11
  43. package/dist/connect.js +55 -118
  44. package/dist/connectFlow.js +35 -9
  45. package/dist/copilotHooks.js +391 -0
  46. package/dist/copilotInstallation.js +64 -0
  47. package/dist/cursorHooks.js +38 -30
  48. package/dist/cursorLifecycle.js +109 -0
  49. package/dist/detection.js +0 -30
  50. package/dist/harnessDirs.js +77 -0
  51. package/dist/hook.js +256 -445
  52. package/dist/inboxFormat.js +0 -24
  53. package/dist/launchContext.js +150 -0
  54. package/dist/launcher.js +168 -0
  55. package/dist/limitEvent.js +44 -13
  56. package/dist/limitEventReporter.js +1 -1
  57. package/dist/managedPi.js +256 -0
  58. package/dist/mcp.js +5 -384
  59. package/dist/nativeSteering.js +24 -0
  60. package/dist/packs/codie.js +182 -0
  61. package/dist/packs/harnesses.js +411 -0
  62. package/dist/packs/hookGate.js +256 -0
  63. package/dist/packs/install.js +286 -0
  64. package/dist/packs/manifest.js +126 -0
  65. package/dist/packs/paths.js +81 -0
  66. package/dist/packs/shim.js +214 -0
  67. package/dist/packs/store.js +155 -0
  68. package/dist/packs/sync.js +170 -0
  69. package/dist/piHooks.js +216 -0
  70. package/dist/piRuntime.js +120 -0
  71. package/dist/repoResolver.js +0 -19
  72. package/dist/secret.js +0 -3
  73. package/dist/selfUpdate.js +376 -0
  74. package/dist/skillMarker.js +23 -0
  75. package/dist/subagents.js +135 -0
  76. package/dist/teamSkillContext.js +10 -0
  77. package/dist/transcriptModels.js +205 -0
  78. package/dist/upgradeNudge.js +0 -30
  79. package/dist/utils.js +502 -828
  80. package/dist/version.js +0 -10
  81. package/dist/versionCheck.js +0 -33
  82. package/package.json +9 -10
  83. package/dist/backup/cache.d.ts +0 -14
  84. package/dist/backup/match.d.ts +0 -2
  85. package/dist/backup/run.d.ts +0 -25
  86. package/dist/captureExclusions.d.ts +0 -30
  87. package/dist/captureLog.d.ts +0 -20
  88. package/dist/captureReadiness.d.ts +0 -65
  89. package/dist/cli.d.ts +0 -16
  90. package/dist/codexHookTrust.d.ts +0 -34
  91. package/dist/codie.d.ts +0 -2
  92. package/dist/codieLauncher.d.ts +0 -53
  93. package/dist/codieRuntime.d.ts +0 -29
  94. package/dist/codieRuntimeInstall.d.ts +0 -28
  95. package/dist/codieStartup.d.ts +0 -26
  96. package/dist/codieStartup.js +0 -30
  97. package/dist/commands/ask.d.ts +0 -1
  98. package/dist/commands/capture.d.ts +0 -14
  99. package/dist/commands/createTicket.d.ts +0 -27
  100. package/dist/commands/delete.d.ts +0 -1
  101. package/dist/commands/doctor.d.ts +0 -1
  102. package/dist/commands/inbox.d.ts +0 -3
  103. package/dist/commands/library.d.ts +0 -2
  104. package/dist/commands/note.d.ts +0 -1
  105. package/dist/commands/ping.d.ts +0 -1
  106. package/dist/commands/promote.d.ts +0 -1
  107. package/dist/commands/promote.js +0 -53
  108. package/dist/commands/pull.d.ts +0 -2
  109. package/dist/commands/push.d.ts +0 -33
  110. package/dist/commands/react.d.ts +0 -2
  111. package/dist/commands/react.js +0 -40
  112. package/dist/commands/reverseTicket.d.ts +0 -2
  113. package/dist/commands/reverseTicket.js +0 -477
  114. package/dist/commands/search.d.ts +0 -2
  115. package/dist/commands/security.d.ts +0 -1
  116. package/dist/commands/security.js +0 -156
  117. package/dist/commands/send.d.ts +0 -2
  118. package/dist/commands/share.d.ts +0 -2
  119. package/dist/commands/shared.d.ts +0 -64
  120. package/dist/configDir.d.ts +0 -1
  121. package/dist/connect.d.ts +0 -1
  122. package/dist/connectFlow.d.ts +0 -13
  123. package/dist/cursorHooks.d.ts +0 -23
  124. package/dist/detection.d.ts +0 -30
  125. package/dist/hook.d.ts +0 -6
  126. package/dist/inboxFormat.d.ts +0 -38
  127. package/dist/limitEvent.d.ts +0 -16
  128. package/dist/limitEventReporter.d.ts +0 -21
  129. package/dist/mcp.d.ts +0 -12
  130. package/dist/prUrl.d.ts +0 -9
  131. package/dist/prUrl.js +0 -33
  132. package/dist/repoResolver.d.ts +0 -17
  133. package/dist/secret.d.ts +0 -1
  134. package/dist/security.d.ts +0 -102
  135. package/dist/security.js +0 -437
  136. package/dist/securityDeepScan.d.ts +0 -41
  137. package/dist/securityDeepScan.js +0 -248
  138. package/dist/securityDoctor.d.ts +0 -32
  139. package/dist/securityDoctor.js +0 -63
  140. package/dist/securityFeature.d.ts +0 -5
  141. package/dist/securityFeature.js +0 -7
  142. package/dist/securityInstall.d.ts +0 -63
  143. package/dist/securityInstall.js +0 -239
  144. package/dist/securityInventory.d.ts +0 -54
  145. package/dist/securityInventory.js +0 -556
  146. package/dist/securityPilot.d.ts +0 -19
  147. package/dist/securityPilot.js +0 -61
  148. package/dist/securityPolicy.d.ts +0 -33
  149. package/dist/securityPolicy.js +0 -188
  150. package/dist/securitySession.d.ts +0 -6
  151. package/dist/securitySession.js +0 -72
  152. package/dist/upgradeNudge.d.ts +0 -25
  153. package/dist/utils.d.ts +0 -342
  154. package/dist/version.d.ts +0 -1
  155. package/dist/versionCheck.d.ts +0 -68
package/dist/hook.js CHANGED
@@ -35,31 +35,34 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.buildDiscoveryContext = buildDiscoveryContext;
38
+ const captureSignals_1 = require("./captureSignals");
39
+ const transcriptModels_1 = require("./transcriptModels");
38
40
  const fs = __importStar(require("fs"));
39
41
  const os = __importStar(require("os"));
42
+ const path = __importStar(require("path"));
40
43
  const https = __importStar(require("https"));
41
44
  const http = __importStar(require("http"));
42
45
  const child_process_1 = require("child_process");
43
46
  const utils_1 = require("./utils");
44
47
  const limitEventReporter_1 = require("./limitEventReporter");
48
+ const limitEvent_1 = require("./limitEvent");
49
+ const cursorLifecycle_1 = require("./cursorLifecycle");
50
+ const codexLimitScan_1 = require("./codexLimitScan");
51
+ const launchContext_1 = require("./launchContext");
52
+ const selfUpdate_1 = require("./selfUpdate");
45
53
  const version_1 = require("./version");
54
+ const sync_1 = require("./packs/sync");
46
55
  const captureLog_1 = require("./captureLog");
47
- const security_1 = require("./security");
48
- const securitySession_1 = require("./securitySession");
49
- const securityPolicy_1 = require("./securityPolicy");
50
- const securityFeature_1 = require("./securityFeature");
51
- const securityInventory_1 = require("./securityInventory");
56
+ const subagents_1 = require("./subagents");
52
57
  const captureExclusions_1 = require("./captureExclusions");
53
58
  const cursorHooks_1 = require("./cursorHooks");
54
- // Pure builder for the discovery additionalContext. Returns null when there are
55
- // no matches. The suggested `codiedev pull` command carries the EXACT top key
56
- // (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
57
- // paraphrase the key (pr-push) and the extension-sensitive `pull` 404s.
59
+ const copilotHooks_1 = require("./copilotHooks");
60
+ const piHooks_1 = require("./piHooks");
61
+ const cliSession_1 = require("./cliSession");
62
+ const utils_2 = require("./utils");
58
63
  function buildDiscoveryContext(skills) {
59
64
  if (skills.length === 0)
60
65
  return null;
61
- // Don't trust the API's ordering — rank by score so the suggested pull key is
62
- // always the highest-confidence match.
63
66
  const top = [...skills].sort((a, b) => b.score - a.score).slice(0, 3);
64
67
  const lines = top
65
68
  .map((s) => `- ${s.key} — ${(s.score * 100).toFixed(0)}% match`)
@@ -71,31 +74,27 @@ function buildDiscoveryContext(skills) {
71
74
  "`. It encodes the team's vetted " +
72
75
  "approach. Skip it only if it clearly doesn't apply.");
73
76
  }
74
- // UserPromptSubmit: query skill discovery for the prompt and, if a team skill
75
- // clears the confidence threshold, inject it as additionalContext so the agent
76
- // can offer to pull+run it. Fully silent on miss/error — never blocks input.
77
- async function runDiscover(prompt) {
77
+ async function discoverContext(prompt) {
78
78
  if (!prompt || !prompt.trim())
79
- process.exit(0);
79
+ return null;
80
80
  const config = (0, utils_1.readConfig)();
81
81
  if (!config)
82
- process.exit(0);
82
+ return null;
83
83
  try {
84
- // POST body, never a query param: raw prompts in GET query strings land
85
- // in server/proxy access logs (SIG-16). The server keeps accepting legacy
86
- // GETs from older CLIs during the transition.
87
84
  const res = await postJsonForResponse(`${config.backendUrl}/api/cli/skill-discovery`, { q: prompt }, config.token);
88
85
  const skills = res?.skills ?? [];
89
- const additionalContext = buildDiscoveryContext(skills);
90
- if (additionalContext) {
91
- process.stdout.write(JSON.stringify({
92
- hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext },
93
- }));
94
- }
86
+ return buildDiscoveryContext(skills);
95
87
  }
96
88
  catch {
97
- // silent — discovery is best-effort
98
89
  }
90
+ return null;
91
+ }
92
+ async function runDiscover(prompt) {
93
+ const additionalContext = await discoverContext(prompt);
94
+ if (additionalContext)
95
+ process.stdout.write(JSON.stringify({
96
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext },
97
+ }));
99
98
  process.exit(0);
100
99
  }
101
100
  function readTranscriptTailFile(transcriptPath, maxBytes = 512 * 1024) {
@@ -112,9 +111,6 @@ function readTranscriptTailFile(transcriptPath, maxBytes = 512 * 1024) {
112
111
  fs.closeSync(handle);
113
112
  }
114
113
  }
115
- // StopFailure (matcher rate_limit): the dev just got blocked. Read the local
116
- // transcript tail for the ground-truth rate_limit line and POST a tiny event.
117
- // Best-effort + silent — StopFailure output is ignored by Claude Code anyway.
118
114
  async function readAndPostLimitEvent(hookInput, source, emitMinimalOnMiss) {
119
115
  const config = (0, utils_1.readConfig)();
120
116
  if (!config)
@@ -123,12 +119,9 @@ async function readAndPostLimitEvent(hookInput, source, emitMinimalOnMiss) {
123
119
  const tp = hookInput.transcript_path;
124
120
  if (tp) {
125
121
  try {
126
- // Codex invokes Stop after every turn. Bound this read so long-running
127
- // sessions do not repeatedly load their entire rollout into memory.
128
122
  transcript = readTranscriptTailFile(tp);
129
123
  }
130
124
  catch {
131
- /* unreadable transcript — fall through to minimal event */
132
125
  }
133
126
  }
134
127
  try {
@@ -141,21 +134,81 @@ async function readAndPostLimitEvent(hookInput, source, emitMinimalOnMiss) {
141
134
  });
142
135
  }
143
136
  catch {
144
- /* best-effort */
145
137
  }
146
138
  }
147
139
  async function runStopFailure(hookInput) {
148
140
  await readAndPostLimitEvent(hookInput, "claude-code", true);
149
141
  process.exit(0);
150
142
  }
143
+ async function reportCodexLimitFromFile(config, transcriptPath, sessionId) {
144
+ const hit = (0, codexLimitScan_1.scanCodexRolloutForLimit)(transcriptPath);
145
+ if (!hit)
146
+ return false;
147
+ await (0, limitEventReporter_1.postLimitEvent)(config, {
148
+ source: "codex",
149
+ ...hit.event,
150
+ sessionId: sessionId ?? (0, codexLimitScan_1.sessionIdFromRolloutPath)(transcriptPath),
151
+ transcriptTail: (0, codexLimitScan_1.readTailEndingAt)(transcriptPath, hit.lineEnd),
152
+ });
153
+ (0, codexLimitScan_1.recordCodexLimitReported)(transcriptPath, hit.event.at);
154
+ return true;
155
+ }
151
156
  async function runCodexLimit(hookInput) {
152
- await readAndPostLimitEvent(hookInput, "codex", false);
157
+ const config = (0, utils_1.readConfig)();
158
+ const transcriptPath = hookInput.transcript_path;
159
+ if (config && transcriptPath) {
160
+ try {
161
+ const sent = await reportCodexLimitFromFile(config, transcriptPath, hookInput.session_id);
162
+ if (sent) {
163
+ (0, captureLog_1.appendCaptureLog)({
164
+ stage: "limit",
165
+ sessionId: hookInput.session_id,
166
+ outcome: "ok",
167
+ detail: "source=codex trigger=stop",
168
+ });
169
+ }
170
+ }
171
+ catch (err) {
172
+ (0, captureLog_1.appendCaptureLog)({
173
+ stage: "limit",
174
+ sessionId: hookInput.session_id,
175
+ outcome: "ERROR",
176
+ detail: `source=codex trigger=stop ${err.message}`,
177
+ });
178
+ }
179
+ }
153
180
  process.exit(0);
154
181
  }
155
- // Skip-reason ping: this session is ending WITHOUT a capture, and here's why.
156
- // Turns silent coverage loss into per-reason buckets on the admin Capture
157
- // Health panel (patches the session's aiSessionStarts row server-side). Tiny
158
- // like the sessionStart ping. Best-effort, never throws.
182
+ const PROMPT_LIMIT_POST_TIMEOUT_MS = 2_500;
183
+ async function runCodexLimitSweep(sessionId) {
184
+ const config = (0, utils_1.readConfig)();
185
+ if (!config)
186
+ return;
187
+ try {
188
+ const posted = await (0, codexLimitScan_1.sweepCodexLimits)({
189
+ sessionsDir: (0, codexLimitScan_1.codexSessionsDir)(),
190
+ post: (payload) => (0, limitEventReporter_1.postLimitEvent)(config, payload, PROMPT_LIMIT_POST_TIMEOUT_MS),
191
+ findOptions: { withinMs: 24 * 3600_000 },
192
+ scanOptions: { maxScanBytes: 4 * 1024 * 1024 },
193
+ });
194
+ if (posted > 0) {
195
+ (0, captureLog_1.appendCaptureLog)({
196
+ stage: "limit",
197
+ sessionId,
198
+ outcome: "ok",
199
+ detail: `source=codex trigger=prompt events=${posted}`,
200
+ });
201
+ }
202
+ }
203
+ catch (err) {
204
+ (0, captureLog_1.appendCaptureLog)({
205
+ stage: "limit",
206
+ sessionId,
207
+ outcome: "ERROR",
208
+ detail: `source=codex trigger=prompt ${err.message}`,
209
+ });
210
+ }
211
+ }
159
212
  async function postSessionSkip(config, sessionId, reason) {
160
213
  if (!sessionId)
161
214
  return;
@@ -163,17 +216,12 @@ async function postSessionSkip(config, sessionId, reason) {
163
216
  await postJson(`${config.backendUrl}/api/cli/sessionSkip`, { sessionId, reason, at: Date.now() }, config.token, PING_TIMEOUT_MS);
164
217
  }
165
218
  catch {
166
- /* best-effort */
167
219
  }
168
220
  }
169
- // SessionStart: the capture-coverage denominator. Record that a session opened
170
- // so the backend can later compute captured ÷ started. Deliberately tiny — no
171
- // transcript read, no git — so it never slows session startup. Best-effort +
172
- // silent (SessionStart hook output is ignored by Claude Code).
173
221
  async function runSessionStart(hookInput, source = "claude-code") {
174
222
  const config = (0, utils_1.readConfig)();
175
223
  if (!config)
176
- process.exit(0);
224
+ return;
177
225
  if (hookInput.session_id) {
178
226
  try {
179
227
  await postJson(`${config.backendUrl}/api/cli/sessionStart`, {
@@ -183,18 +231,11 @@ async function runSessionStart(hookInput, source = "claude-code") {
183
231
  }, config.token, PING_TIMEOUT_MS);
184
232
  }
185
233
  catch {
186
- /* best-effort — never block session startup */
187
234
  }
188
235
  }
189
- process.exit(0);
236
+ return;
190
237
  }
191
- function postJson(url, body, bearerToken,
192
- // Socket timeout. Pass a SMALL value for tiny pings that run on (or near)
193
- // Claude Code's shutdown clock — a black-holed connection (proxy dropping
194
- // packets without RST) otherwise hangs the hook until CC kills it. Leave
195
- // unset for transcript uploads, which legitimately take a while on slow
196
- // corporate links and already run in detached children with retry.
197
- timeoutMs) {
238
+ function postJson(url, body, bearerToken, timeoutMs) {
198
239
  return new Promise((resolve, reject) => {
199
240
  const parsed = new URL(url);
200
241
  const data = JSON.stringify(body);
@@ -235,9 +276,6 @@ timeoutMs) {
235
276
  req.end();
236
277
  });
237
278
  }
238
- // postJson variant that returns the parsed response body — the upload-URL
239
- // handshake needs the { uploadUrl } the server hands back. No timeout: runs
240
- // only in detached uploader children, never on the shutdown clock.
241
279
  function postJsonForResponse(url, body, bearerToken, timeoutMs) {
242
280
  return new Promise((resolve, reject) => {
243
281
  const parsed = new URL(url);
@@ -281,138 +319,6 @@ function postJsonForResponse(url, body, bearerToken, timeoutMs) {
281
319
  req.end();
282
320
  });
283
321
  }
284
- // Local policy runs synchronously before a tool executes. Workflow telemetry
285
- // is dispatched separately and fail-open, so a detector outage or timeout can
286
- // never delay or override the deterministic endpoint decision.
287
- async function runSecurityHook(hookInput, phase, source, uploadTelemetry = true) {
288
- const config = (0, utils_1.readConfig)();
289
- if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
290
- return;
291
- const policy = (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId);
292
- const sessionId = hookInput.session_id ?? hookInput.sessionId;
293
- const sessionSignal = phase === "pre" ? (0, securitySession_1.readSessionSecuritySignal)(sessionId) : undefined;
294
- const localDecision = (0, security_1.evaluateToolSecurity)(hookInput, phase, policy, source, sessionSignal);
295
- if (phase === "post" && (0, security_1.securityInputHasSensitivePath)(hookInput)) {
296
- (0, securitySession_1.recordSensitiveReadSignal)(sessionId);
297
- }
298
- const event = (0, security_1.buildSecurityWorkflowEvent)(hookInput, phase, source);
299
- if (event) {
300
- event.localDecision = localDecision.action;
301
- event.localCategory = localDecision.category;
302
- }
303
- if (localDecision.action === "block" && phase === "pre") {
304
- (0, captureLog_1.appendCaptureLog)({
305
- stage: "security",
306
- sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
307
- outcome: "ok",
308
- detail: `phase=${phase} localDecision=block category=${localDecision.category}`,
309
- });
310
- if (uploadTelemetry)
311
- dispatchSecurityEventUpload(event);
312
- const response = source === "cursor"
313
- ? (0, cursorHooks_1.cursorPreToolBlockResponse)(localDecision.reason ?? "Codie blocked this action")
314
- : {
315
- hookSpecificOutput: {
316
- hookEventName: "PreToolUse",
317
- permissionDecision: "deny",
318
- permissionDecisionReason: localDecision.reason,
319
- },
320
- };
321
- process.stdout.write(JSON.stringify(response));
322
- return;
323
- }
324
- if (!event)
325
- return;
326
- if (uploadTelemetry)
327
- dispatchSecurityEventUpload(event);
328
- }
329
- function runPromptSecurityHook(hookInput, source, uploadTelemetry = true) {
330
- const config = (0, utils_1.readConfig)();
331
- if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
332
- return false;
333
- const decision = (0, security_1.evaluatePromptSecurity)(hookInput.prompt, (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId), source);
334
- if (decision.action !== "block")
335
- return false;
336
- (0, captureLog_1.appendCaptureLog)({
337
- stage: "security",
338
- sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
339
- outcome: "ok",
340
- detail: `phase=prompt localDecision=block category=${decision.category} telemetry=${uploadTelemetry ? "enabled" : "suppressed"} cwd=${hookInput.cwd ?? "unknown"}`,
341
- });
342
- if (uploadTelemetry) {
343
- dispatchSecurityEventUpload((0, security_1.buildBlockedSecurityPromptEvent)(hookInput, source, decision.category ?? "secret_in_prompt"));
344
- }
345
- const response = source === "cursor"
346
- ? (0, cursorHooks_1.cursorPromptBlockResponse)(decision.reason ?? "Codie blocked this prompt")
347
- : { decision: "block", reason: decision.reason };
348
- process.stdout.write(JSON.stringify(response));
349
- return true;
350
- }
351
- function dispatchAllowedPromptSecurityEvent(hookInput, source) {
352
- const config = (0, utils_1.readConfig)();
353
- if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
354
- return;
355
- const event = (0, security_1.buildSecurityPromptEvent)(hookInput, source, Date.now, (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId));
356
- if (event)
357
- dispatchSecurityEventUpload(event);
358
- }
359
- function dispatchSecurityEventUpload(event) {
360
- if (!event || !(0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()))
361
- return;
362
- try {
363
- const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-upload"], {
364
- detached: true,
365
- stdio: ["pipe", "ignore", "ignore"],
366
- });
367
- child.stdin?.end(JSON.stringify(event));
368
- child.unref();
369
- }
370
- catch {
371
- // Telemetry is best-effort and must never delay local enforcement.
372
- }
373
- }
374
- async function runSecurityEventUpload() {
375
- const config = (0, utils_1.readConfig)();
376
- if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
377
- return;
378
- const startedAt = Date.now();
379
- try {
380
- const raw = await readStdin();
381
- const event = JSON.parse(raw);
382
- const cachedPolicy = (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId);
383
- const eventWithEndpoint = (0, security_1.withEndpointIdentity)(event, {
384
- endpointId: config.deviceId,
385
- endpointName: config.deviceName,
386
- cliVersion: version_1.CLI_VERSION,
387
- securityRuntimeVersion: security_1.SECURITY_RUNTIME_VERSION,
388
- securityPolicyVersion: cachedPolicy?.policyVersion,
389
- });
390
- const result = await postJsonForResponse(`${config.backendUrl}/api/cli/securityEvent`, { ...eventWithEndpoint }, config.token,
391
- // Leave room for a cold backend and durable scheduling. This uploader is
392
- // detached, so the network budget never delays the developer's prompt or
393
- // tool call. Policy refreshes use the separately signed sync endpoint.
394
- 5_000);
395
- if (result?.policy?.schemaVersion === 1) {
396
- (0, securityPolicy_1.writeEndpointSecurityPolicy)(result.policy, undefined, undefined, config.companyId);
397
- }
398
- (0, captureLog_1.appendCaptureLog)({
399
- stage: "security",
400
- sessionId: event.sessionId,
401
- outcome: "ok",
402
- detail: `phase=${event.phase} elapsedMs=${Date.now() - startedAt} verdict=${String(result?.verdict ?? "unknown")}`,
403
- });
404
- }
405
- catch (err) {
406
- (0, captureLog_1.appendCaptureLog)({
407
- stage: "security",
408
- sessionId: "upload",
409
- outcome: "ERROR",
410
- detail: `elapsedMs=${Date.now() - startedAt} ${err.message}`,
411
- });
412
- }
413
- }
414
- // Raw-bytes POST to a Convex storage upload URL (no auth header — the URL
415
- // itself is the one-time credential). Returns the parsed { storageId }.
416
322
  function postBytes(url, bytes, contentType) {
417
323
  return new Promise((resolve, reject) => {
418
324
  const parsed = new URL(url);
@@ -448,9 +354,6 @@ function postBytes(url, bytes, contentType) {
448
354
  req.end();
449
355
  });
450
356
  }
451
- // Short leash for the tiny coverage pings (sessionStart / sessionSkip): they
452
- // carry a few bytes and must never stall a hook. 3s is generous for a ping
453
- // and a hard bound for the shutdown clock.
454
357
  const PING_TIMEOUT_MS = 3000;
455
358
  async function readStdin() {
456
359
  return new Promise((resolve, reject) => {
@@ -468,50 +371,35 @@ function resolveMode(arg) {
468
371
  return "codex";
469
372
  if (arg === "capture-cursor" || arg === "backup-cursor")
470
373
  return "cursor";
374
+ if (arg === "capture-copilot" || arg === "backup-copilot")
375
+ return "copilot";
471
376
  if (arg === "capture-opencode" || arg === "capture-opencode-final")
472
377
  return "opencode";
378
+ if (arg === "capture-pi" || arg === "capture-pi-final")
379
+ return "pi";
473
380
  if (arg === "capture-desktop" || arg === "capture-desktop-final")
474
381
  return "desktop";
475
382
  return "claude-code";
476
383
  }
477
384
  async function main() {
478
- // Shared upload deps used by both "upload" and "upload-deferred" branches.
479
- // Extracted to keep both branches in lockstep — any new dep added here
480
- // automatically applies to the deferred path too.
385
+ await (0, cliSession_1.ensureFreshToken)({ configPath: utils_2.CONFIG_PATH }).catch(() => undefined);
481
386
  function makeUploadDeps() {
482
387
  return {
483
388
  post: postJson,
484
- // Direct-to-storage flow for big transcripts (see utils.ts
485
- // DIRECT_UPLOAD_THRESHOLD_CHARS): fetch a one-time upload URL, then
486
- // POST the raw bytes to it. Keeps ~7MB+ transcripts out of the
487
- // backend's 64MB captureSession isolate.
488
389
  requestJson: postJsonForResponse,
489
390
  uploadBytes: postBytes,
490
391
  readFileSync: fs.readFileSync,
491
392
  unlinkSync: fs.unlinkSync,
492
- // Persist a bumped attempt count on failure so a later sweep retries
493
- // (instead of dropping a session on a transient network blip).
494
393
  writeFileSync: fs.writeFileSync,
495
- // On a successful upload, drop the "last session captured" breadcrumb
496
- // that `codiedev doctor` reads to confirm the session landed.
497
394
  recordSuccess: (job) => (0, utils_1.writeLastCapture)((0, utils_1.buildLastCaptureRecord)(job, Date.now())),
498
- // Write structured outcome lines (ok / ERROR / gave-up) to the
499
- // rotating capture log so users and doctor can see why captures fail.
500
395
  log: (e) => (0, captureLog_1.appendCaptureLog)(e),
501
- // Write per-session upload state so the throttle can defer later
502
- // non-final uploads within the same window.
503
396
  writeSessionState: utils_1.writeSessionUploadState,
504
- // Re-read local config in the uploader so adding an exclusion after a
505
- // job was queued still prevents its transcript from leaving the device.
506
397
  shouldSkipJob: (job) => {
507
398
  const config = (0, utils_1.readConfig)();
508
399
  return (0, captureExclusions_1.shouldSkipCaptureUploadJob)(job, config);
509
400
  },
510
401
  };
511
402
  }
512
- // Detached uploader child (spawned by the capture path below). The job —
513
- // including transcript and token — is on disk; no stdin is needed, so handle
514
- // this before readStdin() to avoid blocking on the ignored stdin pipe.
515
403
  if (process.argv[2] === "upload") {
516
404
  const jobFile = process.argv[3];
517
405
  if (jobFile) {
@@ -519,10 +407,6 @@ async function main() {
519
407
  }
520
408
  process.exit(0);
521
409
  }
522
- // Deferred uploader child (spawned by runCaptureFinish when the per-turn
523
- // Stop capture is inside the throttle window). Sleeps for the remaining
524
- // delay, then claims the job file (if it hasn't been superseded by a newer
525
- // turn) and uploads. No stdin needed — handle before readStdin().
526
410
  if (process.argv[2] === "upload-deferred") {
527
411
  const jobFile = process.argv[3];
528
412
  const delayMs = Math.max(0, Number(process.argv[4]) || 0);
@@ -534,35 +418,11 @@ async function main() {
534
418
  }
535
419
  process.exit(0);
536
420
  }
537
- // Daily component drift scan runs in a detached hook process. The parent
538
- // SessionStart path only performs a timestamp read, so agent startup never
539
- // waits for filesystem traversal or an inventory upload.
540
- if (process.argv[2] === "security-inventory-upload") {
541
- const config = (0, utils_1.readConfig)();
542
- if (config && (0, securityFeature_1.isAgentSecurityEnabled)(config)) {
543
- const inventory = (0, securityInventory_1.buildSecurityInventory)({ workspace: process.argv[3] || process.cwd() });
544
- try {
545
- await (0, securityInventory_1.uploadSecurityInventory)(inventory, config);
546
- }
547
- catch { /* retry after the daily inventory window */ }
548
- }
549
- process.exit(0);
550
- }
551
- // Detached backup uploader child (spawned by runBackup below). The job file
552
- // carries only { root, files }; the child re-reads config from disk. No stdin
553
- // needed, so handle it before readStdin() like the capture uploader above.
554
421
  if (process.argv[2] === "backup-upload") {
555
422
  const { runBackupUpload } = await Promise.resolve().then(() => __importStar(require("./backup/run")));
556
423
  await runBackupUpload(process.argv[3]);
557
424
  process.exit(0);
558
425
  }
559
- // Detached capture-finish child (spawned by the SessionEnd capture path
560
- // below). The job carries only the raw hook inputs; this child does ALL the
561
- // heavy work — read the transcript, parse it, resolve git branch/SHAs (the
562
- // per-SHA `git rev-parse` calls that were overrunning CC's shutdown window
563
- // on Windows), build the payload, and dispatch the upload. Running here means
564
- // none of it is on the SessionEnd shutdown clock, so it can never be
565
- // "Hook cancelled". No stdin needed — handle it before readStdin().
566
426
  if (process.argv[2] === "capture-finish") {
567
427
  const jobFile = process.argv[3];
568
428
  if (jobFile) {
@@ -571,26 +431,25 @@ async function main() {
571
431
  await runCaptureFinish(job);
572
432
  }
573
433
  catch {
574
- // best-effort — never throw out of the detached child
575
434
  }
576
435
  finally {
577
436
  try {
578
437
  fs.unlinkSync(jobFile);
579
438
  }
580
439
  catch {
581
- /* ignore */
582
440
  }
583
441
  }
584
442
  }
585
443
  process.exit(0);
586
444
  }
587
- // Detached, best-effort security telemetry. Local policy decisions have
588
- // already completed in the parent hook; this child can never delay or alter
589
- // the developer's action.
590
- if (process.argv[2] === "security-upload") {
591
- await runSecurityEventUpload();
445
+ if (process.argv[2] === "self-update") {
446
+ await (0, selfUpdate_1.runSelfUpdate)(process.argv[1]);
592
447
  process.exit(0);
593
448
  }
449
+ const migrated = process.env.CODIEDEV_VIA_LAUNCHER ? [] : (0, utils_1.migrateHooksToLauncher)();
450
+ if (migrated.length === 0)
451
+ (0, selfUpdate_1.maybeSpawnSelfUpdate)(process.argv[1]);
452
+ (0, selfUpdate_1.maybeRefreshInstructions)();
594
453
  let hookInput = {};
595
454
  try {
596
455
  const raw = await readStdin();
@@ -598,60 +457,65 @@ async function main() {
598
457
  const parsed = JSON.parse(raw);
599
458
  hookInput = (process.argv[2]?.endsWith("-cursor")
600
459
  ? (0, cursorHooks_1.normalizeCursorHookInput)(parsed)
601
- : parsed);
460
+ : process.argv[2]?.endsWith("-copilot")
461
+ ? (0, copilotHooks_1.normalizeCopilotHookInput)(parsed)
462
+ : parsed);
602
463
  }
603
464
  }
604
465
  catch {
605
466
  process.exit(0);
606
467
  }
607
- // Privacy exclusions suppress every Codiedev upload, but they must never
608
- // disable local security enforcement. Security hooks run first with telemetry
609
- // disabled; all other hook modes still exit before making a network request.
610
- const captureIgnored = (0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput);
611
- const securityCommand = process.argv[2] ?? "";
612
- const securityMatch = securityCommand.match(/^security-(pre|post|failure)(?:-(codex|cursor|codie))?$/);
613
- if (securityMatch) {
614
- const source = securityMatch[2] === "codex"
615
- ? "codex"
616
- : securityMatch[2] === "cursor"
617
- ? "cursor"
618
- : securityMatch[2] === "codie"
619
- ? "codie-harness"
620
- : "claude-code";
621
- await runSecurityHook(hookInput, securityMatch[1], source, !captureIgnored);
468
+ if (process.argv[2]?.startsWith("security-"))
622
469
  process.exit(0);
623
- }
624
- // UserPromptSubmit discovery path — separate from session capture.
470
+ const captureIgnored = (0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput);
625
471
  const promptCommand = process.argv[2] ?? "";
626
- if (promptCommand === "security-prompt-cursor") {
627
- if (runPromptSecurityHook(hookInput, "cursor", !captureIgnored))
628
- return;
629
- if (captureIgnored)
630
- return;
631
- dispatchAllowedPromptSecurityEvent(hookInput, "cursor");
632
- process.exit(0);
633
- }
634
472
  if (process.argv[2] === "discover" ||
635
473
  process.argv[2] === "discover-codex" ||
636
- process.argv[2] === "discover-codie") {
474
+ process.argv[2] === "discover-codie" ||
475
+ process.argv[2] === "discover-copilot" ||
476
+ process.argv[2] === "discover-pi") {
637
477
  const source = promptCommand === "discover-codex"
638
478
  ? "codex"
639
479
  : promptCommand === "discover-codie"
640
480
  ? "codie-harness"
641
- : "claude-code";
642
- // Security runs first and locally. A blocked prompt is erased before it
643
- // reaches the model, so discovery must not send it to the backend.
644
- if (runPromptSecurityHook(hookInput, source, !captureIgnored))
645
- return;
481
+ : promptCommand === "discover-copilot"
482
+ ? "copilot"
483
+ : promptCommand === "discover-pi"
484
+ ? "pi"
485
+ : "claude-code";
646
486
  if (captureIgnored)
647
487
  return;
648
- dispatchAllowedPromptSecurityEvent(hookInput, source);
649
- await runDiscover(hookInput.prompt);
488
+ if (source === "codex")
489
+ await runCodexLimitSweep(hookInput.session_id);
490
+ if (source === "codex")
491
+ (0, sync_1.maybeSpawnPackSync)({ connected: Boolean((0, utils_1.readConfig)()), cli: path.join(__dirname, "cli.js") });
492
+ if (source !== "copilot")
493
+ await runDiscover(hookInput.prompt);
650
494
  return;
651
495
  }
652
496
  if (captureIgnored)
653
497
  process.exit(0);
654
- // StopFailure (matcher rate_limit): post the rate-limit event.
498
+ if (["session-start-cursor", "backup-cursor", "capture-cursor", "observe-cursor"].includes(process.argv[2])) {
499
+ try {
500
+ (0, cursorLifecycle_1.recordCursorLifecycle)(hookInput, process.argv[2] === "capture-cursor");
501
+ }
502
+ catch { }
503
+ }
504
+ if (process.argv[2] === "observe-cursor") {
505
+ process.stdout.write("{}");
506
+ return;
507
+ }
508
+ if (process.argv[2] === "limit-copilot") {
509
+ const config = (0, utils_1.readConfig)();
510
+ const event = (0, limitEvent_1.parseNativeLimitError)(hookInput);
511
+ if (config && event) {
512
+ try {
513
+ await (0, limitEventReporter_1.postLimitEvent)(config, { ...event, source: "copilot", sessionId: hookInput.session_id });
514
+ }
515
+ catch { }
516
+ }
517
+ return;
518
+ }
655
519
  if (process.argv[2] === "limit") {
656
520
  await runStopFailure(hookInput);
657
521
  return;
@@ -660,109 +524,100 @@ async function main() {
660
524
  await runCodexLimit(hookInput);
661
525
  return;
662
526
  }
663
- // SessionStart: post the capture-coverage denominator ping. Tiny + fast.
664
527
  if (process.argv[2] === "session-start" ||
665
528
  process.argv[2] === "session-start-cursor" ||
666
- process.argv[2] === "session-start-opencode") {
667
- if ((0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()) && (0, securityInventory_1.securityInventoryDue)()) {
668
- try {
669
- const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-inventory-upload", hookInput.cwd || process.cwd()], {
670
- detached: true,
671
- stdio: "ignore",
672
- });
673
- child.unref();
674
- }
675
- catch { /* daily inventory is best-effort */ }
676
- }
529
+ process.argv[2] === "session-start-copilot" ||
530
+ process.argv[2] === "session-start-opencode" ||
531
+ process.argv[2] === "session-start-pi") {
532
+ (0, sync_1.maybeSpawnPackSync)({ connected: Boolean((0, utils_1.readConfig)()), cli: path.join(__dirname, "cli.js") });
533
+ const packNag = process.argv[2] === "session-start-opencode" || process.argv[2] === "session-start-pi" ? null : (0, sync_1.packNagLine)();
534
+ if (packNag && process.argv[2] === "session-start")
535
+ process.stdout.write(`${packNag}\n`);
677
536
  await runSessionStart(hookInput, process.argv[2] === "session-start-opencode"
678
537
  ? "opencode"
679
538
  : process.argv[2] === "session-start-cursor"
680
539
  ? "cursor"
681
- : "claude-code");
540
+ : process.argv[2] === "session-start-copilot"
541
+ ? "copilot"
542
+ : process.argv[2] === "session-start-pi"
543
+ ? "pi"
544
+ : "claude-code");
545
+ if ((0, utils_1.readConfig)() && (process.argv[2] === "session-start-cursor" || process.argv[2] === "session-start-copilot")) {
546
+ const source = process.argv[2] === "session-start-cursor" ? "cursor" : "copilot";
547
+ const { TEAM_SKILL_CONTEXT, teamSkillSessionOutput } = await Promise.resolve().then(() => __importStar(require("./teamSkillContext")));
548
+ const prompt = hookInput.initialPrompt
549
+ ?? hookInput.initial_prompt;
550
+ const discovered = typeof prompt === "string" ? await discoverContext(prompt) : null;
551
+ process.stdout.write(JSON.stringify(teamSkillSessionOutput(source, [TEAM_SKILL_CONTEXT, discovered, packNag].filter(Boolean).join("\n\n"))));
552
+ }
682
553
  return;
683
554
  }
684
- // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
685
- // every codiedev hook — INCLUDING SessionEnd→capture, which previously had
686
- // no self-heal and could stay stripped forever while SessionStart kept
687
- // pinging (the Sonifi "starts arrive, captures never" loss) — re-wired on
688
- // their next Stop/SessionEnd. One read + one write, only when missing.
689
555
  if ((process.argv[2] ?? "").endsWith("-cursor")) {
690
556
  (0, utils_1.ensureCursorCodiedevHooksInstalled)();
691
557
  }
558
+ else if ((process.argv[2] ?? "").endsWith("-copilot")) {
559
+ (0, utils_1.ensureCopilotCodiedevHooksInstalled)();
560
+ }
561
+ else if (/-pi(?:-final)?$/.test(process.argv[2] ?? "")) {
562
+ (0, utils_1.ensurePiExtensionInstalled)();
563
+ }
692
564
  else {
693
565
  (0, utils_1.ensureAllCodiedevHooksInstalled)();
694
566
  }
695
567
  if (process.argv[2] === "capture-codex")
696
568
  (0, utils_1.ensureCodexLimitHookInstalled)();
569
+ if (/-opencode(?:-final)?$/.test(process.argv[2] ?? ""))
570
+ (0, utils_1.ensureOpenCodePluginCurrent)();
697
571
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
572
+ const launch = (0, launchContext_1.hookLaunch)(process.argv[2], hookInput, {
573
+ copilotClientName: process.argv[2]?.endsWith("-copilot")
574
+ ? (0, copilotHooks_1.readCopilotWorkspace)(transcript_path).clientName
575
+ : undefined,
576
+ });
698
577
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
699
578
  ? workspace_roots[0]
700
579
  : cwd || process.cwd();
701
- // Stop/sessionEnd backup path — separate from session capture and needs no
702
- // transcript. Lists changed artifact files in the workspace and dispatches a
703
- // detached child that backs them up privately. Best-effort, never blocks.
704
- //
705
- // Per-turn capture: Stop fires reliably in editor-embedded clients (VS Code
706
- // extension) where SessionEnd may never come — the silent zero-capture bug.
707
- // Same detached capture-finish child as SessionEnd, marked non-final so the
708
- // server renews the session lease instead of summarizing immediately.
709
- if (process.argv[2] === "backup" || process.argv[2] === "backup-cursor") {
710
- if (transcript_path) {
580
+ if (process.argv[2] === "backup" || process.argv[2] === "backup-cursor" || process.argv[2] === "backup-copilot" || process.argv[2] === "backup-pi") {
581
+ if (transcript_path && !(0, utils_1.isCodexRolloutPath)(transcript_path)) {
711
582
  try {
712
583
  (0, utils_1.dispatchCaptureFinish)({
713
- mode: process.argv[2] === "backup-cursor" ? "capture-cursor" : "capture",
584
+ mode: process.argv[2] === "backup-cursor"
585
+ ? "capture-cursor"
586
+ : process.argv[2] === "backup-copilot"
587
+ ? "capture-copilot"
588
+ : process.argv[2] === "backup-pi"
589
+ ? "capture-pi"
590
+ : "capture",
714
591
  session_id,
715
592
  transcript_path,
716
593
  cwd,
717
594
  workspace_roots,
718
- // non-final: server should renew the lease, not close it yet.
595
+ ...(launch ? { launch } : {}),
719
596
  final: false,
720
597
  }, process.argv[1], {
721
598
  spawn: child_process_1.spawn,
722
599
  writeFileSync: fs.writeFileSync,
723
600
  openSync: fs.openSync,
724
601
  closeSync: fs.closeSync,
725
- // UNIQUE per dispatch (sid+pid+ts): Stop and SessionEnd children
726
- // each own their input file exclusively — a shared per-session
727
- // name let one child's `finally` unlink eat the other's input
728
- // (lost final:true) or double-dispatch. See finishInputTmpFile.
729
602
  tmpFile: (0, utils_1.finishInputTmpFile)(session_id),
730
603
  });
731
604
  }
732
605
  catch {
733
- // capture is best-effort; never break backup
734
606
  }
735
607
  }
736
608
  const { runBackup } = await Promise.resolve().then(() => __importStar(require("./backup/run")));
737
609
  runBackup(workspaceRoot, process.argv[1]);
738
610
  process.exit(0);
739
611
  }
740
- // Capture path (SessionEnd). Do the ABSOLUTE MINIMUM inline so this hook
741
- // exits 0 within Claude Code's SessionEnd shutdown window: a cheap config /
742
- // transcript guard, then hand ALL the heavy work (transcript read + parse +
743
- // per-SHA `git rev-parse` + upload) to a detached `capture-finish` child.
744
- // Detaching only the upload (the old #240 fix) left the synchronous parse and
745
- // git work on the shutdown clock, which still overran on Windows — where each
746
- // subprocess spawn costs ~50-150ms — producing "Hook cancelled" even though
747
- // the data still landed. JS can't be interrupted mid-sync-chain, so the work
748
- // finished and the upload went through, but CC had already given up waiting.
749
612
  const config = (0, utils_1.readConfig)();
750
613
  if (!config) {
751
- // No config = no token; nothing can be sent, not even a skip ping.
752
614
  process.exit(0);
753
615
  }
754
616
  if (!transcript_path) {
755
- // Nothing to capture — but say so, instead of leaving a start ping with
756
- // no matching capture (silent coverage loss). One tiny POST; the rare
757
- // no-transcript case has no other work on the shutdown clock.
758
617
  await postSessionSkip(config, session_id, "no-transcript-path");
759
618
  process.exit(0);
760
619
  }
761
620
  try {
762
- // "codiedev-finish-" prefix (NOT "codiedev-capture-") so the sweep regex
763
- // /^codiedev-capture-.*\.json$/ never mistakes a finish-input file for an
764
- // upload job. UNIQUE per dispatch (sid+pid+ts) so a concurrent Stop-hook
765
- // child's `finally` unlink can never eat this input (lost final:true).
766
621
  const jobFile = (0, utils_1.finishInputTmpFile)(session_id);
767
622
  (0, utils_1.dispatchCaptureFinish)({
768
623
  mode: process.argv[2] ?? "capture",
@@ -770,9 +625,7 @@ async function main() {
770
625
  transcript_path,
771
626
  cwd,
772
627
  workspace_roots,
773
- // final:true for real SessionEnd signals (Claude Code and Cursor).
774
- // Codex routes its per-turn Stop hook through "capture-codex" —
775
- // final:true there would close the server lease every turn.
628
+ ...(launch ? { launch } : {}),
776
629
  final: (0, utils_1.resolveCaptureFinal)(process.argv[2]),
777
630
  }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile, openSync: fs.openSync, closeSync: fs.closeSync });
778
631
  }
@@ -781,19 +634,10 @@ async function main() {
781
634
  }
782
635
  process.exit(0);
783
636
  }
784
- // Runs inside the detached `capture-finish` child spawned by the SessionEnd
785
- // hook above. This is everything that used to run inline in main(): read the
786
- // transcript, resolve the repo, parse stats/usage/steering, resolve the git
787
- // branch + authored SHAs (the per-SHA `git rev-parse` calls that overran the
788
- // shutdown window on Windows), build the payload, and fire the upload from a
789
- // further-detached uploader. Running here means none of it is on the SessionEnd
790
- // shutdown clock, so it can never be "Hook cancelled". Best-effort throughout.
791
637
  async function runCaptureFinish(input) {
792
638
  const config = (0, utils_1.readConfig)();
793
639
  if (!config)
794
640
  return;
795
- // Re-check in the detached child: the user may have added an exclusion
796
- // after the parent hook dispatched this job but before it started.
797
641
  if ((0, captureExclusions_1.shouldIgnoreCaptureInput)(config, input)) {
798
642
  (0, captureLog_1.appendCaptureLog)({
799
643
  stage: "capture-finish",
@@ -808,8 +652,6 @@ async function runCaptureFinish(input) {
808
652
  await postSessionSkip(config, session_id, "no-transcript-path");
809
653
  return;
810
654
  }
811
- // Log that this child has started processing — the first diagnostic proof
812
- // that the detached capture-finish child actually ran (visible in doctor).
813
655
  (0, captureLog_1.appendCaptureLog)({ stage: "capture-finish", sessionId: session_id, outcome: "start" });
814
656
  try {
815
657
  await runCaptureFinishInner(input, config, session_id, transcript_path, cwd, workspace_roots);
@@ -825,10 +667,6 @@ async function runCaptureFinish(input) {
825
667
  }
826
668
  async function runCaptureFinishInner(input, config, session_id, transcript_path, cwd, workspace_roots) {
827
669
  const mode = resolveMode(input.mode);
828
- // Desktop transcripts (opencode SDK {info,parts}[] dumps) are byte-identical
829
- // to opencode's, so every parser below EXCEPT parseUsage treats desktop
830
- // exactly as opencode via this mapping. parseUsage is the one exception —
831
- // see the rawTranscriptContent note below.
832
670
  const parserMode = mode === "desktop" ? "opencode" : mode;
833
671
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
834
672
  ? workspace_roots[0]
@@ -837,11 +675,14 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
837
675
  let transcriptContent;
838
676
  try {
839
677
  transcriptContent = fs.readFileSync(transcript_path, "utf8");
678
+ if (mode === "copilot" && input.final === true && !(0, copilotHooks_1.hasCopilotShutdown)(transcriptContent)) {
679
+ for (let waited = 0; waited < COPILOT_SHUTDOWN_WAIT_MS && !(0, copilotHooks_1.hasCopilotShutdown)(transcriptContent); waited += 250) {
680
+ await new Promise((resolve) => setTimeout(resolve, 250));
681
+ transcriptContent = fs.readFileSync(transcript_path, "utf8");
682
+ }
683
+ }
840
684
  }
841
685
  catch (err) {
842
- // ENOENT → "no-transcript" (phantom desktop-app helper session, excluded
843
- // from coverage server-side); anything else → "transcript-unreadable:<code>"
844
- // (real transcript, read failed — a genuine, diagnosable loss).
845
686
  const reason = (0, utils_1.transcriptReadSkipReason)(err);
846
687
  (0, captureLog_1.appendCaptureLog)({
847
688
  stage: "capture-finish",
@@ -854,98 +695,93 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
854
695
  }
855
696
  return;
856
697
  }
857
- // Snapshot the RAW transcript before normalization below flattens it to
858
- // Claude Code's newline-JSONL shape — that flattening drops info.cost /
859
- // info.providerID, which desktop's parseUsage (below) needs and opencode's
860
- // doesn't. Every other mode leaves transcriptContent untouched here, so this
861
- // is a no-op for them.
862
698
  const rawTranscriptContent = transcriptContent;
699
+ let copilotSubagents;
700
+ const copilotWorkspace = mode === "copilot" ? (0, copilotHooks_1.readCopilotWorkspace)(transcript_path) : {};
863
701
  if (parserMode === "opencode") {
864
702
  transcriptContent = (0, utils_1.normalizeOpenCodeTranscript)(transcriptContent);
865
703
  }
866
704
  else if (parserMode === "cursor") {
867
705
  transcriptContent = (0, cursorHooks_1.normalizeCursorTranscript)(transcriptContent);
706
+ const observations = (0, cursorLifecycle_1.cursorLifecycleRecords)(session_id);
707
+ if (observations)
708
+ transcriptContent += `\n${observations}`;
709
+ }
710
+ else if (parserMode === "copilot") {
711
+ const normalized = (0, copilotHooks_1.normalizeCopilotTranscript)(transcriptContent, { clientName: copilotWorkspace.clientName });
712
+ transcriptContent = normalized.content;
713
+ copilotSubagents = normalized.subagents.count > 0 ? normalized.subagents : undefined;
714
+ }
715
+ else if (parserMode === "pi") {
716
+ transcriptContent = (0, piHooks_1.normalizePiTranscript)(transcriptContent);
717
+ }
718
+ if (mode === "cursor" || mode === "copilot" || mode === "pi") {
719
+ try {
720
+ await (0, limitEventReporter_1.reportTranscriptLimitEvent)({ config, source: mode, sessionId: session_id, transcript: transcriptContent });
721
+ }
722
+ catch { }
868
723
  }
869
- // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
870
- // need the git remote of the workspace.
871
724
  let remoteUrl = null;
872
725
  if (parserMode === "codex") {
873
726
  remoteUrl = (0, utils_1.extractCodexRepoUrl)(transcriptContent);
874
727
  }
875
- // The worktrees the session actually wrote to — resolved up front because
876
- // they both anchor the branch/sha join keys below AND back-fill the repo tag
877
- // when the launch dir is a multi-repo WORKSPACE root (a parent folder of
878
- // several cloned repos) that has no remote of its own.
879
728
  const captureDirs = (0, utils_1.resolveCaptureDirs)(transcriptContent, workspaceRoot);
880
729
  if (!remoteUrl) {
881
730
  remoteUrl = (0, utils_1.resolveCaptureRemoteUrl)(workspaceRoot, captureDirs);
882
731
  }
883
- // Capture-first: the transcript is the asset; the repo is just a tag. We do
884
- // NOT bail when `remoteUrl` is null — that previously dropped EVERY session
885
- // for users whose workspace couldn't resolve a remote (no git binary on lean
886
- // Alpine images, no `origin`, or a submodule whose parent .git isn't mounted),
887
- // with no error and a green `doctor`. Upload regardless; the backend accepts
888
- // an unmatched capture (repoId null) and surfaces it for triage.
889
- //
890
- // Best-effort local match — backend re-resolves from `remoteUrl` against the
891
- // company's current repo list, so a stale/empty local cache doesn't matter.
732
+ if (remoteUrl)
733
+ remoteUrl = (0, utils_1.stripRepoUrlCredentials)(remoteUrl);
892
734
  const matchedRepo = remoteUrl ? (0, utils_1.matchRepo)(remoteUrl, config.repos) : null;
893
735
  const stats = parserMode === "codex"
894
736
  ? (0, utils_1.parseCodexStats)(transcriptContent)
895
737
  : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
896
- // Cursor can emit duplicate lifecycle notifications while one transcript
897
- // path is still byte-empty. Never use parsed message count here: Cursor's
898
- // private JSONL shape can evolve independently, and parser drift must not
899
- // silently discard a real non-empty session.
900
- if (parserMode === "cursor" && (0, cursorHooks_1.isCursorTranscriptEmpty)(rawTranscriptContent)) {
738
+ const emptyReason = parserMode === "cursor" && (0, cursorHooks_1.isCursorTranscriptEmpty)(rawTranscriptContent)
739
+ ? "empty-cursor-transcript"
740
+ : parserMode === "copilot" && (0, copilotHooks_1.isCopilotTranscriptEmpty)(rawTranscriptContent)
741
+ ? "empty-copilot-transcript"
742
+ : parserMode === "pi" && (0, piHooks_1.isPiTranscriptEmpty)(rawTranscriptContent)
743
+ ? "empty-pi-transcript"
744
+ : null;
745
+ if (emptyReason) {
901
746
  (0, captureLog_1.appendCaptureLog)({
902
747
  stage: "capture-finish",
903
748
  sessionId: session_id,
904
749
  outcome: "skip",
905
- detail: "empty-cursor-transcript",
750
+ detail: emptyReason,
906
751
  });
907
- // A non-final stop snapshot is only a backup; reporting it as a skipped
908
- // session would close/remove server-side start coverage while the session
909
- // is still alive. The real SessionEnd remains responsible for a skip ping.
910
752
  if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
911
- await postSessionSkip(config, session_id, "empty-cursor-transcript");
753
+ await postSessionSkip(config, session_id, emptyReason);
912
754
  }
913
755
  return;
914
756
  }
915
757
  const transcriptBase64 = Buffer.from(transcriptContent, "utf8").toString("base64");
916
- // Session→merge join keys: the branch + HEAD sha the work happened on. Matched
917
- // server-side against a merged MR/PR's source_branch / commits for AI attribution.
918
- // Capture from the worktree the agent actually worked in (the launch dir stays
919
- // on main/detached when work happens in a `git worktree`), not workspaceRoot.
920
- // One session can touch several worktrees (→ several PRs); capture EVERY
921
- // worktree's branch+sha so all the resulting PRs attribute. branch/headSha
922
- // remain the primary (most-recent) values for back-compat.
923
758
  const keys = (0, utils_1.collectCaptureKeys)(captureDirs);
924
759
  const gitRefs = (0, utils_1.collectCaptureGitRefs)(captureDirs);
925
- // Union the worktree-dir branches with the branches the agent actually
926
- // committed/pushed to (parsed from the transcript's git output). Like the
927
- // SHAs below, these are independent of end-of-session filesystem state, so
928
- // they rescue branch-join when the worktree was REMOVED before SessionEnd
929
- // (the dominant miss: dir-resolution falls back to the parent's branch, never
930
- // the `worktree-<name>` one the PR was opened from). Indexed server-side under
931
- // every branch in the array, so the worktree's PR attributes regardless.
932
760
  const authoredBranches = (0, utils_1.parseAuthoredBranches)(transcriptContent);
933
- const branches = [...new Set([...keys.branches, ...authoredBranches])];
761
+ const recordedBranches = copilotWorkspace.branch ? [copilotWorkspace.branch] : [];
762
+ const branches = [...new Set([...keys.branches, ...authoredBranches, ...recordedBranches])];
934
763
  const branch = keys.branch ?? branches[0] ?? null;
935
- // Union the worktree HEAD shas with the SHAs the agent actually committed/
936
- // pushed (parsed from the transcript's git output, resolved via the shared
937
- // object store). The authored shas are independent of end-of-session
938
- // filesystem state, so they rescue attribution when a worktree is stale/gone.
939
764
  const authoredShas = (0, utils_1.parseAuthoredShas)(transcriptContent, workspaceRoot);
940
765
  const headShas = [...new Set([...keys.headShas, ...authoredShas])];
941
766
  const headSha = keys.headSha ?? headShas[0] ?? null;
942
767
  const pullRequestUrls = (0, utils_1.parsePullRequestUrls)(transcriptContent);
943
- // Cost/velocity/steering signals parsed straight from the transcript we're
944
- // already holding — no extra hooks, no extra git/process calls. parseUsage
945
- // keeps the literal mode (it has its own desktop parser) and reads the RAW
946
- // pre-normalization content for desktop; parseSteering routes through
947
- // parserMode like the parsers above.
948
768
  const usage = (0, utils_1.parseUsage)(mode === "desktop" ? rawTranscriptContent : transcriptContent, mode);
769
+ const subagents = mode === "claude-code"
770
+ ? (0, subagents_1.readSubagentTranscripts)(transcript_path, session_id)
771
+ : { contents: [], count: 0, truncated: false };
772
+ const subagentUsage = (0, subagents_1.foldSubagentModelUsage)([], subagents, mode);
773
+ for (const u of subagentUsage) {
774
+ usage.inputTokens = (usage.inputTokens ?? 0) + u.inputTokens;
775
+ usage.outputTokens = (usage.outputTokens ?? 0) + u.outputTokens;
776
+ usage.cacheReadTokens = (usage.cacheReadTokens ?? 0) + u.cacheReadTokens;
777
+ usage.cacheCreationTokens = (usage.cacheCreationTokens ?? 0) + u.cacheCreationTokens;
778
+ usage.cacheTokens = (usage.cacheTokens ?? 0) + u.cacheReadTokens + u.cacheCreationTokens;
779
+ }
780
+ const hasUsage = mode === "cursor" ? (0, cursorHooks_1.cursorTranscriptHasUsage)(transcriptContent)
781
+ : mode === "copilot" ? (0, transcriptModels_1.parseTranscriptModels)(transcriptContent, mode).some(model => model.tokensAvailable !== false)
782
+ : true;
783
+ if (mode === "cursor" && !usage.model)
784
+ usage.model = (0, cursorHooks_1.cursorObservedModel)(transcriptContent) ?? null;
949
785
  const sessionStartedAt = (0, utils_1.parseSessionStart)(transcriptContent);
950
786
  const steering = (0, utils_1.parseSteering)(transcriptContent, parserMode);
951
787
  const payload = (0, utils_1.buildCapturePayload)({
@@ -964,31 +800,26 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
964
800
  headShas,
965
801
  gitRefs,
966
802
  pullRequestUrls,
967
- inputTokens: usage.inputTokens,
968
- outputTokens: usage.outputTokens,
969
- cacheTokens: usage.cacheTokens,
970
- cacheReadTokens: usage.cacheReadTokens,
971
- cacheCreationTokens: usage.cacheCreationTokens,
803
+ inputTokens: hasUsage ? usage.inputTokens : undefined,
804
+ outputTokens: hasUsage ? usage.outputTokens : undefined,
805
+ cacheTokens: hasUsage ? usage.cacheTokens : undefined,
806
+ cacheReadTokens: hasUsage ? usage.cacheReadTokens : undefined,
807
+ cacheCreationTokens: hasUsage ? usage.cacheCreationTokens : undefined,
972
808
  model: usage.model,
809
+ captureSignals: withCopilotSubagents((0, subagents_1.foldSubagentSignals)((0, captureSignals_1.withLaunch)((0, captureSignals_1.parseCaptureSignals)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode), input.launch), subagents, mode), copilotSubagents),
810
+ modelUsage: (0, subagents_1.foldSubagentModelUsage)((0, transcriptModels_1.parseTranscriptModels)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode), subagents, mode),
973
811
  sessionStartedAt,
974
812
  interruptCount: steering.interrupts,
975
813
  rejectionCount: steering.rejections,
976
- // Carry the final flag into the HTTP body — the server uses it to decide
977
- // whether to close the session lease (true) or renew it (false/absent).
978
814
  final: input.final,
979
- // Desktop-only measured spend (undefined for every other source).
980
815
  costUsd: usage.costUsd,
981
816
  });
982
- // Throttle decision: final captures go immediately; non-final (per-turn Stop)
983
- // are deferred if we uploaded this session recently, to avoid per-turn churn.
984
817
  const uploadState = (0, utils_1.readSessionUploadState)(session_id ?? "");
985
818
  const decision = (0, utils_1.decideDispatch)({
986
819
  final: input.final === true,
987
820
  lastUploadAt: uploadState?.lastUploadAt ?? null,
988
821
  now: Date.now(),
989
822
  });
990
- // Log capture-finish outcome with dispatch context so doctor can see whether
991
- // the upload went immediately or was deferred (and by how much).
992
823
  (0, captureLog_1.appendCaptureLog)({
993
824
  stage: "capture-finish",
994
825
  sessionId: session_id,
@@ -997,11 +828,6 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
997
828
  ? `dispatch=deferred delayMs=${decision.delayMs} final=${input.final === true} repo=${remoteUrl ?? "-"} branch=${branch ?? "-"} msgs=${stats.messageCount}`
998
829
  : `dispatch=immediate final=${input.final === true} repo=${remoteUrl ?? "-"} branch=${branch ?? "-"} msgs=${stats.messageCount}`,
999
830
  });
1000
- // Fire the upload from a further-detached child. We're already off the
1001
- // shutdown clock here, but reusing dispatchCaptureDetached keeps the upload
1002
- // retry + last-capture breadcrumb logic (runCaptureUpload) intact verbatim.
1003
- // Per-session job file: one file per session; newer turns overwrite it,
1004
- // which is the "supersede" mechanism for the deferred path.
1005
831
  try {
1006
832
  const hookScriptPath = process.argv[1];
1007
833
  const tmpFile = (0, utils_1.captureUploadTmpFile)(session_id, decision.mode);
@@ -1012,21 +838,8 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
1012
838
  body: payload,
1013
839
  scopePaths,
1014
840
  }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
1015
- // The immediate payload is durable before removing the older scheduled
1016
- // snapshot. Do this for every immediate dispatch, not only SessionEnd:
1017
- // first/outside-window snapshots also supersede a sleeping deferred job.
1018
841
  if (session_id)
1019
842
  (0, utils_1.cancelDeferredCaptureUpload)(session_id);
1020
- // Retry any earlier capture jobs that failed to upload (transient network
1021
- // blips), excluding the one we just dispatched. Best-effort, detached.
1022
- // Skip the sweep on deferred path — the deferred child will handle uploading.
1023
- //
1024
- // Orphan pings are COLLECTED and awaited below: this child process calls
1025
- // process.exit(0) as soon as runCaptureFinish resolves, so a
1026
- // fire-and-forget POST here would be killed mid-flight — after the
1027
- // orphan file was already unlinked, i.e. the loss would stay invisible
1028
- // forever. The await is off the shutdown clock (detached child) and
1029
- // bounded by PING_TIMEOUT_MS.
1030
843
  const orphanPings = [];
1031
844
  (0, utils_1.sweepPendingCaptures)({
1032
845
  dir: os.tmpdir(),
@@ -1038,9 +851,6 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
1038
851
  exclude: tmpFile,
1039
852
  openSync: fs.openSync,
1040
853
  closeSync: fs.closeSync,
1041
- // Orphaned finish-inputs (capture-finish child died before writing an
1042
- // upload job): unrecoverable payload, but report the loss so it lands
1043
- // as a skip-reason instead of a silent coverage hole.
1044
854
  unlinkSync: fs.unlinkSync,
1045
855
  reportOrphan: (sid) => {
1046
856
  orphanPings.push(postSessionSkip(config, sid, "finish-orphaned"));
@@ -1049,9 +859,6 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
1049
859
  await Promise.all(orphanPings);
1050
860
  }
1051
861
  else {
1052
- // Deferred: write the job file (newer turns overwrite = supersede), then
1053
- // spawn the deferred-upload child which will sleep, claim, and upload.
1054
- // Same stdio/fd-hygiene contract as dispatchCaptureDetached.
1055
862
  (0, utils_1.dispatchDeferredUpload)({
1056
863
  url: `${config.backendUrl}/api/captureSession`,
1057
864
  token: config.token,
@@ -1064,6 +871,10 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
1064
871
  process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
1065
872
  }
1066
873
  }
874
+ const COPILOT_SHUTDOWN_WAIT_MS = 5000;
875
+ function withCopilotSubagents(signals, subagents) {
876
+ return subagents ? { ...signals, subagents } : signals;
877
+ }
1067
878
  main().catch(() => {
1068
879
  process.exit(0);
1069
880
  });