@bli-cockpit/cli 0.1.6 → 0.1.7

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.
@@ -4,7 +4,11 @@ import { createCollectorServer } from "../server.js";
4
4
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
5
5
  import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
6
6
  import { scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
7
- import { countStaleSessions, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
7
+ import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
8
+ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
9
+ import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
10
+ import { readLocalCollectorConfig } from "../local-state.js";
11
+ import { acquireSyncLock } from "../sync-lock.js";
8
12
  import { discoverGitWorktrees, } from "../repo-identity.js";
9
13
  export const rootCommandNames = new Set([
10
14
  "onboard",
@@ -15,6 +19,7 @@ export const rootCommandNames = new Set([
15
19
  "start",
16
20
  "sync",
17
21
  "status",
22
+ "sessions",
18
23
  "serve",
19
24
  ]);
20
25
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
@@ -48,6 +53,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
48
53
  return await runSync(command, io);
49
54
  case "status":
50
55
  return await runStatus(command, io);
56
+ case "sessions":
57
+ return await runSessions(command, io);
51
58
  case "serve":
52
59
  return await runServe(command, io);
53
60
  }
@@ -69,6 +76,7 @@ export function localCommandHelp(command) {
69
76
  " cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
70
77
  " cockpit sync [--repo <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
71
78
  " cockpit status [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
79
+ " cockpit sessions [--source codex|claude] [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
72
80
  " cockpit serve [--port <port>] [--repo <path>]",
73
81
  ].join("\n");
74
82
  }
@@ -124,9 +132,11 @@ function localSubcommandHelp(command) {
124
132
  "Usage: cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
125
133
  "",
126
134
  "Uploads latest local ambient envelope(s), or spools safe retries if blocked.",
127
- "Parent folders sync each child git worktree; Codex JSONL transcripts are attributed",
128
- "to repos deterministically and ambiguous transcripts are retained as unattributed",
129
- "instead of being duplicated across repos.",
135
+ "Parent folders sync each child git worktree; Codex AND Claude Code JSONL",
136
+ "transcripts (and Claude subagent sidecars) are attributed to repos",
137
+ "deterministically and ambiguous transcripts are retained as unattributed",
138
+ "instead of being duplicated across repos. Use `cockpit sessions` to see why",
139
+ "a session is or is not collected.",
130
140
  "Newly discovered repos get a general ambient work context automatically.",
131
141
  "Discovery scans 3 folder levels and up to 50 repos by default; tune with",
132
142
  "--max-depth and --max-repos.",
@@ -140,6 +150,17 @@ function localSubcommandHelp(command) {
140
150
  "Prints install, pairing, active work, upload, and retry state.",
141
151
  ],
142
152
  ],
153
+ [
154
+ "sessions",
155
+ [
156
+ "Usage: cockpit sessions [--source codex|claude] [--repo <path>] [--json]",
157
+ "",
158
+ "Read-only: re-runs Codex + Claude session attribution and prints each",
159
+ "session's id, source, state, reason, scores, signals, and per-sidecar",
160
+ "skip reasons. No upload, no cursor writes. Answers \"why is session X",
161
+ "missing?\" locally — counts and labels only, never paths or content.",
162
+ ],
163
+ ],
143
164
  [
144
165
  "serve",
145
166
  [
@@ -175,6 +196,8 @@ function parseLocalArgs(argv) {
175
196
  return parseSyncArgs(argv.slice(1));
176
197
  case "status":
177
198
  return parseStatusArgs(argv.slice(1));
199
+ case "sessions":
200
+ return parseSessionsArgs(argv.slice(1));
178
201
  case "serve":
179
202
  return parseServeArgs(argv.slice(1));
180
203
  default:
@@ -362,6 +385,26 @@ function parseStatusArgs(args) {
362
385
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
363
386
  };
364
387
  }
388
+ function parseSessionsArgs(args) {
389
+ const values = parseNamedArgs(args, {
390
+ allowedFlags: ["--home", "--repo", "--source", "--json", "--max-depth", "--max-repos"],
391
+ valueFlags: ["--home", "--repo", "--source", "--max-depth", "--max-repos"],
392
+ });
393
+ assertNoPositionals(values.positionals, "sessions");
394
+ const source = values.flags.get("--source");
395
+ if (source !== undefined && source !== "codex" && source !== "claude") {
396
+ throw new Error("--source must be 'codex' or 'claude'.");
397
+ }
398
+ return {
399
+ kind: "sessions",
400
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
401
+ repoRoot: optionalNonEmpty(values.flags.get("--repo")),
402
+ source,
403
+ json: values.booleans.has("--json"),
404
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
405
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
406
+ };
407
+ }
365
408
  function parseServeArgs(args) {
366
409
  const values = parseNamedArgs(args, {
367
410
  allowedFlags: ["--home", "--repo", "--port"],
@@ -554,8 +597,7 @@ async function runOnboard(command, io) {
554
597
  writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
555
598
  writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
556
599
  writeLine(io.stdout, rawEvidenceSyncLine(sync));
557
- writeLine(io.stdout, codexSummaryLine(run.summary));
558
- writeLine(io.stdout, attributionReportLine(run.summary));
600
+ writeAgentSessionSummary(io, run.summary);
559
601
  writeLine(io.stdout, "5/5 Status ready.");
560
602
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
561
603
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
@@ -616,21 +658,68 @@ async function readOnboardSessionReuseCandidate(homeDir) {
616
658
  function normalizeUrlForComparison(value) {
617
659
  return value ? normalizeUrl(value) : null;
618
660
  }
661
+ const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
662
+ const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
663
+ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
619
664
  /**
620
- * Shared sync orchestration for single-repo and parent-folder modes: Codex
621
- * sessions are scanned and attributed once across every discovered worktree,
622
- * each worktree syncs with only its own attributed transcripts, and the
623
- * ambiguous/unattributed/skipped remainder is reported with reason labels
624
- * instead of being duplicated into every repo or silently dropped.
665
+ * Shared dual-source sync orchestration for single-repo and parent-folder
666
+ * modes. Codex AND Claude Code sessions are scanned and attributed once across
667
+ * every discovered worktree; each worktree syncs with only its own attributed
668
+ * transcripts (codex + claude main + sidecars), and the
669
+ * ambiguous/unattributed/skipped remainder is reported with reason labels and a
670
+ * `source` discriminator instead of being duplicated into every repo or
671
+ * silently dropped. The session row's upload state maps ONLY from the main-file
672
+ * outcome (D3); sidecar outcomes aggregate into CLI counts.
625
673
  */
626
674
  async function runAttributedWorktreeSync(options) {
627
675
  const now = new Date();
628
676
  const homeDir = options.homeDir ?? os.homedir();
629
- const attribution = await scanAndAttributeCodexSessions({
677
+ const paths = getCollectorRuntimePaths(options.homeDir);
678
+ const claudeEnabled = await isClaudeCollectionEnabled(paths);
679
+ const codexAttribution = await scanAndAttributeCodexSessions({
630
680
  sessionsDir: path.join(homeDir, ".codex", "sessions"),
631
681
  worktrees: options.worktrees,
632
682
  now,
633
683
  });
684
+ // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
685
+ // days so the first sync captures retroactive history instead of only 24h.
686
+ const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
687
+ const firstRunBackfill = claudeEnabled && !claudeCursorExists;
688
+ const claudeAttribution = claudeEnabled
689
+ ? await scanAndAttributeClaudeSessions({
690
+ projectsDir: path.join(homeDir, ".claude", "projects"),
691
+ worktrees: options.worktrees,
692
+ now,
693
+ sinceMinutes: firstRunBackfill
694
+ ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
695
+ : undefined,
696
+ })
697
+ : emptyClaudeScan();
698
+ // Read the Claude cursor up front: damping decisions need prior upload state.
699
+ const claudeCursorBefore = claudeEnabled
700
+ ? await readRawEvidenceCursor(paths, {
701
+ filename: CLAUDE_CURSOR_FILENAME,
702
+ }).catch(() => emptyRawEvidenceCursorState())
703
+ : emptyRawEvidenceCursorState();
704
+ // sessionId -> prior durable pointer for damped sessions (drives the
705
+ // growth_damped count + the skip_main decision).
706
+ const dampedClaudePointers = new Map();
707
+ // sessionId -> prior durable pointer for EVERY already-durable Claude session
708
+ // (superset of damped). A session that was durable before but had no fresh
709
+ // upload this sync (damped, spooled, budget-deferred) reports reused_existing
710
+ // with this pointer instead of not_uploaded, so the store row never flips.
711
+ const claudePriorDurablePointers = new Map();
712
+ for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
713
+ if (entry.uploaded_object_key) {
714
+ claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
715
+ }
716
+ }
717
+ // One shared budget for the whole sync (D7b is per-sync): a parent-folder
718
+ // sync over many worktrees honors a single byte/object cap rather than N×.
719
+ const rawEvidenceBudget = {
720
+ remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
721
+ remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
722
+ };
634
723
  const outcomes = [];
635
724
  let ok = true;
636
725
  for (const worktree of options.worktrees) {
@@ -645,11 +734,32 @@ async function runAttributedWorktreeSync(options) {
645
734
  sessionId: options.sessionId,
646
735
  });
647
736
  }
737
+ const claudeSessionFiles = claudeAttribution.results
738
+ .filter((result) => result.state === "attributed" &&
739
+ result.worktree?.worktree_fingerprint ===
740
+ worktree.worktree_fingerprint)
741
+ .map((result) => {
742
+ const damped = !result.main_file_oversized &&
743
+ shouldDampClaudeMain(result, claudeCursorBefore, now);
744
+ if (damped) {
745
+ dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
746
+ ?.uploaded_object_key ?? null);
747
+ }
748
+ return {
749
+ local_path: result.file_path,
750
+ claude_session_id: result.claude_session_id,
751
+ main_file_oversized: result.main_file_oversized,
752
+ skip_main: damped,
753
+ sidecar_files: result.sidecar_files
754
+ .filter((sidecar) => !sidecar.skipped_reason)
755
+ .map((sidecar) => ({ local_path: sidecar.local_path })),
756
+ };
757
+ });
648
758
  const syncOptions = {
649
759
  homeDir: options.homeDir,
650
760
  repoRoot: worktree.repo_root,
651
761
  dashboardUrl: options.dashboardUrl,
652
- codexSessionFiles: attribution.results
762
+ codexSessionFiles: codexAttribution.results
653
763
  .filter((result) => result.state === "attributed" &&
654
764
  result.worktree?.worktree_fingerprint ===
655
765
  worktree.worktree_fingerprint)
@@ -657,6 +767,8 @@ async function runAttributedWorktreeSync(options) {
657
767
  local_path: result.file_path,
658
768
  codex_session_id: result.codex_session_id,
659
769
  })),
770
+ claudeSessionFiles,
771
+ rawEvidenceBudget,
660
772
  fetch: options.fetchImpl,
661
773
  };
662
774
  let sync;
@@ -684,39 +796,54 @@ async function runAttributedWorktreeSync(options) {
684
796
  ok = ok && sync.status === "uploaded";
685
797
  outcomes.push({ worktree, context, sync });
686
798
  }
687
- const sessions = buildCodexSessionReport(attribution.results, outcomes, now);
799
+ const sessions = buildAgentSessionReport({
800
+ codexResults: codexAttribution.results,
801
+ claudeResults: claudeAttribution.results,
802
+ outcomes,
803
+ now,
804
+ claudePriorDurablePointers,
805
+ });
688
806
  // The sessions cursor is an optimization; a broken local state dir must not
689
807
  // turn already-completed syncs into a CLI crash.
690
- let staleSessionCount = 0;
808
+ let codexStaleCount = 0;
809
+ let claudeStaleCount = 0;
691
810
  try {
692
- const paths = getCollectorRuntimePaths(options.homeDir);
693
- const cursor = await readRawEvidenceCursor(paths);
694
- const seenSessionIds = new Set(attribution.results.map((result) => result.codex_session_id));
695
- staleSessionCount = countStaleSessions(cursor, seenSessionIds);
696
- for (const result of attribution.results) {
697
- const reported = sessions.find((session) => session.codex_session_id === result.codex_session_id);
698
- const sessionDurable = reported?.upload_state === "uploaded" ||
699
- reported?.upload_state === "reused_existing";
700
- recordSessionObservation(cursor, result.codex_session_id, {
701
- file_hash_sha256: result.content_hash_sha256,
702
- file_mtime_ms: result.session_file_mtime_ms,
703
- byte_size: result.byte_size,
704
- byte_offset: sessionDurable ? result.byte_size : 0,
705
- state: result.state,
706
- reason: result.reason,
707
- worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
708
- uploaded_object_key: sessionDurable
709
- ? (reported?.raw_evidence_pointer_id ?? null)
710
- : null,
711
- last_seen_at: now.toISOString(),
712
- });
713
- }
714
- cursor.updated_at = now.toISOString();
715
- await writeRawEvidenceCursor(paths, cursor);
811
+ const codexCursor = await readRawEvidenceCursor(paths);
812
+ codexStaleCount = recordSourceObservations({
813
+ cursor: codexCursor,
814
+ results: codexAttribution.results,
815
+ sessions,
816
+ source: "codex",
817
+ sessionIdOf: (result) => result.codex_session_id,
818
+ now,
819
+ });
820
+ codexCursor.updated_at = now.toISOString();
821
+ await writeRawEvidenceCursor(paths, codexCursor);
716
822
  }
717
823
  catch {
718
824
  // Best-effort: stale counts read 0 and observations re-record next sync.
719
825
  }
826
+ if (claudeEnabled) {
827
+ try {
828
+ claudeStaleCount = recordSourceObservations({
829
+ cursor: claudeCursorBefore,
830
+ results: claudeAttribution.results,
831
+ sessions,
832
+ source: "claude_code",
833
+ sessionIdOf: (result) => result.claude_session_id,
834
+ now,
835
+ priorCursor: claudeCursorBefore,
836
+ });
837
+ claudeCursorBefore.updated_at = now.toISOString();
838
+ await writeRawEvidenceCursor(paths, claudeCursorBefore, {
839
+ filename: CLAUDE_CURSOR_FILENAME,
840
+ sessionsOnly: true,
841
+ });
842
+ }
843
+ catch {
844
+ // Best-effort: a broken Claude cursor must not fail the sync.
845
+ }
846
+ }
720
847
  const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
721
848
  const report = firstUploaded
722
849
  ? await postCodexSessionReport({
@@ -731,21 +858,17 @@ async function runAttributedWorktreeSync(options) {
731
858
  posted: false,
732
859
  reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
733
860
  };
734
- return {
735
- ok,
861
+ const summary = buildAgentSessionSummary({
862
+ codexAttribution,
863
+ claudeAttribution,
736
864
  outcomes,
737
- attribution,
738
- summary: {
739
- scanned: attribution.scanned_file_count,
740
- attributed: attribution.counts.attributed,
741
- ambiguous: attribution.counts.ambiguous,
742
- unattributed: attribution.counts.unattributed,
743
- skipped: attribution.counts.skipped,
744
- stale: staleSessionCount,
745
- report_posted: report.posted,
746
- report_reason: report.reason,
747
- },
748
- };
865
+ codexStaleCount,
866
+ claudeStaleCount,
867
+ firstRunBackfill,
868
+ growthDamped: dampedClaudePointers.size,
869
+ report,
870
+ });
871
+ return { ok, outcomes, codexAttribution, claudeAttribution, summary };
749
872
  }
750
873
  const ATTRIBUTION_STATE_RANK = {
751
874
  attributed: 3,
@@ -753,38 +876,100 @@ const ATTRIBUTION_STATE_RANK = {
753
876
  unattributed: 1,
754
877
  skipped: 0,
755
878
  };
756
- function buildCodexSessionReport(results, outcomes, now) {
757
- // Resumed/forked sessions can span multiple files with the same session id;
758
- // report each session once, preferring the strongest attribution and the
759
- // freshest file.
760
- const bestBySessionId = new Map();
761
- for (const result of results) {
762
- const existing = bestBySessionId.get(result.codex_session_id);
879
+ function normalizeCodexResult(result) {
880
+ return {
881
+ source: "codex",
882
+ session_id: result.codex_session_id,
883
+ state: result.state,
884
+ reason: result.reason,
885
+ signals: result.signals,
886
+ attribution_score: result.attribution_score,
887
+ path_score: result.path_score,
888
+ content_hash_sha256: result.content_hash_sha256,
889
+ byte_size: result.byte_size,
890
+ session_file_mtime: result.session_file_mtime,
891
+ session_file_mtime_ms: result.session_file_mtime_ms,
892
+ worktree: result.worktree,
893
+ cwd_basename: result.cwd_basename,
894
+ cwd_hash: result.cwd_hash,
895
+ };
896
+ }
897
+ function normalizeClaudeResult(result) {
898
+ return {
899
+ source: "claude_code",
900
+ session_id: result.claude_session_id,
901
+ state: result.state,
902
+ reason: result.reason,
903
+ signals: result.signals,
904
+ attribution_score: result.attribution_score,
905
+ path_score: result.path_score,
906
+ content_hash_sha256: result.content_hash_sha256,
907
+ byte_size: result.byte_size,
908
+ session_file_mtime: result.session_file_mtime,
909
+ session_file_mtime_ms: result.session_file_mtime_ms,
910
+ worktree: result.worktree,
911
+ cwd_basename: result.cwd_basename,
912
+ cwd_hash: result.cwd_hash,
913
+ };
914
+ }
915
+ /**
916
+ * Generalizes the per-session report across sources. Dedupe is per
917
+ * `(source, session_id)` so a Codex session and a Claude session that happen to
918
+ * share an id are never collapsed. Upload state maps ONLY from the main-file
919
+ * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
920
+ * session's upload state (D3). Damped Claude sessions report `reused_existing`
921
+ * carrying their prior durable pointer.
922
+ */
923
+ function buildAgentSessionReport(options) {
924
+ const normalized = [
925
+ ...options.codexResults.map(normalizeCodexResult),
926
+ ...options.claudeResults.map(normalizeClaudeResult),
927
+ ];
928
+ const bestByKey = new Map();
929
+ for (const result of normalized) {
930
+ const key = `${result.source}:${result.session_id}`;
931
+ const existing = bestByKey.get(key);
763
932
  if (!existing ||
764
933
  (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
765
934
  (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
766
935
  ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
767
936
  (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
768
937
  result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
769
- bestBySessionId.set(result.codex_session_id, result);
938
+ bestByKey.set(key, result);
770
939
  }
771
940
  }
772
- const dedupedResults = [...bestBySessionId.values()];
773
- const uploadBySessionId = new Map();
774
- for (const outcome of outcomes) {
941
+ // Main-file outcomes only (D3): a sidecar making it must never mark a session
942
+ // uploaded when the main did not.
943
+ const uploadByKey = new Map();
944
+ for (const outcome of options.outcomes) {
775
945
  if (outcome.sync.status !== "uploaded")
776
946
  continue;
777
947
  for (const upload of outcome.sync.raw_evidence_outcomes) {
778
- if (upload.codex_session_id && upload.raw_evidence_pointer_id) {
779
- uploadBySessionId.set(upload.codex_session_id, upload);
780
- }
948
+ if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
949
+ continue;
950
+ const source = upload.kind === "claude_jsonl"
951
+ ? "claude_code"
952
+ : upload.kind === "codex_jsonl"
953
+ ? "codex"
954
+ : null;
955
+ if (!source)
956
+ continue; // sidecars and other kinds do not set session state
957
+ uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
781
958
  }
782
959
  }
783
- return dedupedResults.map((result) => {
784
- const upload = uploadBySessionId.get(result.codex_session_id);
960
+ return [...bestByKey.values()].map((result) => {
961
+ const key = `${result.source}:${result.session_id}`;
962
+ const upload = uploadByKey.get(key);
963
+ // A previously-durable Claude session with no fresh main upload this sync
964
+ // (damped / spooled / budget-deferred) reports reused_existing + its prior
965
+ // pointer rather than not_uploaded, so the store row never flips.
966
+ const priorDurablePointer = result.source === "claude_code"
967
+ ? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
968
+ : null;
785
969
  return {
786
- codex_session_id: result.codex_session_id,
787
- observed_at: now.toISOString(),
970
+ codex_session_id: result.session_id,
971
+ source: result.source,
972
+ observed_at: options.now.toISOString(),
788
973
  attribution_state: result.state,
789
974
  attribution_reason: result.reason,
790
975
  attribution_score: result.attribution_score,
@@ -810,23 +995,215 @@ function buildCodexSessionReport(results, outcomes, now) {
810
995
  raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
811
996
  upload_state: upload.upload_state,
812
997
  }
813
- : result.state === "attributed"
814
- ? { upload_state: "not_uploaded" }
815
- : {}),
998
+ : priorDurablePointer
999
+ ? {
1000
+ raw_evidence_pointer_id: priorDurablePointer,
1001
+ upload_state: "reused_existing",
1002
+ }
1003
+ : result.state === "attributed"
1004
+ ? { upload_state: "not_uploaded" }
1005
+ : {}),
816
1006
  };
817
1007
  });
818
1008
  }
1009
+ /**
1010
+ * Records per-source session observations into its cursor and returns the stale
1011
+ * count. Damped/reused Claude sessions carry forward their prior upload
1012
+ * timestamp + byte size so the 6h damping window keeps counting from the real
1013
+ * last upload (otherwise a slowly-growing file would never re-upload — D21).
1014
+ */
1015
+ function recordSourceObservations(options) {
1016
+ const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
1017
+ const stale = countStaleSessions(options.cursor, seen);
1018
+ for (const result of options.results) {
1019
+ const sessionId = options.sessionIdOf(result);
1020
+ const reported = options.sessions.find((session) => session.source === options.source &&
1021
+ session.codex_session_id === sessionId);
1022
+ const uploadedThisSync = reported?.upload_state === "uploaded";
1023
+ const durableThisSync = reported?.upload_state === "uploaded" ||
1024
+ reported?.upload_state === "reused_existing";
1025
+ const prior = options.priorCursor?.sessions[sessionId];
1026
+ // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
1027
+ // upload-failed for a session that was ALREADY durable must NOT wipe the
1028
+ // prior durable state — otherwise damping is forfeited forever and the
1029
+ // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
1030
+ // durable pointer/timestamp/size forward unless we durably uploaded anew.
1031
+ const uploadedObjectKey = durableThisSync
1032
+ ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
1033
+ : (prior?.uploaded_object_key ?? null);
1034
+ const uploadedAt = uploadedThisSync
1035
+ ? options.now.toISOString()
1036
+ : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
1037
+ const uploadedByteSize = uploadedThisSync
1038
+ ? result.byte_size
1039
+ : (prior?.uploaded_byte_size ??
1040
+ (durableThisSync ? result.byte_size : null));
1041
+ const entry = {
1042
+ file_hash_sha256: result.content_hash_sha256,
1043
+ file_mtime_ms: result.session_file_mtime_ms,
1044
+ byte_size: result.byte_size,
1045
+ // Durable byte offset reflects how many bytes are durable remotely (the
1046
+ // last uploaded size), not the current file size.
1047
+ byte_offset: uploadedByteSize ?? 0,
1048
+ state: result.state,
1049
+ reason: result.reason,
1050
+ worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
1051
+ uploaded_object_key: uploadedObjectKey,
1052
+ uploaded_at: uploadedAt,
1053
+ uploaded_byte_size: uploadedByteSize,
1054
+ last_seen_at: options.now.toISOString(),
1055
+ };
1056
+ recordSessionObservation(options.cursor, sessionId, entry);
1057
+ }
1058
+ return stale;
1059
+ }
1060
+ function shouldDampClaudeMain(result, cursor, now) {
1061
+ const entry = cursor.sessions[result.claude_session_id];
1062
+ if (!entry ||
1063
+ !entry.uploaded_object_key ||
1064
+ !entry.uploaded_at ||
1065
+ entry.uploaded_byte_size == null) {
1066
+ return false;
1067
+ }
1068
+ const grew = result.byte_size > entry.uploaded_byte_size;
1069
+ if (!grew)
1070
+ return false; // unchanged content reuses via the object cursor
1071
+ const growth = result.byte_size - entry.uploaded_byte_size;
1072
+ const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
1073
+ return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
1074
+ Number.isFinite(ageMs) &&
1075
+ ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
1076
+ }
1077
+ function buildAgentSessionSummary(options) {
1078
+ const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
1079
+ const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
1080
+ const sidecarsCollected = attributedClaude.reduce((total, result) => total +
1081
+ result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
1082
+ const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
1083
+ result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
1084
+ const codex = {
1085
+ scanned: options.codexAttribution.scanned_file_count,
1086
+ attributed: options.codexAttribution.counts.attributed,
1087
+ ambiguous: options.codexAttribution.counts.ambiguous,
1088
+ unattributed: options.codexAttribution.counts.unattributed,
1089
+ skipped: options.codexAttribution.counts.skipped,
1090
+ stale: options.codexStaleCount,
1091
+ };
1092
+ const claude = {
1093
+ scanned: options.claudeAttribution.scanned_session_count,
1094
+ attributed: options.claudeAttribution.counts.attributed,
1095
+ ambiguous: options.claudeAttribution.counts.ambiguous,
1096
+ unattributed: options.claudeAttribution.counts.unattributed,
1097
+ skipped: options.claudeAttribution.counts.skipped,
1098
+ stale: options.claudeStaleCount,
1099
+ sidecars_collected: sidecarsCollected,
1100
+ sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
1101
+ upload.upload_state === "reused_existing").length,
1102
+ sidecars_skipped: sidecarsSkipped,
1103
+ sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
1104
+ sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
1105
+ mains_oversized: options.claudeAttribution.counts.mains_oversized,
1106
+ oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
1107
+ project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
1108
+ sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
1109
+ growth_damped: options.growthDamped,
1110
+ first_run_backfill: options.firstRunBackfill,
1111
+ };
1112
+ return {
1113
+ scanned: codex.scanned,
1114
+ attributed: codex.attributed,
1115
+ ambiguous: codex.ambiguous,
1116
+ unattributed: codex.unattributed,
1117
+ skipped: codex.skipped,
1118
+ stale: codex.stale,
1119
+ report_posted: options.report.posted,
1120
+ report_reason: options.report.reason,
1121
+ codex,
1122
+ claude,
1123
+ files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
1124
+ files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
1125
+ };
1126
+ }
1127
+ function emptyClaudeScan() {
1128
+ return {
1129
+ results: [],
1130
+ scanned_session_count: 0,
1131
+ project_dirs_skipped: 0,
1132
+ counts: {
1133
+ attributed: 0,
1134
+ ambiguous: 0,
1135
+ unattributed: 0,
1136
+ skipped: 0,
1137
+ mains_oversized: 0,
1138
+ oversized_lines_skipped: 0,
1139
+ sessions_schema_drift: 0,
1140
+ sidecars_capped: 0,
1141
+ },
1142
+ };
1143
+ }
1144
+ async function isClaudeCollectionEnabled(paths) {
1145
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
1146
+ return config?.collect_claude_jsonl !== false;
1147
+ }
1148
+ async function fileExists(filePath) {
1149
+ const { stat } = await import("node:fs/promises");
1150
+ return stat(filePath).then(() => true, () => false);
1151
+ }
819
1152
  function shortSha(value) {
820
1153
  return value ? value.slice(0, 12) : "unknown";
821
1154
  }
822
- function codexSummaryLine(summary) {
823
- return `Codex sessions: attributed ${summary.attributed}, ambiguous ${summary.ambiguous}, unattributed ${summary.unattributed}, skipped ${summary.skipped}, stale ${summary.stale}`;
1155
+ function sourceFunnelLine(label, counts) {
1156
+ return `${label} sessions: attributed ${counts.attributed}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}`;
824
1157
  }
825
1158
  function attributionReportLine(summary) {
826
1159
  return summary.report_posted
827
1160
  ? "Attribution report: recorded"
828
1161
  : `Attribution report: skipped (${summary.report_reason})`;
829
1162
  }
1163
+ /**
1164
+ * One funnel line per source, an anomaly diagnostics line only when something
1165
+ * is nonzero (clean syncs stay one line per source), and the report line.
1166
+ * Counts only — project-dir slugs encode full local paths and never print
1167
+ * (B.4 §5).
1168
+ */
1169
+ function writeAgentSessionSummary(io, summary) {
1170
+ writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
1171
+ writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
1172
+ const claudeDiagnostics = claudeDiagnosticsLine(summary);
1173
+ if (claudeDiagnostics)
1174
+ writeLine(io.stdout, claudeDiagnostics);
1175
+ writeLine(io.stdout, attributionReportLine(summary));
1176
+ }
1177
+ function claudeDiagnosticsLine(summary) {
1178
+ // Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
1179
+ // counters and must NOT trigger this line, or a healthy orchestrated sync
1180
+ // prints it 48×/day in launchd logs. Clean syncs stay one line per source.
1181
+ const claude = summary.claude;
1182
+ const parts = [];
1183
+ if (claude.sidecars_skipped)
1184
+ parts.push(`sidecars_skipped ${claude.sidecars_skipped}`);
1185
+ if (claude.sidecars_capped)
1186
+ parts.push(`sidecars_capped ${claude.sidecars_capped}`);
1187
+ if (claude.sidecars_failed)
1188
+ parts.push(`sidecars_failed ${claude.sidecars_failed}`);
1189
+ if (claude.mains_oversized)
1190
+ parts.push(`mains_oversized ${claude.mains_oversized}`);
1191
+ if (claude.oversized_lines_skipped)
1192
+ parts.push(`oversized_lines_skipped ${claude.oversized_lines_skipped}`);
1193
+ if (claude.project_dirs_skipped)
1194
+ parts.push(`project_dirs_skipped ${claude.project_dirs_skipped}`);
1195
+ if (claude.sessions_schema_drift)
1196
+ parts.push(`schema_drift ${claude.sessions_schema_drift}`);
1197
+ if (claude.growth_damped)
1198
+ parts.push(`growth_damped ${claude.growth_damped}`);
1199
+ if (claude.first_run_backfill)
1200
+ parts.push("first_run_backfill");
1201
+ if (summary.files_deferred_byte_budget)
1202
+ parts.push(`deferred_byte_budget ${summary.files_deferred_byte_budget}`);
1203
+ if (summary.files_deferred_object_budget)
1204
+ parts.push(`deferred_object_budget ${summary.files_deferred_object_budget}`);
1205
+ return parts.length > 0 ? `Claude diagnostics: ${parts.join(", ")}` : null;
1206
+ }
830
1207
  function rawEvidenceSyncLine(sync) {
831
1208
  const failures = sync.raw_evidence_failure_reasons.length > 0
832
1209
  ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
@@ -865,7 +1242,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
865
1242
  worktrees,
866
1243
  fetchImpl: io.fetch,
867
1244
  });
868
- const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
1245
+ const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
869
1246
  if (!command.json) {
870
1247
  for (const [index, outcome] of run.outcomes.entries()) {
871
1248
  const row = results[index];
@@ -876,8 +1253,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
876
1253
  }
877
1254
  writeLine(io.stdout, "4/5 Parent worktree sync complete.");
878
1255
  writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
879
- writeLine(io.stdout, codexSummaryLine(run.summary));
880
- writeLine(io.stdout, attributionReportLine(run.summary));
1256
+ writeAgentSessionSummary(io, run.summary);
881
1257
  writeLine(io.stdout, "5/5 Status ready.");
882
1258
  writeLine(run.ok ? io.stdout : io.stderr, run.ok
883
1259
  ? "PASS: Cockpit collector is ready for harvest."
@@ -886,10 +1262,13 @@ async function runMultiRepoOnboard(command, io, worktrees) {
886
1262
  }
887
1263
  return { ok: run.ok, results, codex_sessions: run.summary };
888
1264
  }
889
- function worktreeSyncRow(outcome, attribution) {
1265
+ function worktreeSyncRow(outcome, run) {
890
1266
  const { worktree, context, sync } = outcome;
891
- const attributedSessionCount = attribution.results.filter((result) => result.state === "attributed" &&
892
- result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint).length;
1267
+ const matchesWorktree = (result) => result.state === "attributed" &&
1268
+ result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint;
1269
+ const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
1270
+ const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
1271
+ const attributedSessionCount = codexSessionCount + claudeSessionCount;
893
1272
  return {
894
1273
  repo_label: context?.repo_label ?? worktree.repo_label,
895
1274
  repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
@@ -906,6 +1285,8 @@ function worktreeSyncRow(outcome, attribution) {
906
1285
  raw_evidence_failed_count: sync.raw_evidence_failed_count,
907
1286
  raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
908
1287
  attributed_session_count: attributedSessionCount,
1288
+ codex_session_count: codexSessionCount,
1289
+ claude_session_count: claudeSessionCount,
909
1290
  cursor_tracked_object_count: sync.cursor_tracked_object_count,
910
1291
  failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
911
1292
  };
@@ -1042,6 +1423,26 @@ async function runStart(command, io) {
1042
1423
  return 0;
1043
1424
  }
1044
1425
  async function runSync(command, io) {
1426
+ // Single-flight: a launchd timer and a manual sync must not interleave the
1427
+ // cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
1428
+ const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
1429
+ if (!lock.acquired) {
1430
+ if (command.json) {
1431
+ writeLine(io.stdout, JSON.stringify({ status: "sync_already_running", held_since: lock.held_since }, null, 2));
1432
+ }
1433
+ else {
1434
+ writeLine(io.stdout, "Cockpit sync already running; skipping this run.");
1435
+ }
1436
+ return 0;
1437
+ }
1438
+ try {
1439
+ return await runSyncLocked(command, io);
1440
+ }
1441
+ finally {
1442
+ await lock.handle.release();
1443
+ }
1444
+ }
1445
+ async function runSyncLocked(command, io) {
1045
1446
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1046
1447
  const run = await runAttributedWorktreeSync({
1047
1448
  homeDir: command.homeDir,
@@ -1051,7 +1452,7 @@ async function runSync(command, io) {
1051
1452
  fetchImpl: io.fetch,
1052
1453
  });
1053
1454
  if (worktrees.length > 1) {
1054
- const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
1455
+ const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1055
1456
  if (command.json) {
1056
1457
  writeLine(io.stdout, JSON.stringify({
1057
1458
  mode: "multi_repo",
@@ -1069,8 +1470,7 @@ async function runSync(command, io) {
1069
1470
  const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
1070
1471
  writeLine(uploaded ? io.stdout : io.stderr, `- ${worktree.repo_label}/${worktree.worktree_label} (${worktree.branch}) head:${shortSha(sync.head_sha ?? worktree.head_sha)} ${sync.status} objects:${sync.raw_evidence_uploaded_object_count} chunks:${sync.raw_evidence_uploaded_chunk_count} reused:${sync.raw_evidence_reused_count} failed:${sync.raw_evidence_failed_count} cursor:${sync.cursor_tracked_object_count}${failureSuffix}`);
1071
1472
  }
1072
- writeLine(io.stdout, codexSummaryLine(run.summary));
1073
- writeLine(io.stdout, attributionReportLine(run.summary));
1473
+ writeAgentSessionSummary(io, run.summary);
1074
1474
  return run.ok ? 0 : 1;
1075
1475
  }
1076
1476
  const result = run.outcomes[0]?.sync;
@@ -1090,8 +1490,7 @@ async function runSync(command, io) {
1090
1490
  writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
1091
1491
  writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
1092
1492
  writeLine(io.stdout, rawEvidenceSyncLine(result));
1093
- writeLine(io.stdout, codexSummaryLine(run.summary));
1094
- writeLine(io.stdout, attributionReportLine(run.summary));
1493
+ writeAgentSessionSummary(io, run.summary);
1095
1494
  writeLine(io.stdout, cursorStatusLine(result));
1096
1495
  return 0;
1097
1496
  }
@@ -1145,6 +1544,100 @@ async function runStatus(command, io) {
1145
1544
  function displayTicketId(ticketId) {
1146
1545
  return ticketId ?? "general ambient";
1147
1546
  }
1547
+ /**
1548
+ * Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
1549
+ * prints why each session is or is not collected. Per-session reasons otherwise
1550
+ * live only in a service-role table with no UI, so this is the operator's local
1551
+ * answer to "why is session X missing?" (B.4 §6). Counts and labels only — the
1552
+ * project-dir slug encodes a local path and is never printed.
1553
+ */
1554
+ async function runSessions(command, io) {
1555
+ const now = new Date();
1556
+ const homeDir = command.homeDir ?? os.homedir();
1557
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1558
+ const wantCodex = command.source !== "claude";
1559
+ const wantClaude = command.source !== "codex";
1560
+ const codex = wantCodex
1561
+ ? await scanAndAttributeCodexSessions({
1562
+ sessionsDir: path.join(homeDir, ".codex", "sessions"),
1563
+ worktrees,
1564
+ now,
1565
+ })
1566
+ : null;
1567
+ const claude = wantClaude
1568
+ ? await scanAndAttributeClaudeSessions({
1569
+ projectsDir: path.join(homeDir, ".claude", "projects"),
1570
+ worktrees,
1571
+ now,
1572
+ })
1573
+ : null;
1574
+ // Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
1575
+ // skip reasons, plus the repo_label basename. Branch is intentionally omitted
1576
+ // — branch names can carry operator-authored task/customer text.
1577
+ const codexRows = (codex?.results ?? []).map((result) => ({
1578
+ source: "codex",
1579
+ session_id: result.codex_session_id,
1580
+ state: result.state,
1581
+ reason: result.reason,
1582
+ attribution_score: result.attribution_score,
1583
+ path_score: result.path_score,
1584
+ signals: result.signals,
1585
+ repo_label: result.worktree?.repo_label ?? null,
1586
+ }));
1587
+ const claudeRows = (claude?.results ?? []).map((result) => ({
1588
+ source: "claude_code",
1589
+ session_id: result.claude_session_id,
1590
+ state: result.state,
1591
+ reason: result.reason,
1592
+ attribution_score: result.attribution_score,
1593
+ path_score: result.path_score,
1594
+ signals: result.signals,
1595
+ repo_label: result.worktree?.repo_label ?? null,
1596
+ main_file_oversized: result.main_file_oversized,
1597
+ sidecar_skips: result.sidecar_files
1598
+ .filter((sidecar) => sidecar.skipped_reason)
1599
+ .map((sidecar) => ({
1600
+ file_name: sidecar.file_name,
1601
+ reason: sidecar.skipped_reason,
1602
+ })),
1603
+ }));
1604
+ if (command.json) {
1605
+ writeLine(io.stdout, JSON.stringify({
1606
+ ...(codex
1607
+ ? { codex: { counts: codex.counts, sessions: codexRows } }
1608
+ : {}),
1609
+ ...(claude
1610
+ ? {
1611
+ claude: {
1612
+ counts: claude.counts,
1613
+ project_dirs_skipped: claude.project_dirs_skipped,
1614
+ sessions: claudeRows,
1615
+ },
1616
+ }
1617
+ : {}),
1618
+ }, null, 2));
1619
+ return 0;
1620
+ }
1621
+ writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
1622
+ for (const row of codexRows) {
1623
+ writeLine(io.stdout, sessionRowLine(row));
1624
+ }
1625
+ for (const row of claudeRows) {
1626
+ writeLine(io.stdout, sessionRowLine(row));
1627
+ for (const sidecar of row.sidecar_skips) {
1628
+ writeLine(io.stdout, ` sidecar ${sidecar.file_name}: ${sidecar.reason}`);
1629
+ }
1630
+ }
1631
+ if (codexRows.length === 0 && claudeRows.length === 0) {
1632
+ writeLine(io.stdout, "No sessions observed in the scan window.");
1633
+ }
1634
+ return 0;
1635
+ }
1636
+ function sessionRowLine(row) {
1637
+ const repo = row.repo_label ? ` repo:${row.repo_label}` : "";
1638
+ const signals = row.signals.length > 0 ? ` signals:${row.signals.join("|")}` : "";
1639
+ return `- [${row.source}] ${row.session_id} ${row.state} (${row.reason}) score:${row.attribution_score} path:${row.path_score}${repo}${signals}`;
1640
+ }
1148
1641
  async function runServe(command, io) {
1149
1642
  const server = createCollectorServer(command);
1150
1643
  await new Promise((resolve) => {