@bli-cockpit/cli 0.1.5 → 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.
@@ -2,9 +2,13 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { createCollectorServer } from "../server.js";
4
4
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
5
- import { postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
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
  }
@@ -61,14 +68,15 @@ export function localCommandHelp(command) {
61
68
  if (command)
62
69
  return localSubcommandHelp(command);
63
70
  return [
64
- " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--branch <name>] [--json]",
71
+ " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
65
72
  " cockpit install [--dashboard-url <url>] [--repo <path>] [--json]",
66
73
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
67
74
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
68
75
  " cockpit logout",
69
- " cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
70
- " cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
71
- " cockpit status [--repo <path>] [--json]",
76
+ " cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
77
+ " cockpit sync [--repo <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
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,14 @@ 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.",
140
+ "Newly discovered repos get a general ambient work context automatically.",
141
+ "Discovery scans 3 folder levels and up to 50 repos by default; tune with",
142
+ "--max-depth and --max-repos.",
130
143
  ],
131
144
  ],
132
145
  [
@@ -137,6 +150,17 @@ function localSubcommandHelp(command) {
137
150
  "Prints install, pairing, active work, upload, and retry state.",
138
151
  ],
139
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
+ ],
140
164
  [
141
165
  "serve",
142
166
  [
@@ -172,6 +196,8 @@ function parseLocalArgs(argv) {
172
196
  return parseSyncArgs(argv.slice(1));
173
197
  case "status":
174
198
  return parseStatusArgs(argv.slice(1));
199
+ case "sessions":
200
+ return parseSessionsArgs(argv.slice(1));
175
201
  case "serve":
176
202
  return parseServeArgs(argv.slice(1));
177
203
  default:
@@ -191,6 +217,8 @@ function parseOnboardArgs(args) {
191
217
  "--json",
192
218
  "--poll-interval-ms",
193
219
  "--timeout-ms",
220
+ "--max-depth",
221
+ "--max-repos",
194
222
  ],
195
223
  valueFlags: [
196
224
  "--home",
@@ -202,6 +230,8 @@ function parseOnboardArgs(args) {
202
230
  "--branch",
203
231
  "--poll-interval-ms",
204
232
  "--timeout-ms",
233
+ "--max-depth",
234
+ "--max-repos",
205
235
  ],
206
236
  });
207
237
  assertNoPositionals(values.positionals, "onboard");
@@ -217,6 +247,8 @@ function parseOnboardArgs(args) {
217
247
  json: values.booleans.has("--json"),
218
248
  pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
219
249
  timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
250
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
251
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
220
252
  };
221
253
  }
222
254
  function parseInstallArgs(args) {
@@ -294,6 +326,8 @@ function parseStartArgs(args) {
294
326
  "--operator-id",
295
327
  "--session-id",
296
328
  "--json",
329
+ "--max-depth",
330
+ "--max-repos",
297
331
  ],
298
332
  valueFlags: [
299
333
  "--home",
@@ -302,6 +336,8 @@ function parseStartArgs(args) {
302
336
  "--ticket",
303
337
  "--operator-id",
304
338
  "--session-id",
339
+ "--max-depth",
340
+ "--max-repos",
305
341
  ],
306
342
  });
307
343
  assertNoPositionals(values.positionals, "start");
@@ -314,12 +350,14 @@ function parseStartArgs(args) {
314
350
  operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
315
351
  sessionId: optionalNonEmpty(values.flags.get("--session-id")),
316
352
  json: values.booleans.has("--json"),
353
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
354
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
317
355
  };
318
356
  }
319
357
  function parseSyncArgs(args) {
320
358
  const values = parseNamedArgs(args, {
321
- allowedFlags: ["--home", "--repo", "--dashboard-url", "--json"],
322
- valueFlags: ["--home", "--repo", "--dashboard-url"],
359
+ allowedFlags: ["--home", "--repo", "--dashboard-url", "--json", "--max-depth", "--max-repos"],
360
+ valueFlags: ["--home", "--repo", "--dashboard-url", "--max-depth", "--max-repos"],
323
361
  });
324
362
  assertNoPositionals(values.positionals, "sync");
325
363
  return {
@@ -328,12 +366,14 @@ function parseSyncArgs(args) {
328
366
  repoRoot: optionalNonEmpty(values.flags.get("--repo")),
329
367
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
330
368
  json: values.booleans.has("--json"),
369
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
370
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
331
371
  };
332
372
  }
333
373
  function parseStatusArgs(args) {
334
374
  const values = parseNamedArgs(args, {
335
- allowedFlags: ["--home", "--repo", "--json"],
336
- valueFlags: ["--home", "--repo"],
375
+ allowedFlags: ["--home", "--repo", "--json", "--max-depth", "--max-repos"],
376
+ valueFlags: ["--home", "--repo", "--max-depth", "--max-repos"],
337
377
  });
338
378
  assertNoPositionals(values.positionals, "status");
339
379
  return {
@@ -341,6 +381,28 @@ function parseStatusArgs(args) {
341
381
  homeDir: optionalNonEmpty(values.flags.get("--home")),
342
382
  repoRoot: optionalNonEmpty(values.flags.get("--repo")),
343
383
  json: values.booleans.has("--json"),
384
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
385
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
386
+ };
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"),
344
406
  };
345
407
  }
346
408
  function parseServeArgs(args) {
@@ -465,7 +527,7 @@ async function runOnboard(command, io) {
465
527
  writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
466
528
  }
467
529
  }
468
- const worktrees = await discoverCommandWorktrees(command.repoRoot);
530
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
469
531
  if (worktrees.length > 1) {
470
532
  const multi = await runMultiRepoOnboard(command, io, worktrees);
471
533
  if (command.json) {
@@ -535,8 +597,7 @@ async function runOnboard(command, io) {
535
597
  writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
536
598
  writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
537
599
  writeLine(io.stdout, rawEvidenceSyncLine(sync));
538
- writeLine(io.stdout, codexSummaryLine(run.summary));
539
- writeLine(io.stdout, attributionReportLine(run.summary));
600
+ writeAgentSessionSummary(io, run.summary);
540
601
  writeLine(io.stdout, "5/5 Status ready.");
541
602
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
542
603
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
@@ -597,21 +658,68 @@ async function readOnboardSessionReuseCandidate(homeDir) {
597
658
  function normalizeUrlForComparison(value) {
598
659
  return value ? normalizeUrl(value) : null;
599
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;
600
664
  /**
601
- * Shared sync orchestration for single-repo and parent-folder modes: Codex
602
- * sessions are scanned and attributed once across every discovered worktree,
603
- * each worktree syncs with only its own attributed transcripts, and the
604
- * ambiguous/unattributed/skipped remainder is reported with reason labels
605
- * 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.
606
673
  */
607
674
  async function runAttributedWorktreeSync(options) {
608
675
  const now = new Date();
609
676
  const homeDir = options.homeDir ?? os.homedir();
610
- const attribution = await scanAndAttributeCodexSessions({
677
+ const paths = getCollectorRuntimePaths(options.homeDir);
678
+ const claudeEnabled = await isClaudeCollectionEnabled(paths);
679
+ const codexAttribution = await scanAndAttributeCodexSessions({
611
680
  sessionsDir: path.join(homeDir, ".codex", "sessions"),
612
681
  worktrees: options.worktrees,
613
682
  now,
614
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
+ };
615
723
  const outcomes = [];
616
724
  let ok = true;
617
725
  for (const worktree of options.worktrees) {
@@ -626,11 +734,32 @@ async function runAttributedWorktreeSync(options) {
626
734
  sessionId: options.sessionId,
627
735
  });
628
736
  }
629
- const sync = await syncLocalAmbientEnvelope({
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
+ });
758
+ const syncOptions = {
630
759
  homeDir: options.homeDir,
631
760
  repoRoot: worktree.repo_root,
632
761
  dashboardUrl: options.dashboardUrl,
633
- codexSessionFiles: attribution.results
762
+ codexSessionFiles: codexAttribution.results
634
763
  .filter((result) => result.state === "attributed" &&
635
764
  result.worktree?.worktree_fingerprint ===
636
765
  worktree.worktree_fingerprint)
@@ -638,44 +767,83 @@ async function runAttributedWorktreeSync(options) {
638
767
  local_path: result.file_path,
639
768
  codex_session_id: result.codex_session_id,
640
769
  })),
770
+ claudeSessionFiles,
771
+ rawEvidenceBudget,
641
772
  fetch: options.fetchImpl,
642
- });
773
+ };
774
+ let sync;
775
+ try {
776
+ sync = await syncLocalAmbientEnvelope(syncOptions);
777
+ }
778
+ catch (error) {
779
+ // A newly cloned repo has no work context yet. Capture is permissive
780
+ // and ticket binding comes later, so start general ambient capture for
781
+ // it instead of blocking every other repo's sync until someone runs
782
+ // `cockpit start` by hand.
783
+ if (error instanceof LocalUploadBlockedError &&
784
+ error.blocker === "missing_context") {
785
+ context = await startLocalWorkContext({
786
+ homeDir: options.homeDir,
787
+ repoRoot: worktree.repo_root,
788
+ branch: options.branch,
789
+ });
790
+ sync = await syncLocalAmbientEnvelope(syncOptions);
791
+ }
792
+ else {
793
+ throw error;
794
+ }
795
+ }
643
796
  ok = ok && sync.status === "uploaded";
644
797
  outcomes.push({ worktree, context, sync });
645
798
  }
646
- 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
+ });
647
806
  // The sessions cursor is an optimization; a broken local state dir must not
648
807
  // turn already-completed syncs into a CLI crash.
649
- let staleSessionCount = 0;
808
+ let codexStaleCount = 0;
809
+ let claudeStaleCount = 0;
650
810
  try {
651
- const paths = getCollectorRuntimePaths(options.homeDir);
652
- const cursor = await readRawEvidenceCursor(paths);
653
- const seenSessionIds = new Set(attribution.results.map((result) => result.codex_session_id));
654
- staleSessionCount = countStaleSessions(cursor, seenSessionIds);
655
- for (const result of attribution.results) {
656
- const reported = sessions.find((session) => session.codex_session_id === result.codex_session_id);
657
- const sessionDurable = reported?.upload_state === "uploaded" ||
658
- reported?.upload_state === "reused_existing";
659
- recordSessionObservation(cursor, result.codex_session_id, {
660
- file_hash_sha256: result.content_hash_sha256,
661
- file_mtime_ms: result.session_file_mtime_ms,
662
- byte_size: result.byte_size,
663
- byte_offset: sessionDurable ? result.byte_size : 0,
664
- state: result.state,
665
- reason: result.reason,
666
- worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
667
- uploaded_object_key: sessionDurable
668
- ? (reported?.raw_evidence_pointer_id ?? null)
669
- : null,
670
- last_seen_at: now.toISOString(),
671
- });
672
- }
673
- cursor.updated_at = now.toISOString();
674
- 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);
675
822
  }
676
823
  catch {
677
824
  // Best-effort: stale counts read 0 and observations re-record next sync.
678
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
+ }
679
847
  const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
680
848
  const report = firstUploaded
681
849
  ? await postCodexSessionReport({
@@ -690,21 +858,17 @@ async function runAttributedWorktreeSync(options) {
690
858
  posted: false,
691
859
  reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
692
860
  };
693
- return {
694
- ok,
861
+ const summary = buildAgentSessionSummary({
862
+ codexAttribution,
863
+ claudeAttribution,
695
864
  outcomes,
696
- attribution,
697
- summary: {
698
- scanned: attribution.scanned_file_count,
699
- attributed: attribution.counts.attributed,
700
- ambiguous: attribution.counts.ambiguous,
701
- unattributed: attribution.counts.unattributed,
702
- skipped: attribution.counts.skipped,
703
- stale: staleSessionCount,
704
- report_posted: report.posted,
705
- report_reason: report.reason,
706
- },
707
- };
865
+ codexStaleCount,
866
+ claudeStaleCount,
867
+ firstRunBackfill,
868
+ growthDamped: dampedClaudePointers.size,
869
+ report,
870
+ });
871
+ return { ok, outcomes, codexAttribution, claudeAttribution, summary };
708
872
  }
709
873
  const ATTRIBUTION_STATE_RANK = {
710
874
  attributed: 3,
@@ -712,38 +876,100 @@ const ATTRIBUTION_STATE_RANK = {
712
876
  unattributed: 1,
713
877
  skipped: 0,
714
878
  };
715
- function buildCodexSessionReport(results, outcomes, now) {
716
- // Resumed/forked sessions can span multiple files with the same session id;
717
- // report each session once, preferring the strongest attribution and the
718
- // freshest file.
719
- const bestBySessionId = new Map();
720
- for (const result of results) {
721
- 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);
722
932
  if (!existing ||
723
933
  (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
724
934
  (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
725
935
  ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
726
936
  (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
727
937
  result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
728
- bestBySessionId.set(result.codex_session_id, result);
938
+ bestByKey.set(key, result);
729
939
  }
730
940
  }
731
- const dedupedResults = [...bestBySessionId.values()];
732
- const uploadBySessionId = new Map();
733
- 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) {
734
945
  if (outcome.sync.status !== "uploaded")
735
946
  continue;
736
947
  for (const upload of outcome.sync.raw_evidence_outcomes) {
737
- if (upload.codex_session_id && upload.raw_evidence_pointer_id) {
738
- uploadBySessionId.set(upload.codex_session_id, upload);
739
- }
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);
740
958
  }
741
959
  }
742
- return dedupedResults.map((result) => {
743
- 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;
744
969
  return {
745
- codex_session_id: result.codex_session_id,
746
- observed_at: now.toISOString(),
970
+ codex_session_id: result.session_id,
971
+ source: result.source,
972
+ observed_at: options.now.toISOString(),
747
973
  attribution_state: result.state,
748
974
  attribution_reason: result.reason,
749
975
  attribution_score: result.attribution_score,
@@ -769,23 +995,215 @@ function buildCodexSessionReport(results, outcomes, now) {
769
995
  raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
770
996
  upload_state: upload.upload_state,
771
997
  }
772
- : result.state === "attributed"
773
- ? { upload_state: "not_uploaded" }
774
- : {}),
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
+ : {}),
775
1006
  };
776
1007
  });
777
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
+ }
778
1152
  function shortSha(value) {
779
1153
  return value ? value.slice(0, 12) : "unknown";
780
1154
  }
781
- function codexSummaryLine(summary) {
782
- 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}`;
783
1157
  }
784
1158
  function attributionReportLine(summary) {
785
1159
  return summary.report_posted
786
1160
  ? "Attribution report: recorded"
787
1161
  : `Attribution report: skipped (${summary.report_reason})`;
788
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
+ }
789
1207
  function rawEvidenceSyncLine(sync) {
790
1208
  const failures = sync.raw_evidence_failure_reasons.length > 0
791
1209
  ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
@@ -795,11 +1213,20 @@ function rawEvidenceSyncLine(sync) {
795
1213
  function cursorStatusLine(sync) {
796
1214
  return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
797
1215
  }
798
- async function discoverCommandWorktrees(repoRoot) {
799
- const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd());
1216
+ const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
1217
+ const DEFAULT_DISCOVERY_MAX_REPOS = 50;
1218
+ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
1219
+ const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
1220
+ const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd(), {
1221
+ maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
1222
+ maxWorktrees,
1223
+ });
800
1224
  if (worktrees.length === 0) {
801
1225
  throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
802
1226
  }
1227
+ if (io && worktrees.length >= maxWorktrees) {
1228
+ writeLine(io.stderr, `Warning: repo discovery hit the cap of ${maxWorktrees}; additional repos may be excluded. Raise --max-repos if this folder really holds more.`);
1229
+ }
803
1230
  return worktrees;
804
1231
  }
805
1232
  async function runMultiRepoOnboard(command, io, worktrees) {
@@ -815,7 +1242,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
815
1242
  worktrees,
816
1243
  fetchImpl: io.fetch,
817
1244
  });
818
- const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
1245
+ const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
819
1246
  if (!command.json) {
820
1247
  for (const [index, outcome] of run.outcomes.entries()) {
821
1248
  const row = results[index];
@@ -826,8 +1253,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
826
1253
  }
827
1254
  writeLine(io.stdout, "4/5 Parent worktree sync complete.");
828
1255
  writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
829
- writeLine(io.stdout, codexSummaryLine(run.summary));
830
- writeLine(io.stdout, attributionReportLine(run.summary));
1256
+ writeAgentSessionSummary(io, run.summary);
831
1257
  writeLine(io.stdout, "5/5 Status ready.");
832
1258
  writeLine(run.ok ? io.stdout : io.stderr, run.ok
833
1259
  ? "PASS: Cockpit collector is ready for harvest."
@@ -836,10 +1262,13 @@ async function runMultiRepoOnboard(command, io, worktrees) {
836
1262
  }
837
1263
  return { ok: run.ok, results, codex_sessions: run.summary };
838
1264
  }
839
- function worktreeSyncRow(outcome, attribution) {
1265
+ function worktreeSyncRow(outcome, run) {
840
1266
  const { worktree, context, sync } = outcome;
841
- const attributedSessionCount = attribution.results.filter((result) => result.state === "attributed" &&
842
- 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;
843
1272
  return {
844
1273
  repo_label: context?.repo_label ?? worktree.repo_label,
845
1274
  repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
@@ -856,6 +1285,8 @@ function worktreeSyncRow(outcome, attribution) {
856
1285
  raw_evidence_failed_count: sync.raw_evidence_failed_count,
857
1286
  raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
858
1287
  attributed_session_count: attributedSessionCount,
1288
+ codex_session_count: codexSessionCount,
1289
+ claude_session_count: claudeSessionCount,
859
1290
  cursor_tracked_object_count: sync.cursor_tracked_object_count,
860
1291
  failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
861
1292
  };
@@ -959,7 +1390,7 @@ async function runLogout(command, io) {
959
1390
  return 0;
960
1391
  }
961
1392
  async function runStart(command, io) {
962
- const worktrees = await discoverCommandWorktrees(command.repoRoot);
1393
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
963
1394
  if (worktrees.length > 1) {
964
1395
  const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
965
1396
  homeDir: command.homeDir,
@@ -992,7 +1423,27 @@ async function runStart(command, io) {
992
1423
  return 0;
993
1424
  }
994
1425
  async function runSync(command, io) {
995
- const worktrees = await discoverCommandWorktrees(command.repoRoot);
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) {
1446
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
996
1447
  const run = await runAttributedWorktreeSync({
997
1448
  homeDir: command.homeDir,
998
1449
  dashboardUrl: command.dashboardUrl,
@@ -1001,7 +1452,7 @@ async function runSync(command, io) {
1001
1452
  fetchImpl: io.fetch,
1002
1453
  });
1003
1454
  if (worktrees.length > 1) {
1004
- const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
1455
+ const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1005
1456
  if (command.json) {
1006
1457
  writeLine(io.stdout, JSON.stringify({
1007
1458
  mode: "multi_repo",
@@ -1019,8 +1470,7 @@ async function runSync(command, io) {
1019
1470
  const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
1020
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}`);
1021
1472
  }
1022
- writeLine(io.stdout, codexSummaryLine(run.summary));
1023
- writeLine(io.stdout, attributionReportLine(run.summary));
1473
+ writeAgentSessionSummary(io, run.summary);
1024
1474
  return run.ok ? 0 : 1;
1025
1475
  }
1026
1476
  const result = run.outcomes[0]?.sync;
@@ -1040,8 +1490,7 @@ async function runSync(command, io) {
1040
1490
  writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
1041
1491
  writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
1042
1492
  writeLine(io.stdout, rawEvidenceSyncLine(result));
1043
- writeLine(io.stdout, codexSummaryLine(run.summary));
1044
- writeLine(io.stdout, attributionReportLine(run.summary));
1493
+ writeAgentSessionSummary(io, run.summary);
1045
1494
  writeLine(io.stdout, cursorStatusLine(result));
1046
1495
  return 0;
1047
1496
  }
@@ -1052,7 +1501,7 @@ async function runSync(command, io) {
1052
1501
  return 1;
1053
1502
  }
1054
1503
  async function runStatus(command, io) {
1055
- const worktrees = await discoverCommandWorktrees(command.repoRoot);
1504
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1056
1505
  if (worktrees.length > 1) {
1057
1506
  const statuses = await Promise.all(worktrees.map(async (worktree) => ({
1058
1507
  ...(await inspectLocalCollectorStatus({
@@ -1095,6 +1544,100 @@ async function runStatus(command, io) {
1095
1544
  function displayTicketId(ticketId) {
1096
1545
  return ticketId ?? "general ambient";
1097
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
+ }
1098
1641
  async function runServe(command, io) {
1099
1642
  const server = createCollectorServer(command);
1100
1643
  await new Promise((resolve) => {