@bli-cockpit/cli 0.1.29 → 0.1.30

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.
@@ -26,6 +26,8 @@ export function parseLocalArgs(argv) {
26
26
  return parseStartArgs(argv.slice(1));
27
27
  case "sync":
28
28
  return parseSyncArgs(argv.slice(1));
29
+ case "backfill":
30
+ return parseBackfillArgs(argv.slice(1));
29
31
  case "status":
30
32
  return parseStatusArgs(argv.slice(1));
31
33
  case "sessions":
@@ -304,6 +306,52 @@ function parseSyncArgs(args) {
304
306
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
305
307
  };
306
308
  }
309
+ function parseBackfillArgs(args) {
310
+ const values = parseNamedArgs(args, {
311
+ allowedFlags: [
312
+ "--home",
313
+ "--repo",
314
+ "--workspace",
315
+ "--since-days",
316
+ "--all",
317
+ "--source",
318
+ "--dry-run",
319
+ "--max-files",
320
+ "--yes",
321
+ "--json",
322
+ ],
323
+ valueFlags: [
324
+ "--home",
325
+ "--repo",
326
+ "--workspace",
327
+ "--since-days",
328
+ "--source",
329
+ "--max-files",
330
+ ],
331
+ });
332
+ assertNoPositionals(values.positionals, "backfill");
333
+ const source = values.flags.get("--source");
334
+ if (source !== undefined && source !== "codex" && source !== "claude") {
335
+ throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
336
+ }
337
+ const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
338
+ const all = values.booleans.has("--all");
339
+ if (all && sinceDays !== undefined) {
340
+ throw new Error("--since-days and --all cannot be combined.");
341
+ }
342
+ return {
343
+ kind: "backfill",
344
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
345
+ repoRoot: optionalNonEmpty(workRootFlagValue(values)),
346
+ source,
347
+ sinceDays,
348
+ all,
349
+ dryRun: values.booleans.has("--dry-run"),
350
+ maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
351
+ yes: values.booleans.has("--yes"),
352
+ json: values.booleans.has("--json"),
353
+ };
354
+ }
307
355
  function parseStatusArgs(args) {
308
356
  const values = parseNamedArgs(args, {
309
357
  allowedFlags: [
@@ -339,6 +387,8 @@ function parseSessionsArgs(args) {
339
387
  "--repo",
340
388
  "--workspace",
341
389
  "--source",
390
+ "--since-days",
391
+ "--all",
342
392
  "--json",
343
393
  "--max-depth",
344
394
  "--max-repos",
@@ -348,6 +398,7 @@ function parseSessionsArgs(args) {
348
398
  "--repo",
349
399
  "--workspace",
350
400
  "--source",
401
+ "--since-days",
351
402
  "--max-depth",
352
403
  "--max-repos",
353
404
  ],
@@ -355,13 +406,20 @@ function parseSessionsArgs(args) {
355
406
  assertNoPositionals(values.positionals, "sessions");
356
407
  const source = values.flags.get("--source");
357
408
  if (source !== undefined && source !== "codex" && source !== "claude") {
358
- throw new Error("--source must be 'codex' or 'claude'.");
409
+ throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
410
+ }
411
+ const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
412
+ const all = values.booleans.has("--all");
413
+ if (all && sinceDays !== undefined) {
414
+ throw new Error("--since-days and --all cannot be combined.");
359
415
  }
360
416
  return {
361
417
  kind: "sessions",
362
418
  homeDir: optionalNonEmpty(values.flags.get("--home")),
363
419
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
364
420
  source,
421
+ sinceDays,
422
+ all,
365
423
  json: values.booleans.has("--json"),
366
424
  maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
367
425
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
@@ -1,14 +1,17 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
2
+ import { access, readdir, readFile, stat } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { createCollectorServer } from "../server.js";
6
6
  import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
7
+ import { runBackfillCommand } from "./backfill.js";
8
+ import { inspectBackfillLock } from "../backfill-lock.js";
7
9
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
8
10
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
9
11
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
10
- import { CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT, CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
12
+ import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
11
13
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
14
+ import { backfillCompletionMarkerPath, readBackfillCursor, } from "../cursors/backfill-cursor.js";
12
15
  import { acquireSyncLock } from "../sync-lock.js";
13
16
  import { discoverGitWorktrees } from "../repo-identity.js";
14
17
  import { runAttributedWorktreeSync, } from "./session-sync.js";
@@ -23,6 +26,7 @@ export const rootCommandNames = new Set([
23
26
  "logout",
24
27
  "start",
25
28
  "sync",
29
+ "backfill",
26
30
  "status",
27
31
  "sessions",
28
32
  "serve",
@@ -61,6 +65,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
61
65
  return await runStart(command, io);
62
66
  case "sync":
63
67
  return await runSync(command, io);
68
+ case "backfill":
69
+ return await runBackfillCommand(command, io);
64
70
  case "status":
65
71
  return await runStatus(command, io);
66
72
  case "sessions":
@@ -93,8 +99,9 @@ export function localCommandHelp(command) {
93
99
  " cockpit logout",
94
100
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
95
101
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
102
+ " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
96
103
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
97
- " cockpit sessions [--source codex|claude] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
104
+ " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
98
105
  " cockpit serve [--port <port>] [--workspace <path>]",
99
106
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
100
107
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
@@ -198,6 +205,18 @@ function localSubcommandHelp(command) {
198
205
  "--max-depth and --max-repos.",
199
206
  ],
200
207
  ],
208
+ [
209
+ "backfill",
210
+ [
211
+ "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
212
+ "",
213
+ "Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
214
+ "Omit --source to scan both sources; there is no --source all literal.",
215
+ "The non-all window is capped at the collector session paired_at timestamp.",
216
+ "--all requires a dry-run review and TTY confirmation; pass --yes for headless agent runs.",
217
+ "--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
218
+ ],
219
+ ],
201
220
  [
202
221
  "status",
203
222
  [
@@ -210,12 +229,13 @@ function localSubcommandHelp(command) {
210
229
  [
211
230
  "sessions",
212
231
  [
213
- "Usage: cockpit sessions [--source codex|claude] [--workspace <path>] [--json]",
232
+ "Usage: cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--json]",
214
233
  "",
215
234
  "Read-only: re-runs Codex + Claude session attribution and prints each",
216
235
  "session's id, source, state, reason, scores, signals, and per-sidecar",
217
236
  "skip reasons. No upload, no cursor writes. Answers \"why is session X",
218
237
  "missing?\" locally — counts and labels only, never paths or content.",
238
+ "--since-days uses the same paired_at cap as `cockpit backfill`; --all scans the full local history.",
219
239
  "`--repo <path>` remains supported as a backward-compatible alias.",
220
240
  ],
221
241
  ],
@@ -638,6 +658,8 @@ function writeOnboardLiveStatus(io, command, roots, options) {
638
658
  writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
639
659
  writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
640
660
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
661
+ if (options.backfillHint)
662
+ writeLine(io.stdout, options.backfillHint);
641
663
  writeLine(io.stdout, "Next: cockpit status");
642
664
  }
643
665
  function backgroundSyncLine(result) {
@@ -660,6 +682,7 @@ async function runOnboard(command, io) {
660
682
  let rootsResult = null;
661
683
  let agentRules = null;
662
684
  let autostart = null;
685
+ let backfillHint = null;
663
686
  try {
664
687
  if (!command.json) {
665
688
  writeLine(io.stdout, "Cockpit harvest onboarding");
@@ -667,6 +690,7 @@ async function runOnboard(command, io) {
667
690
  writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
668
691
  }
669
692
  const paths = getCollectorRuntimePaths(command.homeDir);
693
+ backfillHint = await onboardBackfillHint(command.homeDir);
670
694
  const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
671
695
  const interactive = !command.json && isInteractiveStdin(io);
672
696
  rootsResult = await resolveOnboardingRoots({
@@ -756,6 +780,7 @@ async function runOnboard(command, io) {
756
780
  root_resolution: rootsResult,
757
781
  agent_rules: agentRules,
758
782
  autostart,
783
+ backfill_hint: backfillHint,
759
784
  mode: "multi_repo",
760
785
  repos: multi.results,
761
786
  codex_sessions: multi.codex_sessions,
@@ -769,6 +794,7 @@ async function runOnboard(command, io) {
769
794
  agentRules,
770
795
  autostart,
771
796
  initialSyncOk: true,
797
+ backfillHint,
772
798
  });
773
799
  }
774
800
  return multi.ok ? 0 : 1;
@@ -828,6 +854,7 @@ async function runOnboard(command, io) {
828
854
  root_resolution: rootsResult,
829
855
  agent_rules: agentRules,
830
856
  autostart,
857
+ backfill_hint: backfillHint,
831
858
  codex_sessions: run.summary,
832
859
  }, null, 2));
833
860
  return 0;
@@ -853,6 +880,7 @@ async function runOnboard(command, io) {
853
880
  agentRules,
854
881
  autostart,
855
882
  initialSyncOk: true,
883
+ backfillHint,
856
884
  });
857
885
  return 0;
858
886
  }
@@ -981,6 +1009,8 @@ function cursorStatusLine(sync) {
981
1009
  }
982
1010
  const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
983
1011
  const DEFAULT_DISCOVERY_MAX_REPOS = 50;
1012
+ const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
1013
+ const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
984
1014
  async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
985
1015
  if (Array.isArray(repoRoot)) {
986
1016
  const discovered = [];
@@ -1223,6 +1253,20 @@ async function runStart(command, io) {
1223
1253
  return 0;
1224
1254
  }
1225
1255
  async function runSync(command, io) {
1256
+ const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
1257
+ if (backfillLock.held) {
1258
+ if (command.json) {
1259
+ writeLine(io.stdout, JSON.stringify({
1260
+ status: "live_sync_paused_during_backfill",
1261
+ reason: "live sync paused during backfill",
1262
+ held_since: backfillLock.held_since,
1263
+ }, null, 2));
1264
+ }
1265
+ else {
1266
+ writeLine(io.stdout, "live sync paused during backfill");
1267
+ }
1268
+ return 0;
1269
+ }
1226
1270
  // Single-flight: a launchd timer and a manual sync must not interleave the
1227
1271
  // cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
1228
1272
  const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
@@ -1301,6 +1345,7 @@ async function runSyncLocked(command, io) {
1301
1345
  return 1;
1302
1346
  }
1303
1347
  async function runStatus(command, io) {
1348
+ const backfillCursor = await inspectBackfillCursor(command.homeDir);
1304
1349
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1305
1350
  if (worktrees.length > 1) {
1306
1351
  const statuses = await Promise.all(worktrees.map(async (worktree) => ({
@@ -1311,10 +1356,11 @@ async function runStatus(command, io) {
1311
1356
  head_sha: worktree.head_sha,
1312
1357
  })));
1313
1358
  if (command.json) {
1314
- writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses }, null, 2));
1359
+ writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
1315
1360
  return 0;
1316
1361
  }
1317
1362
  writeLine(io.stdout, "Cockpit parent status");
1363
+ writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
1318
1364
  for (const status of statuses) {
1319
1365
  writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
1320
1366
  }
@@ -1322,7 +1368,7 @@ async function runStatus(command, io) {
1322
1368
  }
1323
1369
  const status = await inspectLocalCollectorStatus(command);
1324
1370
  if (command.json) {
1325
- writeLine(io.stdout, JSON.stringify(status, null, 2));
1371
+ writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
1326
1372
  return 0;
1327
1373
  }
1328
1374
  writeLine(io.stdout, "Cockpit local status");
@@ -1339,6 +1385,7 @@ async function runStatus(command, io) {
1339
1385
  writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
1340
1386
  writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
1341
1387
  writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
1388
+ writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
1342
1389
  for (const detail of status.details)
1343
1390
  writeLine(io.stdout, `- ${detail}`);
1344
1391
  return 0;
@@ -1351,6 +1398,251 @@ function displayWorkLabel(status) {
1351
1398
  return `${status.work_label} (${status.work_id})`;
1352
1399
  return status.work_label ?? status.work_id ?? "no active work context";
1353
1400
  }
1401
+ async function fileExists(filePath) {
1402
+ try {
1403
+ await access(filePath);
1404
+ return true;
1405
+ }
1406
+ catch {
1407
+ return false;
1408
+ }
1409
+ }
1410
+ async function onboardBackfillHint(homeDir) {
1411
+ const paths = getCollectorRuntimePaths(homeDir);
1412
+ if (await fileExists(backfillCompletionMarkerPath(paths)))
1413
+ return null;
1414
+ const hasArchivedSessions = await hasAnyJsonlFile([
1415
+ path.join(homeDir ?? os.homedir(), ".codex", "archived_sessions"),
1416
+ ]);
1417
+ return hasArchivedSessions
1418
+ ? "Backfill: archived sessions found. Run `cockpit backfill --all` to include older local history."
1419
+ : null;
1420
+ }
1421
+ async function inspectBackfillCursor(homeDir) {
1422
+ const paths = getCollectorRuntimePaths(homeDir);
1423
+ const marker = await readBackfillCompletionMarker(paths);
1424
+ if (marker) {
1425
+ return {
1426
+ state: "done",
1427
+ remaining_count: 0,
1428
+ updated_at: marker.cursor.updated_at,
1429
+ completed_at: marker.completed_at,
1430
+ sources: summarizeBackfillCursorSources(marker.cursor),
1431
+ };
1432
+ }
1433
+ const cursor = await readBackfillCursor(paths);
1434
+ if (!cursor.updated_at) {
1435
+ return {
1436
+ state: "never_run",
1437
+ remaining_count: 0,
1438
+ updated_at: null,
1439
+ completed_at: null,
1440
+ sources: summarizeBackfillCursorSources(cursor),
1441
+ };
1442
+ }
1443
+ return {
1444
+ state: "remaining",
1445
+ remaining_count: await countRemainingBackfillSessionFiles(homeDir ?? os.homedir(), cursor),
1446
+ updated_at: cursor.updated_at,
1447
+ completed_at: null,
1448
+ sources: summarizeBackfillCursorSources(cursor),
1449
+ };
1450
+ }
1451
+ async function readBackfillCompletionMarker(paths) {
1452
+ try {
1453
+ const raw = JSON.parse(await readFile(backfillCompletionMarkerPath(paths), "utf8"));
1454
+ if (typeof raw.completed_at !== "string")
1455
+ return null;
1456
+ const cursor = raw.cursor && typeof raw.cursor === "object"
1457
+ ? normalizeBackfillCursor(raw.cursor)
1458
+ : await readBackfillCursor(paths);
1459
+ return { completed_at: raw.completed_at, cursor };
1460
+ }
1461
+ catch {
1462
+ return null;
1463
+ }
1464
+ }
1465
+ function normalizeBackfillCursor(value) {
1466
+ const record = value;
1467
+ return {
1468
+ schema_version: "cockpit-backfill-cursor.v1",
1469
+ updated_at: typeof record.updated_at === "string" && record.updated_at
1470
+ ? record.updated_at
1471
+ : null,
1472
+ sources: {
1473
+ codex: {
1474
+ oldest_mtime_ms_processed: typeof record.sources?.codex?.oldest_mtime_ms_processed === "number"
1475
+ ? record.sources.codex.oldest_mtime_ms_processed
1476
+ : null,
1477
+ oldest_mtime_processed: typeof record.sources?.codex?.oldest_mtime_processed === "string"
1478
+ ? record.sources.codex.oldest_mtime_processed
1479
+ : null,
1480
+ state_counts: numberRecord(record.sources?.codex?.state_counts),
1481
+ reason_counts: numberRecord(record.sources?.codex?.reason_counts),
1482
+ },
1483
+ claude_code: {
1484
+ oldest_mtime_ms_processed: typeof record.sources?.claude_code?.oldest_mtime_ms_processed === "number"
1485
+ ? record.sources.claude_code.oldest_mtime_ms_processed
1486
+ : null,
1487
+ oldest_mtime_processed: typeof record.sources?.claude_code?.oldest_mtime_processed === "string"
1488
+ ? record.sources.claude_code.oldest_mtime_processed
1489
+ : null,
1490
+ state_counts: numberRecord(record.sources?.claude_code?.state_counts),
1491
+ reason_counts: numberRecord(record.sources?.claude_code?.reason_counts),
1492
+ },
1493
+ },
1494
+ };
1495
+ }
1496
+ function numberRecord(value) {
1497
+ if (!value || typeof value !== "object")
1498
+ return {};
1499
+ const out = {};
1500
+ for (const [key, raw] of Object.entries(value)) {
1501
+ if (typeof raw === "number" && Number.isFinite(raw))
1502
+ out[key] = raw;
1503
+ }
1504
+ return out;
1505
+ }
1506
+ function summarizeBackfillCursorSources(cursor) {
1507
+ return {
1508
+ codex: summarizeBackfillSource(cursor.sources.codex),
1509
+ claude_code: summarizeBackfillSource(cursor.sources.claude_code),
1510
+ };
1511
+ }
1512
+ function summarizeBackfillSource(source) {
1513
+ return {
1514
+ oldest_mtime_processed: source.oldest_mtime_processed,
1515
+ observed_count: Object.values(source.state_counts).reduce((total, count) => total + count, 0),
1516
+ state_counts: source.state_counts,
1517
+ reason_counts: source.reason_counts,
1518
+ };
1519
+ }
1520
+ function backfillCursorLine(status) {
1521
+ switch (status.state) {
1522
+ case "done":
1523
+ return `done (${status.completed_at ?? "completion marker present"})`;
1524
+ case "never_run":
1525
+ return "never run";
1526
+ case "remaining":
1527
+ return `${status.remaining_count} remaining`;
1528
+ }
1529
+ }
1530
+ async function countRemainingBackfillSessionFiles(homeDir, cursor) {
1531
+ const codex = await countJsonlFilesBeforeCursor(defaultCodexSessionDirs(homeDir), cursor.sources.codex.oldest_mtime_ms_processed);
1532
+ const claude = await countClaudeMainFilesBeforeCursor(path.join(homeDir, ".claude", "projects"), cursor.sources.claude_code.oldest_mtime_ms_processed);
1533
+ return codex + claude;
1534
+ }
1535
+ async function countJsonlFilesBeforeCursor(roots, oldestProcessedMs) {
1536
+ let count = 0;
1537
+ await walkFiles(roots, async (filePath, entryName) => {
1538
+ if (!entryName.endsWith(".jsonl"))
1539
+ return;
1540
+ const info = await stat(filePath).catch(() => null);
1541
+ if (!info?.isFile())
1542
+ return;
1543
+ if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
1544
+ count += 1;
1545
+ });
1546
+ return count;
1547
+ }
1548
+ async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs) {
1549
+ let count = 0;
1550
+ await walkFiles([projectsDir], async (filePath, entryName) => {
1551
+ if (!entryName.endsWith(".jsonl"))
1552
+ return;
1553
+ if (filePath.includes(`${path.sep}subagents${path.sep}`))
1554
+ return;
1555
+ const info = await stat(filePath).catch(() => null);
1556
+ if (!info?.isFile())
1557
+ return;
1558
+ if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
1559
+ count += 1;
1560
+ });
1561
+ return count;
1562
+ }
1563
+ async function hasAnyJsonlFile(roots) {
1564
+ let found = false;
1565
+ await walkFiles(roots, async (_filePath, entryName) => {
1566
+ if (entryName.endsWith(".jsonl"))
1567
+ found = true;
1568
+ }, () => found);
1569
+ return found;
1570
+ }
1571
+ async function walkFiles(roots, onFile, shouldStop = () => false) {
1572
+ const stack = [...roots];
1573
+ while (stack.length > 0 && !shouldStop()) {
1574
+ const current = stack.pop();
1575
+ if (!current)
1576
+ continue;
1577
+ let entries;
1578
+ try {
1579
+ entries = await readdir(current, { withFileTypes: true });
1580
+ }
1581
+ catch {
1582
+ continue;
1583
+ }
1584
+ for (const entry of entries) {
1585
+ if (shouldStop())
1586
+ return;
1587
+ const full = path.join(current, entry.name);
1588
+ if (entry.isDirectory()) {
1589
+ stack.push(full);
1590
+ }
1591
+ else if (entry.isFile()) {
1592
+ await onFile(full, entry.name);
1593
+ }
1594
+ }
1595
+ }
1596
+ }
1597
+ async function sessionsScanWindow(command, now) {
1598
+ if (command.all) {
1599
+ return {
1600
+ mode: "all",
1601
+ since_days: null,
1602
+ started_at: new Date(now.getTime() - ALL_SESSION_SCAN_WINDOW_MINUTES * 60_000)
1603
+ .toISOString(),
1604
+ paired_at: await readPairedAt(command.homeDir),
1605
+ since_minutes: ALL_SESSION_SCAN_WINDOW_MINUTES,
1606
+ limit: SESSION_SCAN_OVERRIDE_LIMIT,
1607
+ };
1608
+ }
1609
+ if (command.sinceDays !== undefined) {
1610
+ const requestedMs = now.getTime() - command.sinceDays * 24 * 60 * 60_000;
1611
+ const pairedAt = await readPairedAt(command.homeDir);
1612
+ const pairedMs = pairedAt ? Date.parse(pairedAt) : Number.NaN;
1613
+ const startedAtMs = Number.isFinite(pairedMs)
1614
+ ? Math.max(requestedMs, pairedMs)
1615
+ : requestedMs;
1616
+ return {
1617
+ mode: "since_days",
1618
+ since_days: command.sinceDays,
1619
+ started_at: new Date(startedAtMs).toISOString(),
1620
+ paired_at: pairedAt,
1621
+ since_minutes: Math.max(1, Math.ceil((now.getTime() - startedAtMs) / 60_000)),
1622
+ limit: SESSION_SCAN_OVERRIDE_LIMIT,
1623
+ };
1624
+ }
1625
+ return {
1626
+ mode: "default",
1627
+ since_days: null,
1628
+ started_at: null,
1629
+ paired_at: await readPairedAt(command.homeDir),
1630
+ since_minutes: null,
1631
+ limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
1632
+ };
1633
+ }
1634
+ async function readPairedAt(homeDir) {
1635
+ const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(homeDir)).catch(() => null);
1636
+ return typeof session?.paired_at === "string" ? session.paired_at : null;
1637
+ }
1638
+ function sessionsWindowLine(window) {
1639
+ if (window.mode === "all")
1640
+ return "all local history";
1641
+ if (window.mode === "since_days") {
1642
+ return `since ${window.started_at} (${window.since_days} day request, paired_at cap ${window.paired_at ?? "unavailable"})`;
1643
+ }
1644
+ return "default scan window";
1645
+ }
1354
1646
  /**
1355
1647
  * Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
1356
1648
  * prints why each session is or is not collected. Per-session reasons otherwise
@@ -1362,6 +1654,7 @@ async function runSessions(command, io) {
1362
1654
  const now = new Date();
1363
1655
  const homeDir = command.homeDir ?? os.homedir();
1364
1656
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1657
+ const window = await sessionsScanWindow(command, now);
1365
1658
  const wantCodex = command.source !== "claude";
1366
1659
  const wantClaude = command.source !== "codex";
1367
1660
  const codex = wantCodex
@@ -1369,8 +1662,8 @@ async function runSessions(command, io) {
1369
1662
  sessionsDirs: defaultCodexSessionDirs(homeDir),
1370
1663
  worktrees,
1371
1664
  now,
1372
- sinceMinutes: CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES,
1373
- limit: CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT,
1665
+ sinceMinutes: window.since_minutes ?? CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
1666
+ limit: window.limit,
1374
1667
  })
1375
1668
  : null;
1376
1669
  const claude = wantClaude
@@ -1378,6 +1671,8 @@ async function runSessions(command, io) {
1378
1671
  projectsDir: path.join(homeDir, ".claude", "projects"),
1379
1672
  worktrees,
1380
1673
  now,
1674
+ sinceMinutes: window.since_minutes ?? undefined,
1675
+ limit: window.mode === "default" ? undefined : window.limit,
1381
1676
  })
1382
1677
  : null;
1383
1678
  // Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
@@ -1412,6 +1707,7 @@ async function runSessions(command, io) {
1412
1707
  }));
1413
1708
  if (command.json) {
1414
1709
  writeLine(io.stdout, JSON.stringify({
1710
+ window,
1415
1711
  ...(codex
1416
1712
  ? { codex: { counts: codex.counts, sessions: codexRows } }
1417
1713
  : {}),
@@ -1428,6 +1724,7 @@ async function runSessions(command, io) {
1428
1724
  return 0;
1429
1725
  }
1430
1726
  writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
1727
+ writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
1431
1728
  for (const row of codexRows) {
1432
1729
  writeLine(io.stdout, sessionRowLine(row));
1433
1730
  }