@bli-cockpit/cli 0.1.28 → 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":
@@ -206,6 +208,7 @@ function parseStartArgs(args) {
206
208
  "--workspace",
207
209
  "--branch",
208
210
  "--ticket",
211
+ "--clear-ticket",
209
212
  "--topic",
210
213
  "--topic-summary",
211
214
  "--intent",
@@ -237,6 +240,11 @@ function parseStartArgs(args) {
237
240
  ],
238
241
  });
239
242
  assertNoPositionals(values.positionals, "start");
243
+ const activeTicketId = optionalNonEmpty(values.flags.get("--ticket"));
244
+ const clearTicket = values.booleans.has("--clear-ticket");
245
+ if (activeTicketId && clearTicket) {
246
+ throw new Error("--ticket and --clear-ticket cannot be combined.");
247
+ }
240
248
  const topicLabel = optionalNonEmpty(values.flags.get("--topic"));
241
249
  const topicSummaryRedacted = optionalNonEmpty(values.flags.get("--topic-summary"));
242
250
  const workIntent = optionalSchemaValue(WorkIntentSchema, values.flags.get("--intent"), "--intent");
@@ -252,7 +260,8 @@ function parseStartArgs(args) {
252
260
  homeDir: optionalNonEmpty(values.flags.get("--home")),
253
261
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
254
262
  branch: optionalNonEmpty(values.flags.get("--branch")),
255
- activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
263
+ activeTicketId,
264
+ clearTicket,
256
265
  topicLabel,
257
266
  topicSummaryRedacted,
258
267
  workIntent,
@@ -297,6 +306,52 @@ function parseSyncArgs(args) {
297
306
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
298
307
  };
299
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
+ }
300
355
  function parseStatusArgs(args) {
301
356
  const values = parseNamedArgs(args, {
302
357
  allowedFlags: [
@@ -332,6 +387,8 @@ function parseSessionsArgs(args) {
332
387
  "--repo",
333
388
  "--workspace",
334
389
  "--source",
390
+ "--since-days",
391
+ "--all",
335
392
  "--json",
336
393
  "--max-depth",
337
394
  "--max-repos",
@@ -341,6 +398,7 @@ function parseSessionsArgs(args) {
341
398
  "--repo",
342
399
  "--workspace",
343
400
  "--source",
401
+ "--since-days",
344
402
  "--max-depth",
345
403
  "--max-repos",
346
404
  ],
@@ -348,13 +406,20 @@ function parseSessionsArgs(args) {
348
406
  assertNoPositionals(values.positionals, "sessions");
349
407
  const source = values.flags.get("--source");
350
408
  if (source !== undefined && source !== "codex" && source !== "claude") {
351
- 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.");
352
415
  }
353
416
  return {
354
417
  kind: "sessions",
355
418
  homeDir: optionalNonEmpty(values.flags.get("--home")),
356
419
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
357
420
  source,
421
+ sinceDays,
422
+ all,
358
423
  json: values.booleans.has("--json"),
359
424
  maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
360
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":
@@ -91,10 +97,11 @@ export function localCommandHelp(command) {
91
97
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
92
98
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
93
99
  " cockpit logout",
94
- " cockpit start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
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]",
@@ -169,10 +176,11 @@ function localSubcommandHelp(command) {
169
176
  [
170
177
  "start",
171
178
  [
172
- "Usage: cockpit start [--ticket <id>] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
179
+ "Usage: cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--topic-summary <summary>] [--intent <intent>] [--phase <phase>] [--intent-confidence <0..1>] [--workspace <path>] [--branch <name>] [--json]",
173
180
  "",
174
181
  "Starts local ambient capture. Parent folders start each child git worktree.",
175
- "Add --ticket only when the work already has a visible ticket.",
182
+ "Add --ticket only when the work already has a visible ticket; omit it to preserve an existing binding.",
183
+ "Use --clear-ticket to intentionally return the context to general ambient capture.",
176
184
  "Use --topic/--intent/--phase for planning, discovery, and learning work that has no ticket yet.",
177
185
  "Supported intents: implementation, bug_fix, root_cause_analysis, planning, discovery, review, testing, documentation, release, learning, coordination, maintenance, analysis, unknown, other.",
178
186
  "Supported phases: planning, discovery, implementation, debugging, review, testing, documentation, release, handoff, analysis, unknown, other.",
@@ -197,6 +205,18 @@ function localSubcommandHelp(command) {
197
205
  "--max-depth and --max-repos.",
198
206
  ],
199
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
+ ],
200
220
  [
201
221
  "status",
202
222
  [
@@ -209,12 +229,13 @@ function localSubcommandHelp(command) {
209
229
  [
210
230
  "sessions",
211
231
  [
212
- "Usage: cockpit sessions [--source codex|claude] [--workspace <path>] [--json]",
232
+ "Usage: cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--json]",
213
233
  "",
214
234
  "Read-only: re-runs Codex + Claude session attribution and prints each",
215
235
  "session's id, source, state, reason, scores, signals, and per-sidecar",
216
236
  "skip reasons. No upload, no cursor writes. Answers \"why is session X",
217
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.",
218
239
  "`--repo <path>` remains supported as a backward-compatible alias.",
219
240
  ],
220
241
  ],
@@ -637,6 +658,8 @@ function writeOnboardLiveStatus(io, command, roots, options) {
637
658
  writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
638
659
  writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
639
660
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
661
+ if (options.backfillHint)
662
+ writeLine(io.stdout, options.backfillHint);
640
663
  writeLine(io.stdout, "Next: cockpit status");
641
664
  }
642
665
  function backgroundSyncLine(result) {
@@ -659,6 +682,7 @@ async function runOnboard(command, io) {
659
682
  let rootsResult = null;
660
683
  let agentRules = null;
661
684
  let autostart = null;
685
+ let backfillHint = null;
662
686
  try {
663
687
  if (!command.json) {
664
688
  writeLine(io.stdout, "Cockpit harvest onboarding");
@@ -666,6 +690,7 @@ async function runOnboard(command, io) {
666
690
  writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
667
691
  }
668
692
  const paths = getCollectorRuntimePaths(command.homeDir);
693
+ backfillHint = await onboardBackfillHint(command.homeDir);
669
694
  const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
670
695
  const interactive = !command.json && isInteractiveStdin(io);
671
696
  rootsResult = await resolveOnboardingRoots({
@@ -755,6 +780,7 @@ async function runOnboard(command, io) {
755
780
  root_resolution: rootsResult,
756
781
  agent_rules: agentRules,
757
782
  autostart,
783
+ backfill_hint: backfillHint,
758
784
  mode: "multi_repo",
759
785
  repos: multi.results,
760
786
  codex_sessions: multi.codex_sessions,
@@ -768,6 +794,7 @@ async function runOnboard(command, io) {
768
794
  agentRules,
769
795
  autostart,
770
796
  initialSyncOk: true,
797
+ backfillHint,
771
798
  });
772
799
  }
773
800
  return multi.ok ? 0 : 1;
@@ -827,6 +854,7 @@ async function runOnboard(command, io) {
827
854
  root_resolution: rootsResult,
828
855
  agent_rules: agentRules,
829
856
  autostart,
857
+ backfill_hint: backfillHint,
830
858
  codex_sessions: run.summary,
831
859
  }, null, 2));
832
860
  return 0;
@@ -852,6 +880,7 @@ async function runOnboard(command, io) {
852
880
  agentRules,
853
881
  autostart,
854
882
  initialSyncOk: true,
883
+ backfillHint,
855
884
  });
856
885
  return 0;
857
886
  }
@@ -980,6 +1009,8 @@ function cursorStatusLine(sync) {
980
1009
  }
981
1010
  const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
982
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;
983
1014
  async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
984
1015
  if (Array.isArray(repoRoot)) {
985
1016
  const discovered = [];
@@ -1186,6 +1217,7 @@ async function runStart(command, io) {
1186
1217
  repoRoot: worktree.repo_root,
1187
1218
  branch: command.branch,
1188
1219
  activeTicketId: command.activeTicketId,
1220
+ clearTicket: command.clearTicket,
1189
1221
  topicLabel: command.topicLabel,
1190
1222
  topicSummaryRedacted: command.topicSummaryRedacted,
1191
1223
  workIntent: command.workIntent,
@@ -1221,6 +1253,20 @@ async function runStart(command, io) {
1221
1253
  return 0;
1222
1254
  }
1223
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
+ }
1224
1270
  // Single-flight: a launchd timer and a manual sync must not interleave the
1225
1271
  // cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
1226
1272
  const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
@@ -1299,6 +1345,7 @@ async function runSyncLocked(command, io) {
1299
1345
  return 1;
1300
1346
  }
1301
1347
  async function runStatus(command, io) {
1348
+ const backfillCursor = await inspectBackfillCursor(command.homeDir);
1302
1349
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1303
1350
  if (worktrees.length > 1) {
1304
1351
  const statuses = await Promise.all(worktrees.map(async (worktree) => ({
@@ -1309,10 +1356,11 @@ async function runStatus(command, io) {
1309
1356
  head_sha: worktree.head_sha,
1310
1357
  })));
1311
1358
  if (command.json) {
1312
- 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));
1313
1360
  return 0;
1314
1361
  }
1315
1362
  writeLine(io.stdout, "Cockpit parent status");
1363
+ writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
1316
1364
  for (const status of statuses) {
1317
1365
  writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
1318
1366
  }
@@ -1320,7 +1368,7 @@ async function runStatus(command, io) {
1320
1368
  }
1321
1369
  const status = await inspectLocalCollectorStatus(command);
1322
1370
  if (command.json) {
1323
- writeLine(io.stdout, JSON.stringify(status, null, 2));
1371
+ writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
1324
1372
  return 0;
1325
1373
  }
1326
1374
  writeLine(io.stdout, "Cockpit local status");
@@ -1337,6 +1385,7 @@ async function runStatus(command, io) {
1337
1385
  writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
1338
1386
  writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
1339
1387
  writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
1388
+ writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
1340
1389
  for (const detail of status.details)
1341
1390
  writeLine(io.stdout, `- ${detail}`);
1342
1391
  return 0;
@@ -1349,6 +1398,251 @@ function displayWorkLabel(status) {
1349
1398
  return `${status.work_label} (${status.work_id})`;
1350
1399
  return status.work_label ?? status.work_id ?? "no active work context";
1351
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
+ }
1352
1646
  /**
1353
1647
  * Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
1354
1648
  * prints why each session is or is not collected. Per-session reasons otherwise
@@ -1360,6 +1654,7 @@ async function runSessions(command, io) {
1360
1654
  const now = new Date();
1361
1655
  const homeDir = command.homeDir ?? os.homedir();
1362
1656
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1657
+ const window = await sessionsScanWindow(command, now);
1363
1658
  const wantCodex = command.source !== "claude";
1364
1659
  const wantClaude = command.source !== "codex";
1365
1660
  const codex = wantCodex
@@ -1367,8 +1662,8 @@ async function runSessions(command, io) {
1367
1662
  sessionsDirs: defaultCodexSessionDirs(homeDir),
1368
1663
  worktrees,
1369
1664
  now,
1370
- sinceMinutes: CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES,
1371
- limit: CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT,
1665
+ sinceMinutes: window.since_minutes ?? CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
1666
+ limit: window.limit,
1372
1667
  })
1373
1668
  : null;
1374
1669
  const claude = wantClaude
@@ -1376,6 +1671,8 @@ async function runSessions(command, io) {
1376
1671
  projectsDir: path.join(homeDir, ".claude", "projects"),
1377
1672
  worktrees,
1378
1673
  now,
1674
+ sinceMinutes: window.since_minutes ?? undefined,
1675
+ limit: window.mode === "default" ? undefined : window.limit,
1379
1676
  })
1380
1677
  : null;
1381
1678
  // Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
@@ -1410,6 +1707,7 @@ async function runSessions(command, io) {
1410
1707
  }));
1411
1708
  if (command.json) {
1412
1709
  writeLine(io.stdout, JSON.stringify({
1710
+ window,
1413
1711
  ...(codex
1414
1712
  ? { codex: { counts: codex.counts, sessions: codexRows } }
1415
1713
  : {}),
@@ -1426,6 +1724,7 @@ async function runSessions(command, io) {
1426
1724
  return 0;
1427
1725
  }
1428
1726
  writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
1727
+ writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
1429
1728
  for (const row of codexRows) {
1430
1729
  writeLine(io.stdout, sessionRowLine(row));
1431
1730
  }