codiedev 0.9.60 → 0.9.62

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.
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.recordCursorLifecycle = recordCursorLifecycle;
37
+ exports.cursorLifecycleRecords = cursorLifecycleRecords;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const crypto = __importStar(require("crypto"));
41
+ const configDir_1 = require("./configDir");
42
+ const limitEvent_1 = require("./limitEvent");
43
+ /** Retain aborted-turn evidence across non-final captures, which are snapshots. */
44
+ function fileFor(sessionId) {
45
+ const key = crypto.createHash("sha256").update(sessionId).digest("hex");
46
+ return path.join((0, configDir_1.codiedevStateDir)(), "cursor-lifecycle", `${key}.json`);
47
+ }
48
+ function recordCursorLifecycle(input, final) {
49
+ if (typeof input.session_id !== "string")
50
+ return;
51
+ const status = input.status ?? input.reason;
52
+ const file = fileFor(input.session_id);
53
+ let state = { ids: [] };
54
+ try {
55
+ state = JSON.parse(fs.readFileSync(file, "utf8"));
56
+ }
57
+ catch { /* first observation */ }
58
+ const model = typeof input.model_id === "string" && input.model_id.trim() ? input.model_id
59
+ : typeof input.model === "string" && input.model.trim() ? input.model : undefined;
60
+ if (model)
61
+ state.model = model;
62
+ const event = typeof input.hook_event_name === "string" ? input.hook_event_name : final ? "sessionEnd" : "stop";
63
+ // sessionEnd can arrive long after the agent stopped. Do not count that
64
+ // window-open time as active work. These are observed hook times, not
65
+ // invented per-message timestamps.
66
+ if (event !== "sessionEnd" || !state.observations?.length) {
67
+ const timestamp = new Date().toISOString();
68
+ state.observations = [...(state.observations ?? []), { event, timestamp, ...(model ? { model } : {}) }].slice(-1000);
69
+ }
70
+ const limit = event === "postToolUse" || event === "postToolUseFailure" ? null : (0, limitEvent_1.parseNativeLimitError)(input);
71
+ if (limit) {
72
+ const generationId = typeof input.generation_id === "string" ? input.generation_id : undefined;
73
+ const last = state.limits?.at(-1);
74
+ const retry = last && (generationId ? last.generationId === generationId
75
+ : final && state.lastStatus === "error" && last.reason === limit.reason && last.resetText === limit.resetText);
76
+ if (!retry)
77
+ state.limits = [...(state.limits ?? []), { ...limit, ...(generationId ? { generationId } : {}) }].slice(-1000);
78
+ }
79
+ if (event === "subagentStart" && typeof input.subagent_id === "string") {
80
+ state.subagents = [...new Set([...(state.subagents ?? []), input.subagent_id])].slice(-1000);
81
+ }
82
+ if (event === "postToolUseFailure" && input.failure_type === "permission_denied") {
83
+ const rejectionId = typeof input.tool_use_id === "string" ? input.tool_use_id : `${Date.now()}-${state.rejections?.length ?? 0}`;
84
+ state.rejections = [...new Set([...(state.rejections ?? []), rejectionId])].slice(-1000);
85
+ }
86
+ const aborted = status === "aborted" || input.is_interrupt === true;
87
+ // sessionEnd after an aborted stop is the same interruption. A repeated
88
+ // stop with the same generation_id is likewise a retry, not another turn.
89
+ const id = typeof input.generation_id === "string" ? input.generation_id
90
+ : `${Date.now()}-${state.ids.length}`;
91
+ if (aborted && !(final && state.lastStatus === "aborted") && !state.ids.includes(id))
92
+ state.ids.push(id);
93
+ if (typeof status === "string")
94
+ state.lastStatus = status;
95
+ state.ids = state.ids.slice(-1000);
96
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
97
+ fs.writeFileSync(file, JSON.stringify(state), { mode: 0o600 });
98
+ }
99
+ function cursorLifecycleRecords(sessionId) {
100
+ if (!sessionId)
101
+ return "";
102
+ try {
103
+ const state = JSON.parse(fs.readFileSync(fileFor(sessionId), "utf8"));
104
+ return [...(state.observations ?? []).map((observation) => JSON.stringify({ type: "system", subtype: "cursor_hook_observation", ...observation })),
105
+ ...(state.model ? [JSON.stringify({ type: "system", subtype: "cursor_model", model: state.model })] : []),
106
+ ...(state.rejections ?? []).map(id => JSON.stringify({ type: "agent.rejection", id })),
107
+ ...(state.subagents?.length ? [JSON.stringify({ type: "cursor.subagent_summary", count: state.subagents.length, truncated: state.subagents.length >= 1000 })] : []),
108
+ ...state.ids.map((id) => JSON.stringify({ type: "agent.interruption", id })),
109
+ ...(state.limits ?? []).map(({ generationId: _generationId, ...event }) => JSON.stringify({ type: "agent.limit", ...event })),
110
+ ].join("\n");
111
+ }
112
+ catch {
113
+ return "";
114
+ }
115
+ }
package/dist/hook.js CHANGED
@@ -44,6 +44,8 @@ const http = __importStar(require("http"));
44
44
  const child_process_1 = require("child_process");
45
45
  const utils_1 = require("./utils");
46
46
  const limitEventReporter_1 = require("./limitEventReporter");
47
+ const limitEvent_1 = require("./limitEvent");
48
+ const cursorLifecycle_1 = require("./cursorLifecycle");
47
49
  const codexLimitScan_1 = require("./codexLimitScan");
48
50
  const launchContext_1 = require("./launchContext");
49
51
  const version_1 = require("./version");
@@ -51,6 +53,7 @@ const captureLog_1 = require("./captureLog");
51
53
  const subagents_1 = require("./subagents");
52
54
  const captureExclusions_1 = require("./captureExclusions");
53
55
  const cursorHooks_1 = require("./cursorHooks");
56
+ const copilotHooks_1 = require("./copilotHooks");
54
57
  // Pure builder for the discovery additionalContext. Returns null when there are
55
58
  // no matches. The suggested `codiedev pull` command carries the EXACT top key
56
59
  // (e.g. "pr-push.md") rather than a "<key>" placeholder — otherwise agents
@@ -74,28 +77,31 @@ function buildDiscoveryContext(skills) {
74
77
  // UserPromptSubmit: query skill discovery for the prompt and, if a team skill
75
78
  // clears the confidence threshold, inject it as additionalContext so the agent
76
79
  // can offer to pull+run it. Fully silent on miss/error — never blocks input.
77
- async function runDiscover(prompt) {
80
+ async function discoverContext(prompt) {
78
81
  if (!prompt || !prompt.trim())
79
- process.exit(0);
82
+ return null;
80
83
  const config = (0, utils_1.readConfig)();
81
84
  if (!config)
82
- process.exit(0);
85
+ return null;
83
86
  try {
84
87
  // POST body, never a query param: raw prompts in GET query strings land
85
88
  // in server/proxy access logs (SIG-16). The server keeps accepting legacy
86
89
  // GETs from older CLIs during the transition.
87
90
  const res = await postJsonForResponse(`${config.backendUrl}/api/cli/skill-discovery`, { q: prompt }, config.token);
88
91
  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
- }
92
+ return buildDiscoveryContext(skills);
95
93
  }
96
94
  catch {
97
95
  // silent — discovery is best-effort
98
96
  }
97
+ return null;
98
+ }
99
+ async function runDiscover(prompt) {
100
+ const additionalContext = await discoverContext(prompt);
101
+ if (additionalContext)
102
+ process.stdout.write(JSON.stringify({
103
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext },
104
+ }));
99
105
  process.exit(0);
100
106
  }
101
107
  function readTranscriptTailFile(transcriptPath, maxBytes = 512 * 1024) {
@@ -257,7 +263,7 @@ async function postSessionSkip(config, sessionId, reason) {
257
263
  async function runSessionStart(hookInput, source = "claude-code") {
258
264
  const config = (0, utils_1.readConfig)();
259
265
  if (!config)
260
- process.exit(0);
266
+ return;
261
267
  if (hookInput.session_id) {
262
268
  try {
263
269
  await postJson(`${config.backendUrl}/api/cli/sessionStart`, {
@@ -270,7 +276,7 @@ async function runSessionStart(hookInput, source = "claude-code") {
270
276
  /* best-effort — never block session startup */
271
277
  }
272
278
  }
273
- process.exit(0);
279
+ return;
274
280
  }
275
281
  function postJson(url, body, bearerToken,
276
282
  // Socket timeout. Pass a SMALL value for tiny pings that run on (or near)
@@ -417,6 +423,8 @@ function resolveMode(arg) {
417
423
  return "codex";
418
424
  if (arg === "capture-cursor" || arg === "backup-cursor")
419
425
  return "cursor";
426
+ if (arg === "capture-copilot" || arg === "backup-copilot")
427
+ return "copilot";
420
428
  if (arg === "capture-opencode" || arg === "capture-opencode-final")
421
429
  return "opencode";
422
430
  if (arg === "capture-desktop" || arg === "capture-desktop-final")
@@ -526,7 +534,9 @@ async function main() {
526
534
  const parsed = JSON.parse(raw);
527
535
  hookInput = (process.argv[2]?.endsWith("-cursor")
528
536
  ? (0, cursorHooks_1.normalizeCursorHookInput)(parsed)
529
- : parsed);
537
+ : process.argv[2]?.endsWith("-copilot")
538
+ ? (0, copilotHooks_1.normalizeCopilotHookInput)(parsed)
539
+ : parsed);
530
540
  }
531
541
  }
532
542
  catch {
@@ -539,16 +549,39 @@ async function main() {
539
549
  if ((0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput)) {
540
550
  process.exit(0);
541
551
  }
552
+ if (["session-start-cursor", "backup-cursor", "capture-cursor", "observe-cursor"].includes(process.argv[2])) {
553
+ try {
554
+ (0, cursorLifecycle_1.recordCursorLifecycle)(hookInput, process.argv[2] === "capture-cursor");
555
+ }
556
+ catch { /* best-effort local evidence */ }
557
+ }
558
+ if (process.argv[2] === "observe-cursor") {
559
+ process.stdout.write("{}");
560
+ return; // local evidence only
561
+ }
562
+ if (process.argv[2] === "limit-copilot") {
563
+ const config = (0, utils_1.readConfig)();
564
+ const event = (0, limitEvent_1.parseNativeLimitError)(hookInput);
565
+ if (config && event) {
566
+ try {
567
+ await (0, limitEventReporter_1.postLimitEvent)(config, { ...event, source: "copilot", sessionId: hookInput.session_id });
568
+ }
569
+ catch { /* best-effort */ }
570
+ }
571
+ return;
572
+ }
542
573
  // UserPromptSubmit discovery path — separate from session capture.
543
574
  if (process.argv[2] === "discover" ||
544
575
  process.argv[2] === "discover-codex" ||
545
- process.argv[2] === "discover-codie") {
576
+ process.argv[2] === "discover-codie" ||
577
+ process.argv[2] === "discover-copilot") {
546
578
  // A Codex block leaves no hook of its own, so this prompt is the first
547
579
  // chance to report it. Runs before runDiscover, which exits the process.
548
580
  if (process.argv[2] === "discover-codex") {
549
581
  await runCodexLimitSweep(hookInput.session_id);
550
582
  }
551
- await runDiscover(hookInput.prompt);
583
+ if (process.argv[2] !== "discover-copilot")
584
+ await runDiscover(hookInput.prompt);
552
585
  return;
553
586
  }
554
587
  // StopFailure (matcher rate_limit): post the rate-limit event.
@@ -563,12 +596,23 @@ async function main() {
563
596
  // SessionStart: post the capture-coverage denominator ping. Tiny + fast.
564
597
  if (process.argv[2] === "session-start" ||
565
598
  process.argv[2] === "session-start-cursor" ||
599
+ process.argv[2] === "session-start-copilot" ||
566
600
  process.argv[2] === "session-start-opencode") {
567
601
  await runSessionStart(hookInput, process.argv[2] === "session-start-opencode"
568
602
  ? "opencode"
569
603
  : process.argv[2] === "session-start-cursor"
570
604
  ? "cursor"
571
- : "claude-code");
605
+ : process.argv[2] === "session-start-copilot"
606
+ ? "copilot"
607
+ : "claude-code");
608
+ if ((0, utils_1.readConfig)() && (process.argv[2] === "session-start-cursor" || process.argv[2] === "session-start-copilot")) {
609
+ const source = process.argv[2] === "session-start-cursor" ? "cursor" : "copilot";
610
+ const { TEAM_SKILL_CONTEXT, teamSkillSessionOutput } = await Promise.resolve().then(() => __importStar(require("./teamSkillContext")));
611
+ const prompt = hookInput.initialPrompt
612
+ ?? hookInput.initial_prompt;
613
+ const discovered = typeof prompt === "string" ? await discoverContext(prompt) : null;
614
+ process.stdout.write(JSON.stringify(teamSkillSessionOutput(source, [TEAM_SKILL_CONTEXT, discovered].filter(Boolean).join("\n\n"))));
615
+ }
572
616
  return;
573
617
  }
574
618
  // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets
@@ -579,6 +623,9 @@ async function main() {
579
623
  if ((process.argv[2] ?? "").endsWith("-cursor")) {
580
624
  (0, utils_1.ensureCursorCodiedevHooksInstalled)();
581
625
  }
626
+ else if ((process.argv[2] ?? "").endsWith("-copilot")) {
627
+ (0, utils_1.ensureCopilotCodiedevHooksInstalled)();
628
+ }
582
629
  else {
583
630
  (0, utils_1.ensureAllCodiedevHooksInstalled)();
584
631
  }
@@ -587,7 +634,11 @@ async function main() {
587
634
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
588
635
  // Read here, in the process the harness spawned: the detached capture child
589
636
  // is reparented once this exits and can no longer see who launched it.
590
- const launch = (0, launchContext_1.hookLaunch)(process.argv[2], hookInput);
637
+ const launch = (0, launchContext_1.hookLaunch)(process.argv[2], hookInput, {
638
+ copilotClientName: process.argv[2]?.endsWith("-copilot")
639
+ ? (0, copilotHooks_1.readCopilotWorkspace)(transcript_path).clientName
640
+ : undefined,
641
+ });
591
642
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
592
643
  ? workspace_roots[0]
593
644
  : cwd || process.cwd();
@@ -599,13 +650,15 @@ async function main() {
599
650
  // extension) where SessionEnd may never come — the silent zero-capture bug.
600
651
  // Same detached capture-finish child as SessionEnd, marked non-final so the
601
652
  // server renews the session lease instead of summarizing immediately.
602
- if (process.argv[2] === "backup" || process.argv[2] === "backup-cursor") {
653
+ if (process.argv[2] === "backup" || process.argv[2] === "backup-cursor" || process.argv[2] === "backup-copilot") {
603
654
  if (transcript_path) {
604
655
  try {
605
656
  (0, utils_1.dispatchCaptureFinish)({
606
- // "capture" = the claude-code entrypoint literal, same as the
607
- // SessionEnd path below (resolveMode maps it to claude-code).
608
- mode: process.argv[2] === "backup-cursor" ? "capture-cursor" : "capture",
657
+ mode: process.argv[2] === "backup-cursor"
658
+ ? "capture-cursor"
659
+ : process.argv[2] === "backup-copilot"
660
+ ? "capture-copilot"
661
+ : "capture",
609
662
  session_id,
610
663
  transcript_path,
611
664
  cwd,
@@ -667,10 +720,9 @@ async function main() {
667
720
  cwd,
668
721
  workspace_roots,
669
722
  ...(launch ? { launch } : {}),
670
- // final:true ONLY for claude-code's SessionEnd. Codex routes its
671
- // per-turn Stop hook through "capture-codex" — final:true there would
672
- // close the server lease every turn. Cursor SessionEnd is final:true;
673
- // Codex remains the legacy final-omitted per-turn path.
723
+ // final:true for real SessionEnd signals (Claude Code, Cursor, Copilot).
724
+ // Codex routes its per-turn Stop hook through "capture-codex" —
725
+ // final:true there would close the server lease every turn.
674
726
  final: (0, utils_1.resolveCaptureFinal)(process.argv[2]),
675
727
  }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile, openSync: fs.openSync, closeSync: fs.closeSync });
676
728
  }
@@ -735,6 +787,15 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
735
787
  let transcriptContent;
736
788
  try {
737
789
  transcriptContent = fs.readFileSync(transcript_path, "utf8");
790
+ // Copilot writes session.shutdown (the token totals) just AFTER sessionEnd
791
+ // fires. This child is detached, so wait briefly for it on the final
792
+ // capture. VS Code may never write one; then tokens stay unreported.
793
+ if (mode === "copilot" && input.final === true && !(0, copilotHooks_1.hasCopilotShutdown)(transcriptContent)) {
794
+ for (let waited = 0; waited < COPILOT_SHUTDOWN_WAIT_MS && !(0, copilotHooks_1.hasCopilotShutdown)(transcriptContent); waited += 250) {
795
+ await new Promise((resolve) => setTimeout(resolve, 250));
796
+ transcriptContent = fs.readFileSync(transcript_path, "utf8");
797
+ }
798
+ }
738
799
  }
739
800
  catch (err) {
740
801
  // ENOENT → "no-transcript" (phantom desktop-app helper session, excluded
@@ -758,11 +819,27 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
758
819
  // doesn't. Every other mode leaves transcriptContent untouched here, so this
759
820
  // is a no-op for them.
760
821
  const rawTranscriptContent = transcriptContent;
822
+ let copilotSubagents;
823
+ const copilotWorkspace = mode === "copilot" ? (0, copilotHooks_1.readCopilotWorkspace)(transcript_path) : {};
761
824
  if (parserMode === "opencode") {
762
825
  transcriptContent = (0, utils_1.normalizeOpenCodeTranscript)(transcriptContent);
763
826
  }
764
827
  else if (parserMode === "cursor") {
765
828
  transcriptContent = (0, cursorHooks_1.normalizeCursorTranscript)(transcriptContent);
829
+ const observations = (0, cursorLifecycle_1.cursorLifecycleRecords)(session_id);
830
+ if (observations)
831
+ transcriptContent += `\n${observations}`;
832
+ }
833
+ else if (parserMode === "copilot") {
834
+ const normalized = (0, copilotHooks_1.normalizeCopilotTranscript)(transcriptContent, { clientName: copilotWorkspace.clientName });
835
+ transcriptContent = normalized.content;
836
+ copilotSubagents = normalized.subagents.count > 0 ? normalized.subagents : undefined;
837
+ }
838
+ if (mode === "cursor" || mode === "copilot") {
839
+ try {
840
+ await (0, limitEventReporter_1.reportTranscriptLimitEvent)({ config, source: mode, sessionId: session_id, transcript: transcriptContent });
841
+ }
842
+ catch { /* capture proceeds if limit reporting fails */ }
766
843
  }
767
844
  // Resolve repo URL. Codex embeds it in session_meta; CC and Cursor both
768
845
  // need the git remote of the workspace.
@@ -791,19 +868,26 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
791
868
  const stats = parserMode === "codex"
792
869
  ? (0, utils_1.parseCodexStats)(transcriptContent)
793
870
  : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
794
- // Never use parsed message count here: Cursor's private JSONL shape can
795
- // evolve independently, and parser drift must not discard a real session.
796
- if (parserMode === "cursor" && (0, cursorHooks_1.isCursorTranscriptEmpty)(rawTranscriptContent)) {
871
+ // Cursor can emit duplicate lifecycle notifications while one transcript
872
+ // path is still byte-empty. Never use parsed message count here: Cursor's
873
+ // private JSONL shape can evolve independently, and parser drift must not
874
+ // silently discard a real non-empty session.
875
+ const emptyReason = parserMode === "cursor" && (0, cursorHooks_1.isCursorTranscriptEmpty)(rawTranscriptContent)
876
+ ? "empty-cursor-transcript"
877
+ : parserMode === "copilot" && (0, copilotHooks_1.isCopilotTranscriptEmpty)(rawTranscriptContent)
878
+ ? "empty-copilot-transcript"
879
+ : null;
880
+ if (emptyReason) {
797
881
  (0, captureLog_1.appendCaptureLog)({
798
882
  stage: "capture-finish",
799
883
  sessionId: session_id,
800
884
  outcome: "skip",
801
- detail: "empty-cursor-transcript",
885
+ detail: emptyReason,
802
886
  });
803
887
  // A non-final stop snapshot is only a backup; reporting it as a skipped
804
888
  // session would close server-side coverage while the session is alive.
805
889
  if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
806
- await postSessionSkip(config, session_id, "empty-cursor-transcript");
890
+ await postSessionSkip(config, session_id, emptyReason);
807
891
  }
808
892
  return;
809
893
  }
@@ -825,7 +909,9 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
825
909
  // the `worktree-<name>` one the PR was opened from). Indexed server-side under
826
910
  // every branch in the array, so the worktree's PR attributes regardless.
827
911
  const authoredBranches = (0, utils_1.parseAuthoredBranches)(transcriptContent);
828
- const branches = [...new Set([...keys.branches, ...authoredBranches])];
912
+ // Copilot records the branch it started on in workspace.yaml.
913
+ const recordedBranches = copilotWorkspace.branch ? [copilotWorkspace.branch] : [];
914
+ const branches = [...new Set([...keys.branches, ...authoredBranches, ...recordedBranches])];
829
915
  const branch = keys.branch ?? branches[0] ?? null;
830
916
  // Union the worktree HEAD shas with the SHAs the agent actually committed/
831
917
  // pushed (parsed from the transcript's git output, resolved via the shared
@@ -855,6 +941,11 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
855
941
  usage.cacheCreationTokens = (usage.cacheCreationTokens ?? 0) + u.cacheCreationTokens;
856
942
  usage.cacheTokens = (usage.cacheTokens ?? 0) + u.cacheReadTokens + u.cacheCreationTokens;
857
943
  }
944
+ const hasUsage = mode === "cursor" ? (0, cursorHooks_1.cursorTranscriptHasUsage)(transcriptContent)
945
+ : mode === "copilot" ? (0, transcriptModels_1.parseTranscriptModels)(transcriptContent, mode).some(model => model.tokensAvailable !== false)
946
+ : true;
947
+ if (mode === "cursor" && !usage.model)
948
+ usage.model = (0, cursorHooks_1.cursorObservedModel)(transcriptContent) ?? null;
858
949
  const sessionStartedAt = (0, utils_1.parseSessionStart)(transcriptContent);
859
950
  const steering = (0, utils_1.parseSteering)(transcriptContent, parserMode);
860
951
  const payload = (0, utils_1.buildCapturePayload)({
@@ -873,13 +964,13 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
873
964
  headShas,
874
965
  gitRefs,
875
966
  pullRequestUrls,
876
- inputTokens: usage.inputTokens,
877
- outputTokens: usage.outputTokens,
878
- cacheTokens: usage.cacheTokens,
879
- cacheReadTokens: usage.cacheReadTokens,
880
- cacheCreationTokens: usage.cacheCreationTokens,
967
+ inputTokens: hasUsage ? usage.inputTokens : undefined,
968
+ outputTokens: hasUsage ? usage.outputTokens : undefined,
969
+ cacheTokens: hasUsage ? usage.cacheTokens : undefined,
970
+ cacheReadTokens: hasUsage ? usage.cacheReadTokens : undefined,
971
+ cacheCreationTokens: hasUsage ? usage.cacheCreationTokens : undefined,
881
972
  model: usage.model,
882
- captureSignals: (0, subagents_1.foldSubagentSignals)((0, captureSignals_1.withLaunch)((0, captureSignals_1.parseCaptureSignals)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode), input.launch), subagents, mode),
973
+ 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),
883
974
  modelUsage: (0, subagents_1.foldSubagentModelUsage)((0, transcriptModels_1.parseTranscriptModels)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode), subagents, mode),
884
975
  sessionStartedAt,
885
976
  interruptCount: steering.interrupts,
@@ -974,6 +1065,12 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
974
1065
  process.stderr.write(`[codiedev-hook] Warning: Failed to dispatch session capture — ${err.message}\n`);
975
1066
  }
976
1067
  }
1068
+ // How long the final Copilot capture waits for session.shutdown.
1069
+ const COPILOT_SHUTDOWN_WAIT_MS = 5000;
1070
+ /** Copilot fleet sub-agents run inside the one events.jsonl. */
1071
+ function withCopilotSubagents(signals, subagents) {
1072
+ return subagents ? { ...signals, subagents } : signals;
1073
+ }
977
1074
  main().catch(() => {
978
1075
  process.exit(0);
979
1076
  });
@@ -1,6 +1,14 @@
1
1
  export declare function codieLaunchFromArgv(argv: readonly string[]): string;
2
2
  /** One launching process's command line → a Cursor launch value. */
3
3
  export declare function cursorLaunchFromCommand(command: string): string | undefined;
4
+ /**
5
+ * One launching process's command line → a Copilot CLI launch value. The CLI
6
+ * runs as `…/@github/copilot-<platform>/copilot [flags]` under
7
+ * `node …/bin/copilot [flags]`; `-p`/`--prompt` is headless (scripted). VS
8
+ * Code's agent (`copilot-runtime --headless`) is named by workspace.yaml
9
+ * instead, so it never matches here.
10
+ */
11
+ export declare function copilotLaunchFromCommand(command: string): string | undefined;
4
12
  type Ps = (pid: number) => {
5
13
  ppid: number;
6
14
  command: string;
@@ -15,18 +23,24 @@ export declare function windowsAncestors(startPid: number, depth: number): Array
15
23
  ppid: number;
16
24
  command: string;
17
25
  }> | null;
18
- /**
19
- * Walk up to `depth` ancestors (a shell or `npx` usually sits between Cursor
20
- * and the hook) and return the first Cursor launch found.
21
- */
22
- export declare function detectCursorLaunch(startPid?: number, deps?: {
26
+ type LaunchDeps = {
23
27
  platform?: string;
24
28
  ps?: Ps;
25
29
  depth?: number;
26
30
  windows?: typeof windowsAncestors;
27
- }): string | undefined;
31
+ };
32
+ /**
33
+ * Walk up to `depth` ancestors (a shell or `npx` usually sits between Cursor
34
+ * and the hook) and return the first Cursor launch found.
35
+ */
36
+ export declare function detectCursorLaunch(startPid?: number, deps?: LaunchDeps): string | undefined;
37
+ /** The Copilot CLI that launched this hook (`copilot:print` / `copilot:cli`). */
38
+ export declare function detectCopilotLaunch(startPid?: number, deps?: LaunchDeps): string | undefined;
28
39
  /** The launch value a hook invocation should carry into its capture job. */
29
40
  export declare function hookLaunch(hookCommand: string | undefined, input: {
30
41
  launch?: unknown;
42
+ }, deps?: {
43
+ copilotClientName?: string;
44
+ detectCopilot?: () => string | undefined;
31
45
  }): string | undefined;
32
46
  export {};
@@ -2,15 +2,17 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.codieLaunchFromArgv = codieLaunchFromArgv;
4
4
  exports.cursorLaunchFromCommand = cursorLaunchFromCommand;
5
+ exports.copilotLaunchFromCommand = copilotLaunchFromCommand;
5
6
  exports.windowsAncestors = windowsAncestors;
6
7
  exports.detectCursorLaunch = detectCursorLaunch;
8
+ exports.detectCopilotLaunch = detectCopilotLaunch;
7
9
  exports.hookLaunch = hookLaunch;
8
10
  const node_child_process_1 = require("node:child_process");
9
11
  /**
10
12
  * How a harness that records nothing about its own launch was started.
11
13
  * Claude Code (`entrypoint`) and Codex (`session_meta.source`) stamp this in
12
- * the transcript; Cursor and Codie do not, so the hook reads it from the
13
- * process that launched it. Raw, namespaced values; what counts as
14
+ * the transcript; Cursor, Codie and the Copilot CLI do not, so the hook reads
15
+ * it from the process that launched it. Raw, namespaced values; what counts as
14
16
  * "automated" is decided in reporting, not here.
15
17
  */
16
18
  const VALUE = /^[a-z0-9][a-z0-9_.:-]{0,39}$/;
@@ -72,6 +74,45 @@ function cursorFlags(tokens) {
72
74
  }
73
75
  return flags;
74
76
  }
77
+ /** The Copilot CLI binary or its npm launcher (`copilot`, `copilot.exe`, `copilot.cmd`). */
78
+ const COPILOT_BIN = /(^|[/\\])copilot(\.(exe|cmd|ps1|js))?$/i;
79
+ const NODE_BIN = /(^|[/\\])node(\.exe)?$/i;
80
+ /**
81
+ * One launching process's command line → a Copilot CLI launch value. The CLI
82
+ * runs as `…/@github/copilot-<platform>/copilot [flags]` under
83
+ * `node …/bin/copilot [flags]`; `-p`/`--prompt` is headless (scripted). VS
84
+ * Code's agent (`copilot-runtime --headless`) is named by workspace.yaml
85
+ * instead, so it never matches here.
86
+ */
87
+ function copilotLaunchFromCommand(command) {
88
+ const tokens = tokenize(command);
89
+ // Only the program itself (or the script `node` runs): a shell command line
90
+ // that merely mentions "copilot" as a word is not the Copilot CLI.
91
+ const bin = COPILOT_BIN.test(tokens[0] ?? "")
92
+ ? 0
93
+ : NODE_BIN.test(tokens[0] ?? "") && COPILOT_BIN.test(tokens[1] ?? "")
94
+ ? 1
95
+ : -1;
96
+ if (bin < 0)
97
+ return undefined;
98
+ for (let i = bin + 1; i < tokens.length; i++) {
99
+ const t = tokens[i];
100
+ if (t === "-p" || t === "--prompt" || t.startsWith("--prompt="))
101
+ return "copilot:print";
102
+ // The prompt itself can mention "-p": stop at the first non-flag token.
103
+ if (!t.startsWith("-"))
104
+ break;
105
+ if (COPILOT_VALUE_FLAGS.has(t))
106
+ i++;
107
+ }
108
+ return "copilot:cli";
109
+ }
110
+ /** Copilot CLI flags that take a value; the token after them is not a flag. */
111
+ const COPILOT_VALUE_FLAGS = new Set([
112
+ "--model", "--add-dir", "--allow-tool", "--deny-tool", "--resume", "--log-dir",
113
+ "--log-level", "--agent", "--config-dir", "--allow-url", "--deny-url",
114
+ "--additional-mcp-config", "--stream", "--output-format",
115
+ ]);
75
116
  const psPosix = (pid) => {
76
117
  const result = (0, node_child_process_1.spawnSync)("ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 500 });
77
118
  const line = result.status === 0 ? result.stdout.trim() : "";
@@ -105,18 +146,15 @@ function windowsAncestors(startPid, depth) {
105
146
  return null;
106
147
  }
107
148
  }
108
- /**
109
- * Walk up to `depth` ancestors (a shell or `npx` usually sits between Cursor
110
- * and the hook) and return the first Cursor launch found.
111
- */
112
- function detectCursorLaunch(startPid = process.ppid, deps = {}) {
149
+ /** First launch value `fromCommand` finds among up to `depth` ancestors. */
150
+ function detectLaunch(fromCommand, startPid, deps) {
113
151
  const depth = deps.depth ?? 5;
114
152
  if ((deps.platform ?? process.platform) === "win32" && !deps.ps) {
115
153
  const chain = (deps.windows ?? windowsAncestors)(startPid, depth);
116
154
  if (!chain)
117
155
  return undefined;
118
156
  for (const row of chain) {
119
- const launch = cursorLaunchFromCommand(row.command);
157
+ const launch = fromCommand(row.command);
120
158
  if (launch)
121
159
  return launch;
122
160
  }
@@ -128,18 +166,35 @@ function detectCursorLaunch(startPid = process.ppid, deps = {}) {
128
166
  const row = ps(pid);
129
167
  if (!row)
130
168
  return undefined;
131
- const launch = cursorLaunchFromCommand(row.command);
169
+ const launch = fromCommand(row.command);
132
170
  if (launch)
133
171
  return launch;
134
172
  pid = row.ppid;
135
173
  }
136
174
  return undefined;
137
175
  }
176
+ /**
177
+ * Walk up to `depth` ancestors (a shell or `npx` usually sits between Cursor
178
+ * and the hook) and return the first Cursor launch found.
179
+ */
180
+ function detectCursorLaunch(startPid = process.ppid, deps = {}) {
181
+ return detectLaunch(cursorLaunchFromCommand, startPid, deps);
182
+ }
183
+ /** The Copilot CLI that launched this hook (`copilot:print` / `copilot:cli`). */
184
+ function detectCopilotLaunch(startPid = process.ppid, deps = {}) {
185
+ return detectLaunch(copilotLaunchFromCommand, startPid, deps);
186
+ }
138
187
  /** The launch value a hook invocation should carry into its capture job. */
139
- function hookLaunch(hookCommand, input) {
188
+ function hookLaunch(hookCommand, input, deps = {}) {
140
189
  if (typeof input.launch === "string" && VALUE.test(input.launch))
141
190
  return input.launch;
142
191
  if (hookCommand?.endsWith("-cursor"))
143
192
  return detectCursorLaunch();
193
+ if (hookCommand?.endsWith("-copilot")) {
194
+ // VS Code names itself in workspace.yaml; only the CLI needs the walk.
195
+ if (deps.copilotClientName === "vscode-agent-host")
196
+ return "copilot:vscode";
197
+ return (deps.detectCopilot ?? detectCopilotLaunch)();
198
+ }
144
199
  return undefined;
145
200
  }
@@ -9,4 +9,7 @@ export declare function classifyLimitKind(text: string): LimitKind;
9
9
  export declare function parseLimitEventFromTranscript(transcript: string): LimitEvent | null;
10
10
  /** Parse the structured rate-limit snapshot written to Codex rollout JSONL. */
11
11
  export declare function parseCodexLimitEventFromTranscript(transcript: string): LimitEvent | null;
12
+ /** Only explicit provider errors qualify; never scan user/assistant prose. */
13
+ export declare function parseNativeLimitError(input: Record<string, unknown>, now?: number): LimitEvent | null;
14
+ export declare function parseNativeLimitEventFromTranscript(transcript: string): LimitEvent | null;
12
15
  export declare function tailForLimitEvent(transcript: string, maxChars?: number): string;