@bli-cockpit/cli 0.2.33 → 0.2.35

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.
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.33");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.35");
19
19
  return 0;
20
20
  }
21
21
 
@@ -46,6 +46,92 @@ function allLocalHistorySinceMinutes(now) {
46
46
  // Claude session files while keeping date arithmetic finite and portable.
47
47
  return Math.ceil(now.getTime() / 60_000) + 24 * 60;
48
48
  }
49
+ /** Whether the spool already recorded a source-scan retry for this source. */
50
+ function sourceScanRetryIsPending(uploadSpool, source) {
51
+ return Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === source));
52
+ }
53
+ /**
54
+ * Does this source have a reason to widen its scan window to all local
55
+ * history instead of the normal live-sync window? Used to be two separately
56
+ * written boolean chains, one per source, that happened to agree on shape by
57
+ * hand — asymmetric to read even though the rule is identical for both
58
+ * (BLI-3394): a legacy spooled upload with no recorded source, a scan-retry
59
+ * the spool already remembers, a spooled upload that names this source, or an
60
+ * undurable session this source's own cursor is still carrying.
61
+ */
62
+ function sourceHasPendingRetries(source, uploadSpool, legacyRetryPending, sourceScanRetryPending, cursor) {
63
+ return (legacyRetryPending ||
64
+ sourceScanRetryPending ||
65
+ Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes(source))) ||
66
+ cursorHasUndurableCollectableSession(cursor));
67
+ }
68
+ /**
69
+ * Scan and attribute Codex sessions for this sync, widening the window to all
70
+ * local history when a retry is pending. Codex has no first-run backfill
71
+ * concept (that is a Claude-only window, see `scanClaudeSessionsForSync`) and
72
+ * is always scanned, unlike Claude which can be disabled entirely.
73
+ */
74
+ async function scanCodexSessionsForSync(options) {
75
+ const sinceMinutes = options.retryPending
76
+ ? options.allHistorySinceMinutes
77
+ : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
78
+ let scan = await scanAndAttributeCodexSessions({
79
+ sessionsDirs: defaultCodexSessionDirs(options.homeDir),
80
+ worktrees: options.worktrees,
81
+ now: options.now,
82
+ sinceMinutes,
83
+ limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
84
+ collectionRoots: options.collectionRoots,
85
+ });
86
+ if (scan.session_limit_applied) {
87
+ // Re-run once, bounded by exactly what the first pass discovered, so a
88
+ // capped scan still returns every session it found instead of silently
89
+ // truncating at the window's default limit.
90
+ scan = await scanAndAttributeCodexSessions({
91
+ sessionsDirs: defaultCodexSessionDirs(options.homeDir),
92
+ worktrees: options.worktrees,
93
+ now: options.now,
94
+ sinceMinutes,
95
+ limit: scan.discovered_file_count,
96
+ collectionRoots: options.collectionRoots,
97
+ });
98
+ }
99
+ return scan;
100
+ }
101
+ /**
102
+ * Scan and attribute Claude sessions for this sync — the Claude counterpart to
103
+ * `scanCodexSessionsForSync`. Disabled collection returns an empty scan
104
+ * up front; a first sync (no cursor yet) widens the window to 14 days instead
105
+ * of the normal 24h so it captures retroactive history.
106
+ */
107
+ async function scanClaudeSessionsForSync(options) {
108
+ if (!options.claudeEnabled)
109
+ return emptyClaudeScan();
110
+ const sinceMinutes = options.retryPending
111
+ ? options.allHistorySinceMinutes
112
+ : options.firstRunBackfill
113
+ ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
114
+ : undefined;
115
+ const projectsDir = path.join(options.homeDir, ".claude", "projects");
116
+ let scan = await scanAndAttributeClaudeSessions({
117
+ projectsDir,
118
+ worktrees: options.worktrees,
119
+ now: options.now,
120
+ collectionRoots: options.collectionRoots,
121
+ sinceMinutes,
122
+ });
123
+ if (scan.session_limit_applied) {
124
+ scan = await scanAndAttributeClaudeSessions({
125
+ projectsDir,
126
+ worktrees: options.worktrees,
127
+ now: options.now,
128
+ collectionRoots: options.collectionRoots,
129
+ sinceMinutes,
130
+ limit: scan.discovered_session_count,
131
+ });
132
+ }
133
+ return scan;
134
+ }
49
135
  function codexAttributionReadFailureCount(scan) {
50
136
  return scan.directory_read_failed_count + scan.stat_failed_count;
51
137
  }
@@ -140,71 +226,34 @@ export async function runAttributedWorktreeSync(options) {
140
226
  readLocalUploadSpoolState(paths).catch(() => null),
141
227
  ]);
142
228
  const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
143
- const codexSourceRetryPending = Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === "codex"));
144
- const claudeSourceRetryPending = Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === "claude_code"));
145
- const codexRetryPending = legacyRetryPending ||
146
- codexSourceRetryPending ||
147
- Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes("codex"))) ||
148
- cursorHasUndurableCollectableSession(codexCursorBefore);
229
+ const codexSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "codex");
230
+ const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
231
+ const codexRetryPending = sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore);
149
232
  const claudeRetryPending = claudeEnabled &&
150
- (legacyRetryPending ||
151
- claudeSourceRetryPending ||
152
- Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes("claude_code"))) ||
153
- cursorHasUndurableCollectableSession(claudeCursorBefore));
233
+ sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore);
154
234
  const allHistorySinceMinutes = allLocalHistorySinceMinutes(now);
155
- let codexAttribution = await scanAndAttributeCodexSessions({
156
- sessionsDirs: defaultCodexSessionDirs(homeDir),
235
+ const codexAttribution = await scanCodexSessionsForSync({
236
+ homeDir,
157
237
  worktrees: options.worktrees,
158
238
  now,
159
- sinceMinutes: codexRetryPending
160
- ? allHistorySinceMinutes
161
- : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
162
- limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
163
239
  collectionRoots,
240
+ retryPending: codexRetryPending,
241
+ allHistorySinceMinutes,
164
242
  });
165
- if (codexAttribution.session_limit_applied) {
166
- codexAttribution = await scanAndAttributeCodexSessions({
167
- sessionsDirs: defaultCodexSessionDirs(homeDir),
168
- worktrees: options.worktrees,
169
- now,
170
- sinceMinutes: codexRetryPending
171
- ? allHistorySinceMinutes
172
- : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
173
- limit: codexAttribution.discovered_file_count,
174
- collectionRoots,
175
- });
176
- }
177
243
  // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
178
244
  // days so the first sync captures retroactive history instead of only 24h.
179
245
  const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
180
246
  const firstRunBackfill = claudeEnabled && !claudeCursorExists;
181
- let claudeAttribution = claudeEnabled
182
- ? await scanAndAttributeClaudeSessions({
183
- projectsDir: path.join(homeDir, ".claude", "projects"),
184
- worktrees: options.worktrees,
185
- now,
186
- collectionRoots,
187
- sinceMinutes: claudeRetryPending
188
- ? allHistorySinceMinutes
189
- : firstRunBackfill
190
- ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
191
- : undefined,
192
- })
193
- : emptyClaudeScan();
194
- if (claudeAttribution.session_limit_applied) {
195
- claudeAttribution = await scanAndAttributeClaudeSessions({
196
- projectsDir: path.join(homeDir, ".claude", "projects"),
197
- worktrees: options.worktrees,
198
- now,
199
- collectionRoots,
200
- sinceMinutes: claudeRetryPending
201
- ? allHistorySinceMinutes
202
- : firstRunBackfill
203
- ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
204
- : undefined,
205
- limit: claudeAttribution.discovered_session_count,
206
- });
207
- }
247
+ const claudeAttribution = await scanClaudeSessionsForSync({
248
+ claudeEnabled,
249
+ homeDir,
250
+ worktrees: options.worktrees,
251
+ now,
252
+ collectionRoots,
253
+ retryPending: claudeRetryPending,
254
+ allHistorySinceMinutes,
255
+ firstRunBackfill,
256
+ });
208
257
  await reconcileSourceScanRetry({
209
258
  paths,
210
259
  source: "codex",
@@ -359,67 +408,23 @@ export async function runAttributedWorktreeSync(options) {
359
408
  queuedCurrentSessionReport;
360
409
  // The sessions cursor is an optimization; a broken local state dir must not
361
410
  // turn already-completed syncs into a CLI crash.
362
- let codexStaleCount = 0;
363
- let claudeStaleCount = 0;
364
- try {
365
- const codexCursor = await readRawEvidenceCursor(paths);
366
- codexStaleCount = recordSourceObservations({
367
- cursor: codexCursor,
368
- results: codexAttribution.results,
369
- sessions,
370
- source: "codex",
371
- sessionIdOf: (result) => result.codex_session_id,
372
- now,
373
- priorCursor: codexCursorBefore,
374
- terminalizeMissingUndurable: codexRetryPending &&
375
- codexAttributionReadFailureCount(codexAttribution) === 0,
376
- });
377
- codexCursor.updated_at = now.toISOString();
378
- await writeRawEvidenceCursor(paths, codexCursor);
379
- }
380
- catch (error) {
381
- // Best-effort: stale counts read 0 and observations re-record next sync —
382
- // which is fine ONCE. A cursor write that keeps failing means the sessions
383
- // cursor never advances, every sync re-does the same work, and the only
384
- // symptom is a stale count that is permanently zero (BLI-3238).
385
- console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
386
- reason: "session_cursor_update_failed",
387
- source: "codex",
388
- session_count: sessions.length,
389
- ...describeError(error),
390
- }));
391
- }
392
- if (claudeEnabled) {
393
- try {
394
- claudeStaleCount = recordSourceObservations({
395
- cursor: claudeCursorBefore,
396
- results: claudeAttribution.results,
397
- sessions,
398
- source: "claude_code",
399
- sessionIdOf: (result) => result.claude_session_id,
400
- now,
401
- priorCursor: claudeCursorBefore,
402
- terminalizeMissingUndurable: claudeRetryPending &&
403
- claudeAttributionReadFailureCount(claudeAttribution) === 0,
404
- });
405
- claudeCursorBefore.updated_at = now.toISOString();
406
- await writeRawEvidenceCursor(paths, claudeCursorBefore, {
407
- filename: CLAUDE_CURSOR_FILENAME,
408
- sessionsOnly: true,
409
- });
410
- }
411
- catch (error) {
412
- // Best-effort: a broken Claude cursor must not fail the sync. It must
413
- // still say it is broken — otherwise the Claude half of collection
414
- // quietly repeats itself forever.
415
- console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
416
- reason: "session_cursor_update_failed",
417
- source: "claude_code",
418
- session_count: sessions.length,
419
- ...describeError(error),
420
- }));
421
- }
422
- }
411
+ const codexStaleCount = await recordCodexSessionObservationsForSync({
412
+ paths,
413
+ codexAttribution,
414
+ sessions,
415
+ now,
416
+ priorCursor: codexCursorBefore,
417
+ codexRetryPending,
418
+ });
419
+ const claudeStaleCount = await recordClaudeSessionObservationsForSync({
420
+ claudeEnabled,
421
+ paths,
422
+ claudeAttribution,
423
+ sessions,
424
+ now,
425
+ claudeCursorBefore,
426
+ claudeRetryPending,
427
+ });
423
428
  const report = hadPendingSessionReports || queuedCurrentSessionReport
424
429
  ? await flushPendingCodexSessionReports({
425
430
  homeDir: options.homeDir,
@@ -529,17 +534,16 @@ function normalizeClaudeResult(result) {
529
534
  };
530
535
  }
531
536
  /**
532
- * Generalizes the per-session report across sources. Dedupe is per
533
- * `(source, session_id)` so a Codex session and a Claude session that happen to
534
- * share an id are never collapsed. Upload state maps ONLY from the main-file
535
- * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
536
- * session's upload state (D3). Damped Claude sessions report `reused_existing`
537
- * carrying their prior durable pointer.
537
+ * Dedupe every source's attributed results down to the single best result per
538
+ * `(source, session_id)`. Best means highest attribution-state rank, ties
539
+ * broken by the newer file mtime. Codex and Claude sessions that happen to
540
+ * share an id are never collapsed into each other because the key carries the
541
+ * source.
538
542
  */
539
- export function buildAgentSessionReport(options) {
543
+ function bestAttributedSessionsByKey(codexResults, claudeResults) {
540
544
  const normalized = [
541
- ...options.codexResults.map(normalizeCodexResult),
542
- ...options.claudeResults.map(normalizeClaudeResult),
545
+ ...codexResults.map(normalizeCodexResult),
546
+ ...claudeResults.map(normalizeClaudeResult),
543
547
  ];
544
548
  const bestByKey = new Map();
545
549
  for (const result of normalized) {
@@ -554,20 +558,19 @@ export function buildAgentSessionReport(options) {
554
558
  bestByKey.set(key, result);
555
559
  }
556
560
  }
557
- // Main-file outcomes only (D3): a sidecar making it must never mark a session
558
- // uploaded when the main did not.
561
+ return bestByKey;
562
+ }
563
+ /**
564
+ * Which sessions this sync actually uploaded, and why the rest did not — read
565
+ * ONLY from main-file outcomes (kind `codex_jsonl` / `claude_jsonl`); a
566
+ * sidecar making it must never mark a session uploaded when the main did not
567
+ * (D3).
568
+ */
569
+ function sessionUploadOutcomesByKey(outcomes) {
559
570
  const uploadByKey = new Map();
560
- // BLI-2107: why a session got no pointer, keyed the same way. The reasons
561
- // already existed — file_too_large, deferred_byte_budget, the redaction
562
- // guards — but only as aggregate skip counts in the health report, so no
563
- // individual session could say what happened to it.
564
571
  const noUploadReasonByKey = new Map();
565
- // A worktree sync that did not finish carries no per-session outcomes, and
566
- // WorktreeSyncOutcome does not record which sessions it was going to cover.
567
- // So this pass knows only that it was degraded, not which session each
568
- // failure belonged to — and says exactly that rather than picking one.
569
572
  let anySyncIncomplete = false;
570
- for (const outcome of options.outcomes) {
573
+ for (const outcome of outcomes) {
571
574
  if (outcome.sync.status !== "uploaded") {
572
575
  anySyncIncomplete = true;
573
576
  continue;
@@ -593,84 +596,194 @@ export function buildAgentSessionReport(options) {
593
596
  uploadByKey.set(key, upload);
594
597
  }
595
598
  }
596
- return [...bestByKey.values()].map((result) => {
597
- const key = `${result.source}:${result.session_id}`;
598
- const upload = uploadByKey.get(key);
599
- // A previously-durable Claude session with no fresh main upload this sync
600
- // (damped / spooled / budget-deferred) reports reused_existing + its prior
601
- // pointer rather than not_uploaded, so the store row never flips.
602
- const priorDurablePointer = result.source === "claude_code"
603
- ? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
604
- : null;
605
- return {
606
- codex_session_id: result.session_id,
607
- source: result.source,
608
- observed_at: options.now.toISOString(),
609
- attribution_state: result.state,
610
- attribution_reason: result.reason,
611
- attribution_score: result.attribution_score,
612
- path_score: result.path_score,
613
- signals: result.signals,
614
- ...(result.content_hash_sha256
615
- ? { session_file_hash_sha256: result.content_hash_sha256 }
616
- : {}),
617
- session_file_byte_size: result.byte_size,
618
- session_file_mtime: result.session_file_mtime,
619
- ...(result.worktree
620
- ? {
621
- repo_fingerprint: result.worktree.repo_fingerprint,
622
- worktree_fingerprint: result.worktree.worktree_fingerprint,
623
- repo_label: result.worktree.repo_label,
624
- branch: result.worktree.branch,
625
- }
626
- : {}),
627
- ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
628
- ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
629
- ...(upload
599
+ return { uploadByKey, noUploadReasonByKey, anySyncIncomplete };
600
+ }
601
+ /** Turn one deduped, best-ranked session result into its report row. */
602
+ function sessionReportEntry(result, context) {
603
+ const key = `${result.source}:${result.session_id}`;
604
+ const upload = context.uploadByKey.get(key);
605
+ // A previously-durable Claude session with no fresh main upload this sync
606
+ // (damped / spooled / budget-deferred) reports reused_existing + its prior
607
+ // pointer rather than not_uploaded, so the store row never flips.
608
+ const priorDurablePointer = result.source === "claude_code"
609
+ ? (context.claudePriorDurablePointers.get(result.session_id) ?? null)
610
+ : null;
611
+ return {
612
+ codex_session_id: result.session_id,
613
+ source: result.source,
614
+ observed_at: context.now.toISOString(),
615
+ attribution_state: result.state,
616
+ attribution_reason: result.reason,
617
+ attribution_score: result.attribution_score,
618
+ path_score: result.path_score,
619
+ signals: result.signals,
620
+ ...(result.content_hash_sha256
621
+ ? { session_file_hash_sha256: result.content_hash_sha256 }
622
+ : {}),
623
+ session_file_byte_size: result.byte_size,
624
+ session_file_mtime: result.session_file_mtime,
625
+ ...(result.worktree
626
+ ? {
627
+ repo_fingerprint: result.worktree.repo_fingerprint,
628
+ worktree_fingerprint: result.worktree.worktree_fingerprint,
629
+ repo_label: result.worktree.repo_label,
630
+ branch: result.worktree.branch,
631
+ }
632
+ : {}),
633
+ ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
634
+ ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
635
+ ...(upload
636
+ ? {
637
+ raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
638
+ upload_state: upload.upload_state,
639
+ // A failed upload names itself; a successful one has nothing to
640
+ // explain.
641
+ ...(upload.upload_state === "upload_failed"
642
+ ? {
643
+ upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED,
644
+ }
645
+ : {}),
646
+ }
647
+ : priorDurablePointer
630
648
  ? {
631
- raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
632
- upload_state: upload.upload_state,
633
- // A failed upload names itself; a successful one has nothing to
634
- // explain.
635
- ...(upload.upload_state === "upload_failed"
636
- ? {
637
- upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED,
638
- }
639
- : {}),
649
+ raw_evidence_pointer_id: priorDurablePointer,
650
+ upload_state: "reused_existing",
640
651
  }
641
- : priorDurablePointer
652
+ : isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
642
653
  ? {
643
- raw_evidence_pointer_id: priorDurablePointer,
644
- upload_state: "reused_existing",
654
+ upload_state: "not_uploaded",
655
+ // BLI-2107: `not_uploaded` used to be the branch of last
656
+ // resort, recording that nothing happened and never why. It
657
+ // now always carries a cause, even when the cause is that we
658
+ // have none — a session labelled NO_UPLOAD_ATTEMPT_RECORDED
659
+ // is a path that still needs instrumenting, and saying so is
660
+ // the point.
661
+ upload_reason: context.noUploadReasonByKey.get(key) ??
662
+ (context.anySyncIncomplete
663
+ ? "sync_incomplete_this_pass"
664
+ : NO_UPLOAD_ATTEMPT_RECORDED),
645
665
  }
646
- : isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
647
- ? {
648
- upload_state: "not_uploaded",
649
- // BLI-2107: `not_uploaded` used to be the branch of last
650
- // resort, recording that nothing happened and never why. It
651
- // now always carries a cause, even when the cause is that we
652
- // have none — a session labelled NO_UPLOAD_ATTEMPT_RECORDED
653
- // is a path that still needs instrumenting, and saying so is
654
- // the point.
655
- upload_reason: noUploadReasonByKey.get(key) ??
656
- (anySyncIncomplete
657
- ? "sync_incomplete_this_pass"
658
- : NO_UPLOAD_ATTEMPT_RECORDED),
659
- }
660
- : {
661
- // BLI-3272: the upload policy refused this session's
662
- // attribution state. That refusal used to spread `{}` here, so
663
- // the row landed with upload_state NULL and upload_reason NULL
664
- // — a withhold that named nothing, on 1,256 production
665
- // sessions. It is a decision like any other and it says so.
666
- // Any reason the pipeline did record still wins: it is the more
667
- // specific answer.
668
- upload_state: "not_uploaded",
669
- upload_reason: noUploadReasonByKey.get(key) ??
670
- notUploadableAttributionStateReason(result.reason),
671
- }),
672
- };
673
- });
666
+ : {
667
+ // BLI-3272: the upload policy refused this session's
668
+ // attribution state. That refusal used to spread `{}` here, so
669
+ // the row landed with upload_state NULL and upload_reason NULL
670
+ // — a withhold that named nothing, on 1,256 production
671
+ // sessions. It is a decision like any other and it says so.
672
+ // Any reason the pipeline did record still wins: it is the more
673
+ // specific answer.
674
+ upload_state: "not_uploaded",
675
+ upload_reason: context.noUploadReasonByKey.get(key) ??
676
+ notUploadableAttributionStateReason(result.reason),
677
+ }),
678
+ };
679
+ }
680
+ /**
681
+ * Generalizes the per-session report across sources. Dedupe is per
682
+ * `(source, session_id)` so a Codex session and a Claude session that happen to
683
+ * share an id are never collapsed. Upload state maps ONLY from the main-file
684
+ * outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
685
+ * session's upload state (D3). Damped Claude sessions report `reused_existing`
686
+ * carrying their prior durable pointer.
687
+ *
688
+ * The pass, named: pick the best-ranked result per session
689
+ * (`bestAttributedSessionsByKey`), read back what this sync actually uploaded
690
+ * (`sessionUploadOutcomesByKey`), then render one report row per session
691
+ * (`sessionReportEntry`).
692
+ */
693
+ export function buildAgentSessionReport(options) {
694
+ const bestByKey = bestAttributedSessionsByKey(options.codexResults, options.claudeResults);
695
+ const uploadOutcomes = sessionUploadOutcomesByKey(options.outcomes);
696
+ return [...bestByKey.values()].map((result) => sessionReportEntry(result, {
697
+ ...uploadOutcomes,
698
+ claudePriorDurablePointers: options.claudePriorDurablePointers,
699
+ now: options.now,
700
+ }));
701
+ }
702
+ /**
703
+ * Record this sync's Codex session observations into the Codex cursor and
704
+ * return the stale count. Best-effort by design: a broken local state dir
705
+ * must not turn an already-completed sync into a CLI crash, so a failure here
706
+ * only logs — the stale count simply keeps whatever value it had when the
707
+ * failure happened (0 if the read itself failed).
708
+ *
709
+ * Reads the cursor fresh from disk right before recording rather than reusing
710
+ * `priorCursor`, so a concurrent writer's update is not clobbered. Claude's
711
+ * counterpart does not do this extra read (see
712
+ * `recordClaudeSessionObservationsForSync`) — a real, intentional asymmetry
713
+ * kept as-is rather than forced to match.
714
+ */
715
+ async function recordCodexSessionObservationsForSync(options) {
716
+ let staleCount = 0;
717
+ try {
718
+ const codexCursor = await readRawEvidenceCursor(options.paths);
719
+ staleCount = recordSourceObservations({
720
+ cursor: codexCursor,
721
+ results: options.codexAttribution.results,
722
+ sessions: options.sessions,
723
+ source: "codex",
724
+ sessionIdOf: (result) => result.codex_session_id,
725
+ now: options.now,
726
+ priorCursor: options.priorCursor,
727
+ terminalizeMissingUndurable: options.codexRetryPending &&
728
+ codexAttributionReadFailureCount(options.codexAttribution) === 0,
729
+ });
730
+ codexCursor.updated_at = options.now.toISOString();
731
+ await writeRawEvidenceCursor(options.paths, codexCursor);
732
+ }
733
+ catch (error) {
734
+ // Best-effort: stale counts read 0 and observations re-record next sync —
735
+ // which is fine ONCE. A cursor write that keeps failing means the sessions
736
+ // cursor never advances, every sync re-does the same work, and the only
737
+ // symptom is a stale count that is permanently zero (BLI-3238).
738
+ console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
739
+ reason: "session_cursor_update_failed",
740
+ source: "codex",
741
+ session_count: options.sessions.length,
742
+ ...describeError(error),
743
+ }));
744
+ }
745
+ return staleCount;
746
+ }
747
+ /**
748
+ * Record this sync's Claude session observations into the Claude cursor and
749
+ * return the stale count — the Claude counterpart to
750
+ * `recordCodexSessionObservationsForSync`. A no-op returning 0 when Claude
751
+ * collection is disabled, matching the source-scan side's own disabled state.
752
+ */
753
+ async function recordClaudeSessionObservationsForSync(options) {
754
+ if (!options.claudeEnabled)
755
+ return 0;
756
+ let staleCount = 0;
757
+ try {
758
+ staleCount = recordSourceObservations({
759
+ cursor: options.claudeCursorBefore,
760
+ results: options.claudeAttribution.results,
761
+ sessions: options.sessions,
762
+ source: "claude_code",
763
+ sessionIdOf: (result) => result.claude_session_id,
764
+ now: options.now,
765
+ priorCursor: options.claudeCursorBefore,
766
+ terminalizeMissingUndurable: options.claudeRetryPending &&
767
+ claudeAttributionReadFailureCount(options.claudeAttribution) === 0,
768
+ });
769
+ options.claudeCursorBefore.updated_at = options.now.toISOString();
770
+ await writeRawEvidenceCursor(options.paths, options.claudeCursorBefore, {
771
+ filename: CLAUDE_CURSOR_FILENAME,
772
+ sessionsOnly: true,
773
+ });
774
+ }
775
+ catch (error) {
776
+ // Best-effort: a broken Claude cursor must not fail the sync. It must
777
+ // still say it is broken — otherwise the Claude half of collection
778
+ // quietly repeats itself forever.
779
+ console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
780
+ reason: "session_cursor_update_failed",
781
+ source: "claude_code",
782
+ session_count: options.sessions.length,
783
+ ...describeError(error),
784
+ }));
785
+ }
786
+ return staleCount;
674
787
  }
675
788
  /**
676
789
  * Records per-source session observations into its cursor and returns the stale
@@ -89,7 +89,7 @@ export async function runSessions(command, io) {
89
89
  }, null, 2));
90
90
  return 0;
91
91
  }
92
- writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
92
+ writeLine(io.stdout, "Tower sessions (read-only attribution)");
93
93
  writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
94
94
  for (const row of codexRows) {
95
95
  writeLine(io.stdout, sessionRowLine(row));
@@ -35,7 +35,7 @@ export async function runStatus(command, io) {
35
35
  writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
36
36
  return 0;
37
37
  }
38
- writeLine(io.stdout, "Cockpit status — every repo below this folder");
38
+ writeLine(io.stdout, "Tower status — every repo below this folder");
39
39
  writeLine(io.stdout, `Old sessions: ${backfillCursorLine(backfillCursor)}`);
40
40
  for (const status of statuses) {
41
41
  writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
@@ -47,7 +47,7 @@ export async function runStatus(command, io) {
47
47
  writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
48
48
  return 0;
49
49
  }
50
- writeLine(io.stdout, "Cockpit status");
50
+ writeLine(io.stdout, "Tower status");
51
51
  writeLine(io.stdout, `Installed: ${status.installed ? "yes" : "no"}`);
52
52
  writeLine(io.stdout, `Signed in: ${signedInLine(status.session_state)}`);
53
53
  writeLine(io.stdout, `Repo: ${status.repo}`);
@@ -332,7 +332,7 @@ export function missingCollectionRootMessage(options = {}) {
332
332
  const homeDir = path.resolve(options.homeDir ?? os.homedir());
333
333
  return [
334
334
  "No collection root was confirmed:",
335
- " Cockpit only scans folders you name or confirm.",
335
+ " Tower only scans folders you name or confirm.",
336
336
  "What you can do:",
337
337
  ` 1) Paste one work parent: ${path.join(homeDir, "BLI")}`,
338
338
  ` 2) Paste specific folders: ${path.join(homeDir, "repo-a")},${path.join(homeDir, "repo-b")}`,