codiedev 0.10.0-beta.4 → 0.10.0-beta.5

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