@bli-cockpit/cli 0.1.6 → 0.1.8

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
  }
@@ -81,7 +89,7 @@ function localSubcommandHelp(command) {
81
89
  "",
82
90
  "Installs, pairs, starts work context(s), syncs once, and prints readiness proof.",
83
91
  "If --repo is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
84
- "Use --email on shared or reused machines; mismatched existing sessions are re-paired.",
92
+ "Run with no flags in a terminal and it prompts for the dashboard email; pass --email to skip the prompt (and on shared/reused machines, where mismatched sessions are re-paired).",
85
93
  ],
86
94
  ],
87
95
  [
@@ -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"],
@@ -426,6 +469,38 @@ async function runInstall(command, io) {
426
469
  writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
427
470
  return 0;
428
471
  }
472
+ function isInteractiveStdin(io) {
473
+ return Boolean(io.stdin.isTTY);
474
+ }
475
+ /**
476
+ * Reads one line from stdin so `cockpit onboard` (no flags) can ask for the
477
+ * dashboard email instead of forcing a `--email` flag. Only called when stdin
478
+ * is a TTY and we're not in --json mode, so headless / piped / spawned runs
479
+ * never block on input — they keep the existing behaviour (email optional; the
480
+ * approving admin's account owns the device). An empty answer or a non-email
481
+ * skips rather than failing, matching `optionalEmail`'s leniency.
482
+ */
483
+ async function promptOnboardEmail(io) {
484
+ io.stdout.write("Dashboard email (press enter to skip): ");
485
+ io.stdin.setEncoding("utf8");
486
+ const raw = await new Promise((resolve) => {
487
+ const onData = (chunk) => {
488
+ io.stdin.removeListener("data", onData);
489
+ io.stdin.pause();
490
+ resolve(chunk);
491
+ };
492
+ io.stdin.resume();
493
+ io.stdin.on("data", onData);
494
+ });
495
+ const answer = raw.trim().toLowerCase();
496
+ if (!answer)
497
+ return undefined;
498
+ if (!answer.includes("@")) {
499
+ writeLine(io.stderr, `"${answer}" is not an email; continuing without one — the approving admin's account will own this device.`);
500
+ return undefined;
501
+ }
502
+ return answer;
503
+ }
429
504
  async function runOnboard(command, io) {
430
505
  let install = null;
431
506
  let pair = null;
@@ -437,6 +512,10 @@ async function runOnboard(command, io) {
437
512
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
438
513
  writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
439
514
  }
515
+ let claimedOwnerEmail = command.claimedOwnerEmail;
516
+ if (!claimedOwnerEmail && !command.json && isInteractiveStdin(io)) {
517
+ claimedOwnerEmail = await promptOnboardEmail(io);
518
+ }
440
519
  install = await installLocalCollector({
441
520
  homeDir: command.homeDir,
442
521
  repoRoot: command.repoRoot,
@@ -453,7 +532,7 @@ async function runOnboard(command, io) {
453
532
  branch: command.branch,
454
533
  });
455
534
  const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
456
- const canReuseInstalledSession = canReuseOnboardSession(installedSession, command.claimedOwnerEmail, command.dashboardUrl);
535
+ const canReuseInstalledSession = canReuseOnboardSession(installedSession, claimedOwnerEmail, command.dashboardUrl);
457
536
  if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
458
537
  if (!command.json) {
459
538
  writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
@@ -469,7 +548,7 @@ async function runOnboard(command, io) {
469
548
  pair = await pairLocalCollector({
470
549
  homeDir: command.homeDir,
471
550
  dashboardUrl: command.dashboardUrl,
472
- claimedOwnerEmail: command.claimedOwnerEmail,
551
+ claimedOwnerEmail,
473
552
  deviceName: command.deviceName,
474
553
  pollIntervalMs: command.pollIntervalMs,
475
554
  timeoutMs: command.timeoutMs,
@@ -554,8 +633,7 @@ async function runOnboard(command, io) {
554
633
  writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
555
634
  writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
556
635
  writeLine(io.stdout, rawEvidenceSyncLine(sync));
557
- writeLine(io.stdout, codexSummaryLine(run.summary));
558
- writeLine(io.stdout, attributionReportLine(run.summary));
636
+ writeAgentSessionSummary(io, run.summary);
559
637
  writeLine(io.stdout, "5/5 Status ready.");
560
638
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
561
639
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
@@ -616,21 +694,68 @@ async function readOnboardSessionReuseCandidate(homeDir) {
616
694
  function normalizeUrlForComparison(value) {
617
695
  return value ? normalizeUrl(value) : null;
618
696
  }
697
+ const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
698
+ const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
699
+ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
619
700
  /**
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.
701
+ * Shared dual-source sync orchestration for single-repo and parent-folder
702
+ * modes. Codex AND Claude Code sessions are scanned and attributed once across
703
+ * every discovered worktree; each worktree syncs with only its own attributed
704
+ * transcripts (codex + claude main + sidecars), and the
705
+ * ambiguous/unattributed/skipped remainder is reported with reason labels and a
706
+ * `source` discriminator instead of being duplicated into every repo or
707
+ * silently dropped. The session row's upload state maps ONLY from the main-file
708
+ * outcome (D3); sidecar outcomes aggregate into CLI counts.
625
709
  */
626
710
  async function runAttributedWorktreeSync(options) {
627
711
  const now = new Date();
628
712
  const homeDir = options.homeDir ?? os.homedir();
629
- const attribution = await scanAndAttributeCodexSessions({
713
+ const paths = getCollectorRuntimePaths(options.homeDir);
714
+ const claudeEnabled = await isClaudeCollectionEnabled(paths);
715
+ const codexAttribution = await scanAndAttributeCodexSessions({
630
716
  sessionsDir: path.join(homeDir, ".codex", "sessions"),
631
717
  worktrees: options.worktrees,
632
718
  now,
633
719
  });
720
+ // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
721
+ // days so the first sync captures retroactive history instead of only 24h.
722
+ const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
723
+ const firstRunBackfill = claudeEnabled && !claudeCursorExists;
724
+ const claudeAttribution = claudeEnabled
725
+ ? await scanAndAttributeClaudeSessions({
726
+ projectsDir: path.join(homeDir, ".claude", "projects"),
727
+ worktrees: options.worktrees,
728
+ now,
729
+ sinceMinutes: firstRunBackfill
730
+ ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
731
+ : undefined,
732
+ })
733
+ : emptyClaudeScan();
734
+ // Read the Claude cursor up front: damping decisions need prior upload state.
735
+ const claudeCursorBefore = claudeEnabled
736
+ ? await readRawEvidenceCursor(paths, {
737
+ filename: CLAUDE_CURSOR_FILENAME,
738
+ }).catch(() => emptyRawEvidenceCursorState())
739
+ : emptyRawEvidenceCursorState();
740
+ // sessionId -> prior durable pointer for damped sessions (drives the
741
+ // growth_damped count + the skip_main decision).
742
+ const dampedClaudePointers = new Map();
743
+ // sessionId -> prior durable pointer for EVERY already-durable Claude session
744
+ // (superset of damped). A session that was durable before but had no fresh
745
+ // upload this sync (damped, spooled, budget-deferred) reports reused_existing
746
+ // with this pointer instead of not_uploaded, so the store row never flips.
747
+ const claudePriorDurablePointers = new Map();
748
+ for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
749
+ if (entry.uploaded_object_key) {
750
+ claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
751
+ }
752
+ }
753
+ // One shared budget for the whole sync (D7b is per-sync): a parent-folder
754
+ // sync over many worktrees honors a single byte/object cap rather than N×.
755
+ const rawEvidenceBudget = {
756
+ remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
757
+ remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
758
+ };
634
759
  const outcomes = [];
635
760
  let ok = true;
636
761
  for (const worktree of options.worktrees) {
@@ -645,11 +770,32 @@ async function runAttributedWorktreeSync(options) {
645
770
  sessionId: options.sessionId,
646
771
  });
647
772
  }
773
+ const claudeSessionFiles = claudeAttribution.results
774
+ .filter((result) => result.state === "attributed" &&
775
+ result.worktree?.worktree_fingerprint ===
776
+ worktree.worktree_fingerprint)
777
+ .map((result) => {
778
+ const damped = !result.main_file_oversized &&
779
+ shouldDampClaudeMain(result, claudeCursorBefore, now);
780
+ if (damped) {
781
+ dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
782
+ ?.uploaded_object_key ?? null);
783
+ }
784
+ return {
785
+ local_path: result.file_path,
786
+ claude_session_id: result.claude_session_id,
787
+ main_file_oversized: result.main_file_oversized,
788
+ skip_main: damped,
789
+ sidecar_files: result.sidecar_files
790
+ .filter((sidecar) => !sidecar.skipped_reason)
791
+ .map((sidecar) => ({ local_path: sidecar.local_path })),
792
+ };
793
+ });
648
794
  const syncOptions = {
649
795
  homeDir: options.homeDir,
650
796
  repoRoot: worktree.repo_root,
651
797
  dashboardUrl: options.dashboardUrl,
652
- codexSessionFiles: attribution.results
798
+ codexSessionFiles: codexAttribution.results
653
799
  .filter((result) => result.state === "attributed" &&
654
800
  result.worktree?.worktree_fingerprint ===
655
801
  worktree.worktree_fingerprint)
@@ -657,6 +803,8 @@ async function runAttributedWorktreeSync(options) {
657
803
  local_path: result.file_path,
658
804
  codex_session_id: result.codex_session_id,
659
805
  })),
806
+ claudeSessionFiles,
807
+ rawEvidenceBudget,
660
808
  fetch: options.fetchImpl,
661
809
  };
662
810
  let sync;
@@ -684,39 +832,54 @@ async function runAttributedWorktreeSync(options) {
684
832
  ok = ok && sync.status === "uploaded";
685
833
  outcomes.push({ worktree, context, sync });
686
834
  }
687
- const sessions = buildCodexSessionReport(attribution.results, outcomes, now);
835
+ const sessions = buildAgentSessionReport({
836
+ codexResults: codexAttribution.results,
837
+ claudeResults: claudeAttribution.results,
838
+ outcomes,
839
+ now,
840
+ claudePriorDurablePointers,
841
+ });
688
842
  // The sessions cursor is an optimization; a broken local state dir must not
689
843
  // turn already-completed syncs into a CLI crash.
690
- let staleSessionCount = 0;
844
+ let codexStaleCount = 0;
845
+ let claudeStaleCount = 0;
691
846
  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);
847
+ const codexCursor = await readRawEvidenceCursor(paths);
848
+ codexStaleCount = recordSourceObservations({
849
+ cursor: codexCursor,
850
+ results: codexAttribution.results,
851
+ sessions,
852
+ source: "codex",
853
+ sessionIdOf: (result) => result.codex_session_id,
854
+ now,
855
+ });
856
+ codexCursor.updated_at = now.toISOString();
857
+ await writeRawEvidenceCursor(paths, codexCursor);
716
858
  }
717
859
  catch {
718
860
  // Best-effort: stale counts read 0 and observations re-record next sync.
719
861
  }
862
+ if (claudeEnabled) {
863
+ try {
864
+ claudeStaleCount = recordSourceObservations({
865
+ cursor: claudeCursorBefore,
866
+ results: claudeAttribution.results,
867
+ sessions,
868
+ source: "claude_code",
869
+ sessionIdOf: (result) => result.claude_session_id,
870
+ now,
871
+ priorCursor: claudeCursorBefore,
872
+ });
873
+ claudeCursorBefore.updated_at = now.toISOString();
874
+ await writeRawEvidenceCursor(paths, claudeCursorBefore, {
875
+ filename: CLAUDE_CURSOR_FILENAME,
876
+ sessionsOnly: true,
877
+ });
878
+ }
879
+ catch {
880
+ // Best-effort: a broken Claude cursor must not fail the sync.
881
+ }
882
+ }
720
883
  const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
721
884
  const report = firstUploaded
722
885
  ? await postCodexSessionReport({
@@ -731,21 +894,17 @@ async function runAttributedWorktreeSync(options) {
731
894
  posted: false,
732
895
  reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
733
896
  };
734
- return {
735
- ok,
897
+ const summary = buildAgentSessionSummary({
898
+ codexAttribution,
899
+ claudeAttribution,
736
900
  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
- };
901
+ codexStaleCount,
902
+ claudeStaleCount,
903
+ firstRunBackfill,
904
+ growthDamped: dampedClaudePointers.size,
905
+ report,
906
+ });
907
+ return { ok, outcomes, codexAttribution, claudeAttribution, summary };
749
908
  }
750
909
  const ATTRIBUTION_STATE_RANK = {
751
910
  attributed: 3,
@@ -753,38 +912,100 @@ const ATTRIBUTION_STATE_RANK = {
753
912
  unattributed: 1,
754
913
  skipped: 0,
755
914
  };
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);
915
+ function normalizeCodexResult(result) {
916
+ return {
917
+ source: "codex",
918
+ session_id: result.codex_session_id,
919
+ state: result.state,
920
+ reason: result.reason,
921
+ signals: result.signals,
922
+ attribution_score: result.attribution_score,
923
+ path_score: result.path_score,
924
+ content_hash_sha256: result.content_hash_sha256,
925
+ byte_size: result.byte_size,
926
+ session_file_mtime: result.session_file_mtime,
927
+ session_file_mtime_ms: result.session_file_mtime_ms,
928
+ worktree: result.worktree,
929
+ cwd_basename: result.cwd_basename,
930
+ cwd_hash: result.cwd_hash,
931
+ };
932
+ }
933
+ function normalizeClaudeResult(result) {
934
+ return {
935
+ source: "claude_code",
936
+ session_id: result.claude_session_id,
937
+ state: result.state,
938
+ reason: result.reason,
939
+ signals: result.signals,
940
+ attribution_score: result.attribution_score,
941
+ path_score: result.path_score,
942
+ content_hash_sha256: result.content_hash_sha256,
943
+ byte_size: result.byte_size,
944
+ session_file_mtime: result.session_file_mtime,
945
+ session_file_mtime_ms: result.session_file_mtime_ms,
946
+ worktree: result.worktree,
947
+ cwd_basename: result.cwd_basename,
948
+ cwd_hash: result.cwd_hash,
949
+ };
950
+ }
951
+ /**
952
+ * Generalizes the per-session report across sources. Dedupe is per
953
+ * `(source, session_id)` so a Codex session and a Claude session that happen to
954
+ * share an id are never collapsed. Upload state maps ONLY from the main-file
955
+ * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
956
+ * session's upload state (D3). Damped Claude sessions report `reused_existing`
957
+ * carrying their prior durable pointer.
958
+ */
959
+ function buildAgentSessionReport(options) {
960
+ const normalized = [
961
+ ...options.codexResults.map(normalizeCodexResult),
962
+ ...options.claudeResults.map(normalizeClaudeResult),
963
+ ];
964
+ const bestByKey = new Map();
965
+ for (const result of normalized) {
966
+ const key = `${result.source}:${result.session_id}`;
967
+ const existing = bestByKey.get(key);
763
968
  if (!existing ||
764
969
  (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
765
970
  (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
766
971
  ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
767
972
  (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
768
973
  result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
769
- bestBySessionId.set(result.codex_session_id, result);
974
+ bestByKey.set(key, result);
770
975
  }
771
976
  }
772
- const dedupedResults = [...bestBySessionId.values()];
773
- const uploadBySessionId = new Map();
774
- for (const outcome of outcomes) {
977
+ // Main-file outcomes only (D3): a sidecar making it must never mark a session
978
+ // uploaded when the main did not.
979
+ const uploadByKey = new Map();
980
+ for (const outcome of options.outcomes) {
775
981
  if (outcome.sync.status !== "uploaded")
776
982
  continue;
777
983
  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
- }
984
+ if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
985
+ continue;
986
+ const source = upload.kind === "claude_jsonl"
987
+ ? "claude_code"
988
+ : upload.kind === "codex_jsonl"
989
+ ? "codex"
990
+ : null;
991
+ if (!source)
992
+ continue; // sidecars and other kinds do not set session state
993
+ uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
781
994
  }
782
995
  }
783
- return dedupedResults.map((result) => {
784
- const upload = uploadBySessionId.get(result.codex_session_id);
996
+ return [...bestByKey.values()].map((result) => {
997
+ const key = `${result.source}:${result.session_id}`;
998
+ const upload = uploadByKey.get(key);
999
+ // A previously-durable Claude session with no fresh main upload this sync
1000
+ // (damped / spooled / budget-deferred) reports reused_existing + its prior
1001
+ // pointer rather than not_uploaded, so the store row never flips.
1002
+ const priorDurablePointer = result.source === "claude_code"
1003
+ ? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
1004
+ : null;
785
1005
  return {
786
- codex_session_id: result.codex_session_id,
787
- observed_at: now.toISOString(),
1006
+ codex_session_id: result.session_id,
1007
+ source: result.source,
1008
+ observed_at: options.now.toISOString(),
788
1009
  attribution_state: result.state,
789
1010
  attribution_reason: result.reason,
790
1011
  attribution_score: result.attribution_score,
@@ -810,23 +1031,215 @@ function buildCodexSessionReport(results, outcomes, now) {
810
1031
  raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
811
1032
  upload_state: upload.upload_state,
812
1033
  }
813
- : result.state === "attributed"
814
- ? { upload_state: "not_uploaded" }
815
- : {}),
1034
+ : priorDurablePointer
1035
+ ? {
1036
+ raw_evidence_pointer_id: priorDurablePointer,
1037
+ upload_state: "reused_existing",
1038
+ }
1039
+ : result.state === "attributed"
1040
+ ? { upload_state: "not_uploaded" }
1041
+ : {}),
816
1042
  };
817
1043
  });
818
1044
  }
1045
+ /**
1046
+ * Records per-source session observations into its cursor and returns the stale
1047
+ * count. Damped/reused Claude sessions carry forward their prior upload
1048
+ * timestamp + byte size so the 6h damping window keeps counting from the real
1049
+ * last upload (otherwise a slowly-growing file would never re-upload — D21).
1050
+ */
1051
+ function recordSourceObservations(options) {
1052
+ const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
1053
+ const stale = countStaleSessions(options.cursor, seen);
1054
+ for (const result of options.results) {
1055
+ const sessionId = options.sessionIdOf(result);
1056
+ const reported = options.sessions.find((session) => session.source === options.source &&
1057
+ session.codex_session_id === sessionId);
1058
+ const uploadedThisSync = reported?.upload_state === "uploaded";
1059
+ const durableThisSync = reported?.upload_state === "uploaded" ||
1060
+ reported?.upload_state === "reused_existing";
1061
+ const prior = options.priorCursor?.sessions[sessionId];
1062
+ // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
1063
+ // upload-failed for a session that was ALREADY durable must NOT wipe the
1064
+ // prior durable state — otherwise damping is forfeited forever and the
1065
+ // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
1066
+ // durable pointer/timestamp/size forward unless we durably uploaded anew.
1067
+ const uploadedObjectKey = durableThisSync
1068
+ ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
1069
+ : (prior?.uploaded_object_key ?? null);
1070
+ const uploadedAt = uploadedThisSync
1071
+ ? options.now.toISOString()
1072
+ : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
1073
+ const uploadedByteSize = uploadedThisSync
1074
+ ? result.byte_size
1075
+ : (prior?.uploaded_byte_size ??
1076
+ (durableThisSync ? result.byte_size : null));
1077
+ const entry = {
1078
+ file_hash_sha256: result.content_hash_sha256,
1079
+ file_mtime_ms: result.session_file_mtime_ms,
1080
+ byte_size: result.byte_size,
1081
+ // Durable byte offset reflects how many bytes are durable remotely (the
1082
+ // last uploaded size), not the current file size.
1083
+ byte_offset: uploadedByteSize ?? 0,
1084
+ state: result.state,
1085
+ reason: result.reason,
1086
+ worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
1087
+ uploaded_object_key: uploadedObjectKey,
1088
+ uploaded_at: uploadedAt,
1089
+ uploaded_byte_size: uploadedByteSize,
1090
+ last_seen_at: options.now.toISOString(),
1091
+ };
1092
+ recordSessionObservation(options.cursor, sessionId, entry);
1093
+ }
1094
+ return stale;
1095
+ }
1096
+ function shouldDampClaudeMain(result, cursor, now) {
1097
+ const entry = cursor.sessions[result.claude_session_id];
1098
+ if (!entry ||
1099
+ !entry.uploaded_object_key ||
1100
+ !entry.uploaded_at ||
1101
+ entry.uploaded_byte_size == null) {
1102
+ return false;
1103
+ }
1104
+ const grew = result.byte_size > entry.uploaded_byte_size;
1105
+ if (!grew)
1106
+ return false; // unchanged content reuses via the object cursor
1107
+ const growth = result.byte_size - entry.uploaded_byte_size;
1108
+ const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
1109
+ return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
1110
+ Number.isFinite(ageMs) &&
1111
+ ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
1112
+ }
1113
+ function buildAgentSessionSummary(options) {
1114
+ const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
1115
+ const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
1116
+ const sidecarsCollected = attributedClaude.reduce((total, result) => total +
1117
+ result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
1118
+ const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
1119
+ result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
1120
+ const codex = {
1121
+ scanned: options.codexAttribution.scanned_file_count,
1122
+ attributed: options.codexAttribution.counts.attributed,
1123
+ ambiguous: options.codexAttribution.counts.ambiguous,
1124
+ unattributed: options.codexAttribution.counts.unattributed,
1125
+ skipped: options.codexAttribution.counts.skipped,
1126
+ stale: options.codexStaleCount,
1127
+ };
1128
+ const claude = {
1129
+ scanned: options.claudeAttribution.scanned_session_count,
1130
+ attributed: options.claudeAttribution.counts.attributed,
1131
+ ambiguous: options.claudeAttribution.counts.ambiguous,
1132
+ unattributed: options.claudeAttribution.counts.unattributed,
1133
+ skipped: options.claudeAttribution.counts.skipped,
1134
+ stale: options.claudeStaleCount,
1135
+ sidecars_collected: sidecarsCollected,
1136
+ sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
1137
+ upload.upload_state === "reused_existing").length,
1138
+ sidecars_skipped: sidecarsSkipped,
1139
+ sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
1140
+ sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
1141
+ mains_oversized: options.claudeAttribution.counts.mains_oversized,
1142
+ oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
1143
+ project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
1144
+ sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
1145
+ growth_damped: options.growthDamped,
1146
+ first_run_backfill: options.firstRunBackfill,
1147
+ };
1148
+ return {
1149
+ scanned: codex.scanned,
1150
+ attributed: codex.attributed,
1151
+ ambiguous: codex.ambiguous,
1152
+ unattributed: codex.unattributed,
1153
+ skipped: codex.skipped,
1154
+ stale: codex.stale,
1155
+ report_posted: options.report.posted,
1156
+ report_reason: options.report.reason,
1157
+ codex,
1158
+ claude,
1159
+ files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
1160
+ files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
1161
+ };
1162
+ }
1163
+ function emptyClaudeScan() {
1164
+ return {
1165
+ results: [],
1166
+ scanned_session_count: 0,
1167
+ project_dirs_skipped: 0,
1168
+ counts: {
1169
+ attributed: 0,
1170
+ ambiguous: 0,
1171
+ unattributed: 0,
1172
+ skipped: 0,
1173
+ mains_oversized: 0,
1174
+ oversized_lines_skipped: 0,
1175
+ sessions_schema_drift: 0,
1176
+ sidecars_capped: 0,
1177
+ },
1178
+ };
1179
+ }
1180
+ async function isClaudeCollectionEnabled(paths) {
1181
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
1182
+ return config?.collect_claude_jsonl !== false;
1183
+ }
1184
+ async function fileExists(filePath) {
1185
+ const { stat } = await import("node:fs/promises");
1186
+ return stat(filePath).then(() => true, () => false);
1187
+ }
819
1188
  function shortSha(value) {
820
1189
  return value ? value.slice(0, 12) : "unknown";
821
1190
  }
822
- function codexSummaryLine(summary) {
823
- return `Codex sessions: attributed ${summary.attributed}, ambiguous ${summary.ambiguous}, unattributed ${summary.unattributed}, skipped ${summary.skipped}, stale ${summary.stale}`;
1191
+ function sourceFunnelLine(label, counts) {
1192
+ return `${label} sessions: attributed ${counts.attributed}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}`;
824
1193
  }
825
1194
  function attributionReportLine(summary) {
826
1195
  return summary.report_posted
827
1196
  ? "Attribution report: recorded"
828
1197
  : `Attribution report: skipped (${summary.report_reason})`;
829
1198
  }
1199
+ /**
1200
+ * One funnel line per source, an anomaly diagnostics line only when something
1201
+ * is nonzero (clean syncs stay one line per source), and the report line.
1202
+ * Counts only — project-dir slugs encode full local paths and never print
1203
+ * (B.4 §5).
1204
+ */
1205
+ function writeAgentSessionSummary(io, summary) {
1206
+ writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
1207
+ writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
1208
+ const claudeDiagnostics = claudeDiagnosticsLine(summary);
1209
+ if (claudeDiagnostics)
1210
+ writeLine(io.stdout, claudeDiagnostics);
1211
+ writeLine(io.stdout, attributionReportLine(summary));
1212
+ }
1213
+ function claudeDiagnosticsLine(summary) {
1214
+ // Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
1215
+ // counters and must NOT trigger this line, or a healthy orchestrated sync
1216
+ // prints it 48×/day in launchd logs. Clean syncs stay one line per source.
1217
+ const claude = summary.claude;
1218
+ const parts = [];
1219
+ if (claude.sidecars_skipped)
1220
+ parts.push(`sidecars_skipped ${claude.sidecars_skipped}`);
1221
+ if (claude.sidecars_capped)
1222
+ parts.push(`sidecars_capped ${claude.sidecars_capped}`);
1223
+ if (claude.sidecars_failed)
1224
+ parts.push(`sidecars_failed ${claude.sidecars_failed}`);
1225
+ if (claude.mains_oversized)
1226
+ parts.push(`mains_oversized ${claude.mains_oversized}`);
1227
+ if (claude.oversized_lines_skipped)
1228
+ parts.push(`oversized_lines_skipped ${claude.oversized_lines_skipped}`);
1229
+ if (claude.project_dirs_skipped)
1230
+ parts.push(`project_dirs_skipped ${claude.project_dirs_skipped}`);
1231
+ if (claude.sessions_schema_drift)
1232
+ parts.push(`schema_drift ${claude.sessions_schema_drift}`);
1233
+ if (claude.growth_damped)
1234
+ parts.push(`growth_damped ${claude.growth_damped}`);
1235
+ if (claude.first_run_backfill)
1236
+ parts.push("first_run_backfill");
1237
+ if (summary.files_deferred_byte_budget)
1238
+ parts.push(`deferred_byte_budget ${summary.files_deferred_byte_budget}`);
1239
+ if (summary.files_deferred_object_budget)
1240
+ parts.push(`deferred_object_budget ${summary.files_deferred_object_budget}`);
1241
+ return parts.length > 0 ? `Claude diagnostics: ${parts.join(", ")}` : null;
1242
+ }
830
1243
  function rawEvidenceSyncLine(sync) {
831
1244
  const failures = sync.raw_evidence_failure_reasons.length > 0
832
1245
  ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
@@ -865,7 +1278,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
865
1278
  worktrees,
866
1279
  fetchImpl: io.fetch,
867
1280
  });
868
- const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
1281
+ const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
869
1282
  if (!command.json) {
870
1283
  for (const [index, outcome] of run.outcomes.entries()) {
871
1284
  const row = results[index];
@@ -876,8 +1289,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
876
1289
  }
877
1290
  writeLine(io.stdout, "4/5 Parent worktree sync complete.");
878
1291
  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));
1292
+ writeAgentSessionSummary(io, run.summary);
881
1293
  writeLine(io.stdout, "5/5 Status ready.");
882
1294
  writeLine(run.ok ? io.stdout : io.stderr, run.ok
883
1295
  ? "PASS: Cockpit collector is ready for harvest."
@@ -886,10 +1298,13 @@ async function runMultiRepoOnboard(command, io, worktrees) {
886
1298
  }
887
1299
  return { ok: run.ok, results, codex_sessions: run.summary };
888
1300
  }
889
- function worktreeSyncRow(outcome, attribution) {
1301
+ function worktreeSyncRow(outcome, run) {
890
1302
  const { worktree, context, sync } = outcome;
891
- const attributedSessionCount = attribution.results.filter((result) => result.state === "attributed" &&
892
- result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint).length;
1303
+ const matchesWorktree = (result) => result.state === "attributed" &&
1304
+ result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint;
1305
+ const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
1306
+ const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
1307
+ const attributedSessionCount = codexSessionCount + claudeSessionCount;
893
1308
  return {
894
1309
  repo_label: context?.repo_label ?? worktree.repo_label,
895
1310
  repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
@@ -906,6 +1321,8 @@ function worktreeSyncRow(outcome, attribution) {
906
1321
  raw_evidence_failed_count: sync.raw_evidence_failed_count,
907
1322
  raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
908
1323
  attributed_session_count: attributedSessionCount,
1324
+ codex_session_count: codexSessionCount,
1325
+ claude_session_count: claudeSessionCount,
909
1326
  cursor_tracked_object_count: sync.cursor_tracked_object_count,
910
1327
  failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
911
1328
  };
@@ -1042,6 +1459,26 @@ async function runStart(command, io) {
1042
1459
  return 0;
1043
1460
  }
1044
1461
  async function runSync(command, io) {
1462
+ // Single-flight: a launchd timer and a manual sync must not interleave the
1463
+ // cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
1464
+ const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
1465
+ if (!lock.acquired) {
1466
+ if (command.json) {
1467
+ writeLine(io.stdout, JSON.stringify({ status: "sync_already_running", held_since: lock.held_since }, null, 2));
1468
+ }
1469
+ else {
1470
+ writeLine(io.stdout, "Cockpit sync already running; skipping this run.");
1471
+ }
1472
+ return 0;
1473
+ }
1474
+ try {
1475
+ return await runSyncLocked(command, io);
1476
+ }
1477
+ finally {
1478
+ await lock.handle.release();
1479
+ }
1480
+ }
1481
+ async function runSyncLocked(command, io) {
1045
1482
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1046
1483
  const run = await runAttributedWorktreeSync({
1047
1484
  homeDir: command.homeDir,
@@ -1051,7 +1488,7 @@ async function runSync(command, io) {
1051
1488
  fetchImpl: io.fetch,
1052
1489
  });
1053
1490
  if (worktrees.length > 1) {
1054
- const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
1491
+ const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1055
1492
  if (command.json) {
1056
1493
  writeLine(io.stdout, JSON.stringify({
1057
1494
  mode: "multi_repo",
@@ -1069,8 +1506,7 @@ async function runSync(command, io) {
1069
1506
  const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
1070
1507
  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
1508
  }
1072
- writeLine(io.stdout, codexSummaryLine(run.summary));
1073
- writeLine(io.stdout, attributionReportLine(run.summary));
1509
+ writeAgentSessionSummary(io, run.summary);
1074
1510
  return run.ok ? 0 : 1;
1075
1511
  }
1076
1512
  const result = run.outcomes[0]?.sync;
@@ -1090,8 +1526,7 @@ async function runSync(command, io) {
1090
1526
  writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
1091
1527
  writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
1092
1528
  writeLine(io.stdout, rawEvidenceSyncLine(result));
1093
- writeLine(io.stdout, codexSummaryLine(run.summary));
1094
- writeLine(io.stdout, attributionReportLine(run.summary));
1529
+ writeAgentSessionSummary(io, run.summary);
1095
1530
  writeLine(io.stdout, cursorStatusLine(result));
1096
1531
  return 0;
1097
1532
  }
@@ -1145,6 +1580,100 @@ async function runStatus(command, io) {
1145
1580
  function displayTicketId(ticketId) {
1146
1581
  return ticketId ?? "general ambient";
1147
1582
  }
1583
+ /**
1584
+ * Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
1585
+ * prints why each session is or is not collected. Per-session reasons otherwise
1586
+ * live only in a service-role table with no UI, so this is the operator's local
1587
+ * answer to "why is session X missing?" (B.4 §6). Counts and labels only — the
1588
+ * project-dir slug encodes a local path and is never printed.
1589
+ */
1590
+ async function runSessions(command, io) {
1591
+ const now = new Date();
1592
+ const homeDir = command.homeDir ?? os.homedir();
1593
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1594
+ const wantCodex = command.source !== "claude";
1595
+ const wantClaude = command.source !== "codex";
1596
+ const codex = wantCodex
1597
+ ? await scanAndAttributeCodexSessions({
1598
+ sessionsDir: path.join(homeDir, ".codex", "sessions"),
1599
+ worktrees,
1600
+ now,
1601
+ })
1602
+ : null;
1603
+ const claude = wantClaude
1604
+ ? await scanAndAttributeClaudeSessions({
1605
+ projectsDir: path.join(homeDir, ".claude", "projects"),
1606
+ worktrees,
1607
+ now,
1608
+ })
1609
+ : null;
1610
+ // Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
1611
+ // skip reasons, plus the repo_label basename. Branch is intentionally omitted
1612
+ // — branch names can carry operator-authored task/customer text.
1613
+ const codexRows = (codex?.results ?? []).map((result) => ({
1614
+ source: "codex",
1615
+ session_id: result.codex_session_id,
1616
+ state: result.state,
1617
+ reason: result.reason,
1618
+ attribution_score: result.attribution_score,
1619
+ path_score: result.path_score,
1620
+ signals: result.signals,
1621
+ repo_label: result.worktree?.repo_label ?? null,
1622
+ }));
1623
+ const claudeRows = (claude?.results ?? []).map((result) => ({
1624
+ source: "claude_code",
1625
+ session_id: result.claude_session_id,
1626
+ state: result.state,
1627
+ reason: result.reason,
1628
+ attribution_score: result.attribution_score,
1629
+ path_score: result.path_score,
1630
+ signals: result.signals,
1631
+ repo_label: result.worktree?.repo_label ?? null,
1632
+ main_file_oversized: result.main_file_oversized,
1633
+ sidecar_skips: result.sidecar_files
1634
+ .filter((sidecar) => sidecar.skipped_reason)
1635
+ .map((sidecar) => ({
1636
+ file_name: sidecar.file_name,
1637
+ reason: sidecar.skipped_reason,
1638
+ })),
1639
+ }));
1640
+ if (command.json) {
1641
+ writeLine(io.stdout, JSON.stringify({
1642
+ ...(codex
1643
+ ? { codex: { counts: codex.counts, sessions: codexRows } }
1644
+ : {}),
1645
+ ...(claude
1646
+ ? {
1647
+ claude: {
1648
+ counts: claude.counts,
1649
+ project_dirs_skipped: claude.project_dirs_skipped,
1650
+ sessions: claudeRows,
1651
+ },
1652
+ }
1653
+ : {}),
1654
+ }, null, 2));
1655
+ return 0;
1656
+ }
1657
+ writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
1658
+ for (const row of codexRows) {
1659
+ writeLine(io.stdout, sessionRowLine(row));
1660
+ }
1661
+ for (const row of claudeRows) {
1662
+ writeLine(io.stdout, sessionRowLine(row));
1663
+ for (const sidecar of row.sidecar_skips) {
1664
+ writeLine(io.stdout, ` sidecar ${sidecar.file_name}: ${sidecar.reason}`);
1665
+ }
1666
+ }
1667
+ if (codexRows.length === 0 && claudeRows.length === 0) {
1668
+ writeLine(io.stdout, "No sessions observed in the scan window.");
1669
+ }
1670
+ return 0;
1671
+ }
1672
+ function sessionRowLine(row) {
1673
+ const repo = row.repo_label ? ` repo:${row.repo_label}` : "";
1674
+ const signals = row.signals.length > 0 ? ` signals:${row.signals.join("|")}` : "";
1675
+ return `- [${row.source}] ${row.session_id} ${row.state} (${row.reason}) score:${row.attribution_score} path:${row.path_score}${repo}${signals}`;
1676
+ }
1148
1677
  async function runServe(command, io) {
1149
1678
  const server = createCollectorServer(command);
1150
1679
  await new Promise((resolve) => {