@bli-cockpit/cli 0.2.291 → 0.2.293

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.
Files changed (61) hide show
  1. package/dist/adapters/claude-attribution-discovery.js +22 -0
  2. package/dist/adapters/claude-attribution-memoized-reads.js +3 -3
  3. package/dist/adapters/claude-attribution.js +5 -2
  4. package/dist/adapters/codex-attribution-memoized-reads.js +3 -3
  5. package/dist/adapters/codex-attribution.js +30 -12
  6. package/dist/adapters/codex-segment-discovery.js +66 -0
  7. package/dist/adapters/codex-session-revision.js +139 -0
  8. package/dist/adapters/codex-session-signals.js +2 -2
  9. package/dist/adapters/pi-session-facts.js +28 -0
  10. package/dist/adapters/pi-session-reader.js +132 -0
  11. package/dist/adapters/raw-evidence-codex-reader.js +17 -7
  12. package/dist/adapters/raw-evidence-transcript-body.js +3 -0
  13. package/dist/adapters/raw-evidence-transcript-file.js +27 -4
  14. package/dist/adapters/raw-evidence-transcript-stream.js +33 -1
  15. package/dist/agent-plugins-converge.js +24 -5
  16. package/dist/collection-scope.js +2 -0
  17. package/dist/commands/backfill-checkpoint.js +3 -1
  18. package/dist/commands/backfill-history-scan.js +38 -0
  19. package/dist/commands/backfill-issues.js +8 -20
  20. package/dist/commands/backfill-scan.js +18 -40
  21. package/dist/commands/backfill-upload.js +1 -0
  22. package/dist/commands/books-usage.js +2 -1
  23. package/dist/commands/books.js +12 -4
  24. package/dist/commands/codex-hooks-trust-choice.js +51 -0
  25. package/dist/commands/codex-hooks-trust-prompt.js +172 -0
  26. package/dist/commands/codex-managed-hooks.js +226 -0
  27. package/dist/commands/codex-revision-facts.js +64 -0
  28. package/dist/commands/doctor-report.js +2 -2
  29. package/dist/commands/doctor-update.js +2 -0
  30. package/dist/commands/doctor.js +2 -0
  31. package/dist/commands/local-args-collector-setup.js +2 -0
  32. package/dist/commands/local-args-collector-status.js +5 -3
  33. package/dist/commands/local-args-notes-targets.js +35 -0
  34. package/dist/commands/local-args-tower-notes.js +7 -18
  35. package/dist/commands/local-help-commands-tower.js +3 -2
  36. package/dist/commands/local-help-commands.js +3 -2
  37. package/dist/commands/local-help-notes.js +2 -1
  38. package/dist/commands/local-help.js +1 -1
  39. package/dist/commands/memory-install-codex-hooks.js +95 -2
  40. package/dist/commands/memory-install-receipt.js +17 -1
  41. package/dist/commands/memory-install-report.js +5 -4
  42. package/dist/commands/memory-install.js +15 -6
  43. package/dist/commands/memory-log.js +13 -2
  44. package/dist/commands/memory-map.js +6 -2
  45. package/dist/commands/memory-verb-list.js +1 -1
  46. package/dist/commands/notes-writes.js +24 -0
  47. package/dist/commands/notes.js +3 -2
  48. package/dist/commands/public-root.js +1 -1
  49. package/dist/commands/session-sync-record.js +3 -1
  50. package/dist/commands/session-sync-scan.js +14 -6
  51. package/dist/commands/session-sync-shelf.js +2 -0
  52. package/dist/commands/session-sync-upload.js +1 -0
  53. package/dist/commands/setup-receipt-lines.js +2 -8
  54. package/dist/commands/sources-discovery.js +63 -14
  55. package/dist/commands/sources-reconciliation.js +7 -5
  56. package/dist/commands/sources-verdict.js +3 -0
  57. package/dist/cursors/raw-evidence-cursor.js +1 -0
  58. package/dist/image-description-session.js +4 -2
  59. package/dist/native-session-roots.js +31 -0
  60. package/dist/t3-native-homes.js +64 -0
  61. package/package.json +4 -4
@@ -56,11 +56,27 @@ export async function collectOneEvidenceFile(collection, options) {
56
56
  }
57
57
  // BLI-3936: stat BEFORE the read. The same stamp, taken again after the work, proves the file
58
58
  // did not move underneath it; only then is the result remembered.
59
- const stamp = await statSourceForMemo(options.filePath);
59
+ const stamp = options.sourceProvider ? null : await statSourceForMemo(options.filePath);
60
60
  const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
61
61
  if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
62
62
  return true;
63
- const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
63
+ let read;
64
+ try {
65
+ read = options.sourceProvider
66
+ ? { status: "ok", ...await options.sourceProvider() }
67
+ : await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
68
+ }
69
+ catch (error) {
70
+ if (error instanceof TranscriptTooLargeError) {
71
+ markCapApplied(collection, options.kind, "max_file_bytes");
72
+ return skip("file_too_large");
73
+ }
74
+ const reason = error instanceof TranscriptReadError ? `file_read_failed:${error.stage}` : "file_read_failed";
75
+ console.error("[raw-evidence] revision source unavailable", JSON.stringify({ reason, ...describeError(error) }));
76
+ return skip(reason);
77
+ }
78
+ if (read.status === "ok" && options.maxFileBytes && read.byteLength > options.maxFileBytes)
79
+ read = { status: "too_large" };
64
80
  if (read.status === "read_failed")
65
81
  return skip("file_read_failed");
66
82
  if (read.status === "too_large") {
@@ -80,8 +96,15 @@ export async function collectOneEvidenceFile(collection, options) {
80
96
  }
81
97
  catch (error) {
82
98
  // BLI-23317: a streamed or fallback read that fails ends exactly as a whole-file read that failed.
83
- if (error instanceof TranscriptReadError)
84
- return skip("file_read_failed");
99
+ if (error instanceof TranscriptReadError) {
100
+ const deferred = error.stage === "composite_staging_declined" && error.underlying instanceof Error ? error.underlying.message : null;
101
+ if (deferred && /^(deferred_(byte|object)_budget|staging_deferred:[a-z_]+)$/.test(deferred)) {
102
+ markBudgetCapApplied(collection, deferred);
103
+ return skip(deferred);
104
+ }
105
+ const detail = options.sourceProvider ? `:${error.stage}` : "";
106
+ return skip(`file_read_failed${detail}`);
107
+ }
85
108
  if (error instanceof TranscriptTooLargeError) {
86
109
  markCapApplied(collection, options.kind, "max_file_bytes");
87
110
  return skip("file_too_large");
@@ -12,6 +12,8 @@
12
12
  * read gives (`file_read_failed`, `file_too_large`), never a shorter file.
13
13
  */
14
14
  import fs from "node:fs/promises";
15
+ import { copyLinesStream } from "../slim/keep-line-copy.js";
16
+ import { openStagedOutput } from "./staged-output.js";
15
17
  import { modeDropsImages } from "@bli-cockpit/telemetry-core";
16
18
  import { describeError } from "../health-detail.js";
17
19
  import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError, } from "./transcript-text-stream.js";
@@ -32,11 +34,13 @@ export function volatilityOf(mode, prepared) {
32
34
  }
33
35
  export async function streamTranscriptBody(session, agent, file) {
34
36
  if (!session?.prepareTranscriptFile || !agent) {
37
+ if (file.openSource)
38
+ return streamCompositeWithoutImages(file);
35
39
  console.error(TAG + " streamed read not available, reading the transcript whole", JSON.stringify({ reason: "stream_unavailable", kind: file.kind }));
36
40
  return null;
37
41
  }
38
42
  try {
39
- const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
43
+ const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, ...(file.openSource ? { openSource: file.openSource } : {}), source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
40
44
  const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
41
45
  console.error(TAG + " transcript streamed through image swap and slim", JSON.stringify({ kind: file.kind, mode: session.mode, byte_size: prepared.readBytes, changed: prepared.changed, images: prepared.imageCount, volatile, staged_bytes: prepared.staged?.byteLength ?? null }));
42
46
  // BLI-23344: with a spool the text is not read back; the sanitizer reads the spool line by line.
@@ -45,12 +49,18 @@ export async function streamTranscriptBody(session, agent, file) {
45
49
  catch (error) {
46
50
  if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
47
51
  throw error;
52
+ if (file.openSource) {
53
+ console.error(TAG + " composite revision remains pending", JSON.stringify({ reason: "composite_prepare_failed", kind: file.kind, ...describeError(error) }));
54
+ throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
55
+ }
48
56
  console.error(TAG + " streamed read set aside, reading the transcript whole", JSON.stringify({ reason: error instanceof TranscriptNotStreamable ? error.reason : "stream_step_failed", kind: file.kind, ...(error instanceof TranscriptNotStreamable ? {} : describeError(error)) }));
49
57
  return null;
50
58
  }
51
59
  }
52
60
  /** The whole-file read the streamed path falls back to: same failures, same labels, as the reader that did not stream. */
53
61
  export async function readTranscriptWhole(file) {
62
+ if (file.openSource)
63
+ throw new TranscriptReadError("composite_buffering_refused", new Error("composite_buffering_refused"));
54
64
  let bytes;
55
65
  try {
56
66
  bytes = await fs.readFile(file.filePath);
@@ -62,4 +72,26 @@ export async function readTranscriptWhole(file) {
62
72
  if (file.maxFileBytes && bytes.byteLength > file.maxFileBytes)
63
73
  throw new TranscriptTooLargeError(file.maxFileBytes);
64
74
  return bytes;
75
+ }
76
+ /** Keep mode when image preparation is unavailable: same spool/mask/ack, no representative read or whole buffer. */
77
+ async function streamCompositeWithoutImages(file) {
78
+ if (!file.stageDir)
79
+ throw new TranscriptReadError("composite_staging_unavailable", new Error("composite_staging_unavailable"));
80
+ const source = await file.openSource();
81
+ let writer;
82
+ try {
83
+ writer = await openStagedOutput(file.stageDir);
84
+ await copyLinesStream(source.lines(), writer);
85
+ await source.verifyUnchanged();
86
+ const staged = await writer.finish();
87
+ console.error(TAG + " composite revision kept without image preparation", JSON.stringify({ reason: "composite_images_unavailable_kept", kind: file.kind, byte_size: source.length }));
88
+ return { held: { text: null, exactBytes: null, staged }, volatile: true, pendingImages: null, readBytes: source.length };
89
+ }
90
+ catch (error) {
91
+ await writer?.abandon();
92
+ throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
93
+ }
94
+ finally {
95
+ await source.close();
96
+ }
65
97
  }
@@ -113,7 +113,12 @@ async function convergeHost(host, reading, entry, options, now) {
113
113
  if (reading.installed) {
114
114
  // Installed by us or by hand: a failure remembered from before is spent.
115
115
  delete entry.last_reason;
116
- const due = options.refresh && msSince(entry.last_refresh_at, now) >= PLUGIN_REFRESH_EVERY_MS;
116
+ // A failed refresh waits out the six-hour retry window, not a whole day: it
117
+ // never counts as the day's refresh, so a machine (or a Boat template
118
+ // snapshot) that failed once is not left on an old version (BLI-24352).
119
+ const due = options.refresh
120
+ && msSince(entry.last_refresh_at, now) >= PLUGIN_REFRESH_EVERY_MS
121
+ && msSince(entry.last_refresh_failed_at, now) >= PLUGIN_RETRY_AFTER_FAILURE_MS;
117
122
  if (!due)
118
123
  return { host, status: "already_installed", reason: "installed", quiet: true };
119
124
  return refreshHost(host, reading, entry, options, now);
@@ -166,17 +171,31 @@ async function installHost(host, reading, entry, options, now) {
166
171
  console.error("[agent-plugins] plugin installed", JSON.stringify({ reason: "installed", host }));
167
172
  return { host, status: "installed", reason: "installed", quiet: false };
168
173
  }
169
- /** The daily refresh. A failure here never un-installs anything; the next try is tomorrow's. */
174
+ /**
175
+ * The daily refresh. A failure here never un-installs anything and never counts
176
+ * as the day's refresh: `last_refresh_at` stays on the last clean one and the
177
+ * next try is after the six-hour retry window (BLI-24352).
178
+ */
170
179
  async function refreshHost(host, reading, entry, options, now) {
171
- entry.last_refresh_at = now.toISOString();
180
+ const failed = (reason) => {
181
+ entry.last_refresh_failed_at = now.toISOString();
182
+ entry.last_refresh_reason = reason;
183
+ };
172
184
  const binary = await locateHostBinary(host, options.homeDir, options);
173
185
  if (!binary) {
174
- console.error("[agent-plugins] host program not found for the daily refresh", JSON.stringify({ reason: "binary_missing", host }));
186
+ failed("binary_missing");
187
+ console.error("[agent-plugins] host program not found for the daily refresh", JSON.stringify({ reason: "binary_missing", host, retry_after_hours: PLUGIN_RETRY_AFTER_FAILURE_MS / 3_600_000 }));
175
188
  return { host, status: "already_installed", reason: "refresh_skipped_binary_missing", quiet: true };
176
189
  }
177
190
  const failure = await runSteps(host, binary, stepsFor(host, reading, true), options);
178
- if (failure)
191
+ if (failure) {
192
+ failed(failure);
193
+ console.error("[agent-plugins] daily refresh failed; the installed plugin stays and the refresh is retried after the failure window", JSON.stringify({ reason: `refresh_failed_${failure}`, host, retry_after_hours: PLUGIN_RETRY_AFTER_FAILURE_MS / 3_600_000 }));
179
194
  return { host, status: "already_installed", reason: `refresh_failed_${failure}`, quiet: false };
195
+ }
196
+ entry.last_refresh_at = now.toISOString();
197
+ delete entry.last_refresh_failed_at;
198
+ delete entry.last_refresh_reason;
180
199
  console.error("[agent-plugins] plugin refreshed", JSON.stringify({ reason: "refreshed", host }));
181
200
  return { host, status: "refreshed", reason: "refreshed", quiet: false };
182
201
  }
@@ -146,6 +146,8 @@ export function cwdInsideRoots(cwd, roots) {
146
146
  * it cannot be proven inside, so it is withheld.
147
147
  */
148
148
  export function sessionInsideRoots(result, roots) {
149
+ if (result.local_cwds)
150
+ return result.local_cwds.length > 0 && result.local_cwds.every(cwd => cwdInsideRoots(cwd, roots) || Boolean(result.worktree && cwdInsideRoots(cwd, [result.worktree.repo_root])));
149
151
  if (cwdInsideRoots(result.local_cwd, roots))
150
152
  return true;
151
153
  return Boolean(result.worktree && cwdInsideRoots(result.local_cwd, [result.worktree.repo_root]));
@@ -37,7 +37,8 @@ export async function recordBackfillDurableSessionPointers(options) {
37
37
  // delivery, so a real pointer replaces it and the mark clears.
38
38
  // BLI-11119: an entry the cursor itself knows is `stubbed` counts too.
39
39
  const replacesStub = Boolean(prior?.stub_reoffer || prior?.stubbed) && prior?.uploaded_object_key !== objectKey;
40
- if (!objectKey || !prior || (prior.uploaded_object_key && !replacesStub))
40
+ const replacesRevision = candidate.source === "codex" && prior?.uploaded_source_revision !== candidate.content_hash_sha256;
41
+ if (!objectKey || (prior?.uploaded_object_key && !replacesStub && !replacesRevision))
41
42
  continue;
42
43
  if (replacesStub && !isRedactionStubObjectKey(objectKey))
43
44
  stubsReplaced += 1;
@@ -61,6 +62,7 @@ function deliveredCursorEntry(prior, candidate, objectKey, now) {
61
62
  const entry = {
62
63
  ...prior,
63
64
  file_hash_sha256: candidate.content_hash_sha256,
65
+ uploaded_source_revision: isRedactionStubObjectKey(objectKey) ? prior?.uploaded_source_revision ?? null : candidate.content_hash_sha256,
64
66
  file_mtime_ms: candidate.session_file_mtime_ms,
65
67
  byte_size: candidate.byte_size,
66
68
  // The whole file was delivered, so the incremental reader starts at its end.
@@ -0,0 +1,38 @@
1
+ /** Keep complete-history rescans separate from candidate selection and cursor policy. */
2
+ import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
3
+ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
4
+ /**
5
+ * The adapter's own limit is a memory guard, not permission to silently leave
6
+ * history undiscovered. When it fires, re-scan the exact discovered population
7
+ * so a user-facing `--max-files` cap can resume from a truthful ordering.
8
+ */
9
+ export async function scanCodexHistory(request, sessionsDirs, scanLimit) {
10
+ const scan = (limit) => scanAndAttributeCodexSessions({
11
+ sessionsDirs,
12
+ worktrees: request.worktrees,
13
+ now: request.now,
14
+ sinceMinutes: request.window.since_minutes,
15
+ limit,
16
+ collectionRoots: request.collectionRoots,
17
+ });
18
+ const firstPass = await scan(scanLimit);
19
+ if (!firstPass.session_limit_applied)
20
+ return firstPass;
21
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
22
+ }
23
+ /** Same rule for the Claude store: a capped first pass is rescanned in full. */
24
+ export async function scanClaudeHistory(request, projectsDirs, scanLimit) {
25
+ const scan = (limit) => scanAndAttributeClaudeSessions({
26
+ projectsDir: projectsDirs[0],
27
+ projectsDirs,
28
+ worktrees: request.worktrees,
29
+ now: request.now,
30
+ sinceMinutes: request.window.since_minutes,
31
+ limit,
32
+ collectionRoots: request.collectionRoots,
33
+ });
34
+ const firstPass = await scan(scanLimit);
35
+ if (!firstPass.session_limit_applied)
36
+ return firstPass;
37
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
38
+ }
@@ -29,36 +29,33 @@ import { candidateCursorKey } from "./backfill-candidates.js";
29
29
  export async function backfillScanIssues(options) {
30
30
  const issues = [];
31
31
  if (options.codexAttribution) {
32
- await addCodexStoreScanIssues(issues, options.codexAttribution, options.codexSessionDirs);
32
+ await addCodexStoreScanIssues(issues, options.codexAttribution);
33
33
  }
34
34
  if (options.claudeAttribution) {
35
- await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.claudeProjectsDir, options.candidates);
35
+ await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.candidates);
36
36
  }
37
37
  addCandidateScanIssues(issues, options.candidates, options.omittedCandidateCount);
38
38
  sortScanIssuesByPriority(issues);
39
39
  return issues;
40
40
  }
41
41
  /** What the archived Codex store could not tell us — every one of these hides history. */
42
- async function addCodexStoreScanIssues(issues, codexAttribution, codexSessionDirs) {
42
+ async function addCodexStoreScanIssues(issues, codexAttribution) {
43
43
  countScanIssue(issues, "codex_session_limit_applied", codexAttribution.session_limit_applied
44
44
  ? Math.max(1, codexAttribution.discovered_file_count -
45
45
  codexAttribution.scanned_file_count)
46
46
  : 0, "global");
47
- // A session directory that simply does not exist on this machine is not a
48
- // read failure; subtract those before reporting one.
49
- const missingTopLevelDirs = (await Promise.all(codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
50
- countScanIssue(issues, "codex_directory_read_failed", Math.max(0, codexAttribution.directory_read_failed_count - missingTopLevelDirs), "global");
47
+ // The reader already excludes ENOENT. Subtracting absent default roots
48
+ // here would erase a failure in another configured home or T3 discovery.
49
+ countScanIssue(issues, "codex_directory_read_failed", codexAttribution.directory_read_failed_count, "global");
51
50
  countScanIssue(issues, "codex_session_stat_failed", codexAttribution.stat_failed_count, "global");
52
51
  }
53
52
  /** The same census for the Claude store, which also has sidecars to account for. */
54
- async function addClaudeStoreScanIssues(issues, claudeAttribution, claudeProjectsDir, candidates) {
53
+ async function addClaudeStoreScanIssues(issues, claudeAttribution, candidates) {
55
54
  countScanIssue(issues, "claude_session_limit_applied", claudeAttribution.session_limit_applied
56
55
  ? Math.max(1, claudeAttribution.discovered_session_count -
57
56
  claudeAttribution.scanned_session_count)
58
57
  : 0, "global");
59
- const projectsDirMissing = await isMissingPath(claudeProjectsDir);
60
- countScanIssue(issues, "claude_project_dir_read_failed", Math.max(0, claudeAttribution.project_dir_read_failed_count -
61
- (projectsDirMissing ? 1 : 0)), "global");
58
+ countScanIssue(issues, "claude_project_dir_read_failed", claudeAttribution.project_dir_read_failed_count, "global");
62
59
  countScanIssue(issues, "claude_session_stat_failed", claudeAttribution.session_stat_failed_count, "global");
63
60
  countScanIssue(issues, "claude_sidecar_stat_failed", claudeAttribution.sidecar_stat_failed_count, "global");
64
61
  countScanIssue(issues, "claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(candidates), "global");
@@ -151,15 +148,6 @@ async function countUnreadableClaudeSidecarDirs(candidates) {
151
148
  }
152
149
  return unreadable;
153
150
  }
154
- async function isMissingPath(filePath) {
155
- try {
156
- await fs.stat(filePath);
157
- return false;
158
- }
159
- catch (error) {
160
- return isMissingFsError(error);
161
- }
162
- }
163
151
  function isMissingFsError(error) {
164
152
  if (!error || typeof error !== "object")
165
153
  return false;
@@ -1,3 +1,5 @@
1
+ import { scanCodexHistory, scanClaudeHistory } from "./backfill-history-scan.js";
2
+ import { nativeSessionRoots } from "../native-session-roots.js";
1
3
  /**
2
4
  * SCAN: read both archived session stores and turn them into the candidates
3
5
  * this run will consider, then census what could not be accounted for.
@@ -9,9 +11,6 @@
9
11
  * on what was missed before what merely arrived.
10
12
  */
11
13
  import os from "node:os";
12
- import path from "node:path";
13
- import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
14
- import { defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
15
14
  import { emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, } from "../cursors/backfill-cursor.js";
16
15
  import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
17
16
  import { liveWindowMsFor, scopeBackfillCandidates } from "./backfill-live-window.js";
@@ -136,10 +135,15 @@ async function auditScannedCandidates(scan, discoveryIncompleteReasons) {
136
135
  */
137
136
  async function scanBackfillSessions(options) {
138
137
  const scanLimit = options.command.maxFiles ?? 10_000;
139
- const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
140
- const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
138
+ const { codex: codexSessionDirs, claudeProjects: claudeProjectsDir, claudeProjectDirs, discoveryFailed } = nativeSessionRoots(options.homeDir);
141
139
  const codexScanned = options.sources.includes("codex") ? await scanCodexHistory(options, codexSessionDirs, scanLimit) : null;
142
- const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectsDir, scanLimit) : null;
140
+ const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectDirs, scanLimit) : null;
141
+ if (discoveryFailed) {
142
+ if (codexScanned)
143
+ codexScanned.directory_read_failed_count += 1;
144
+ if (claudeScanned)
145
+ claudeScanned.project_dir_read_failed_count += 1;
146
+ }
143
147
  // BLI-23590 withholds sessions outside the approved roots first; BLI-23478 then keeps drawer sessions home.
144
148
  const scoped = scopeHistoryScans(options, codexScanned, claudeScanned);
145
149
  const { codexAttribution, claudeAttribution } = shelveBackfillScan(scoped.codex, scoped.claude, options.shelfDecision);
@@ -155,6 +159,13 @@ async function scanBackfillSessions(options) {
155
159
  sinceMinutes: options.window.since_minutes,
156
160
  });
157
161
  const liveWindowMs = liveWindowMsFor(options.command);
162
+ const durableCodex = await readRawEvidenceCursor(getCollectorRuntimePaths(options.homeDir));
163
+ for (const c of scanned) {
164
+ const prior = durableCodex.sessions[c.session_id];
165
+ if (c.source === "codex" && (c.segment_paths || prior?.uploaded_source_revision) &&
166
+ prior?.uploaded_source_revision !== c.content_hash_sha256)
167
+ options.pointerlessTerminalSessions.codex.add(c.session_id);
168
+ }
158
169
  const selection = selectBackfillCandidates({
159
170
  scanned,
160
171
  cursor: options.cursor,
@@ -188,40 +199,6 @@ async function scanBackfillSessions(options) {
188
199
  live_window_ms: liveWindowMs,
189
200
  };
190
201
  }
191
- /**
192
- * The adapter's own limit is a memory guard, not permission to silently leave
193
- * history undiscovered. When it fires, re-scan the exact discovered population
194
- * so a user-facing `--max-files` cap can resume from a truthful ordering.
195
- */
196
- async function scanCodexHistory(request, sessionsDirs, scanLimit) {
197
- const scan = (limit) => scanAndAttributeCodexSessions({
198
- sessionsDirs,
199
- worktrees: request.worktrees,
200
- now: request.now,
201
- sinceMinutes: request.window.since_minutes,
202
- limit,
203
- collectionRoots: request.collectionRoots,
204
- });
205
- const firstPass = await scan(scanLimit);
206
- if (!firstPass.session_limit_applied)
207
- return firstPass;
208
- return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
209
- }
210
- /** Same rule for the Claude store: a capped first pass is rescanned in full. */
211
- async function scanClaudeHistory(request, projectsDir, scanLimit) {
212
- const scan = (limit) => scanAndAttributeClaudeSessions({
213
- projectsDir,
214
- worktrees: request.worktrees,
215
- now: request.now,
216
- sinceMinutes: request.window.since_minutes,
217
- limit,
218
- collectionRoots: request.collectionRoots,
219
- });
220
- const firstPass = await scan(scanLimit);
221
- if (!firstPass.session_limit_applied)
222
- return firstPass;
223
- return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
224
- }
225
202
  /**
226
203
  * Which scanned sessions this run will actually work on. A session is in play
227
204
  * if the cursor has not passed it, or if it is a terminal session that never
@@ -291,6 +268,7 @@ function normalizeCodexCandidate(result) {
291
268
  return {
292
269
  source: "codex",
293
270
  session_id: result.codex_session_id,
271
+ ...(result.segment_paths ? { segment_paths: result.segment_paths } : {}),
294
272
  session_id_source: result.session_id_source,
295
273
  file_path: result.file_path,
296
274
  state: result.state,
@@ -231,6 +231,7 @@ function backfillBatchSyncOptions(options) {
231
231
  .map((candidate) => ({
232
232
  local_path: candidate.file_path,
233
233
  codex_session_id: candidate.session_id,
234
+ ...(candidate.segment_paths ? { segment_paths: candidate.segment_paths, revision_hash: candidate.content_hash_sha256 ?? undefined, revision_bytes: candidate.byte_size } : {}),
234
235
  })),
235
236
  codexAttributionScan: options.codexAttribution ?? undefined,
236
237
  claudeSessionFiles: options.batch.candidates
@@ -1,6 +1,7 @@
1
1
  /** `cockpit books` usage text (BLI-23711), in its own module so the help table can print it without loading the command. */
2
2
  export const BOOKS_USAGE = [
3
- "Usage: cockpit books list [--json]",
3
+ "Usage: cockpit books list [--json] (includes archived books, marked archived)",
4
+ " cockpit books archive <book> [--restore] [--json] (founders; nothing deleted)",
4
5
  " cockpit books create <book> --title \"<title>\" [--json] (founders)",
5
6
  " cockpit books members list|add|remove <book> [<email or user id>] [--edit] [--json] (founders)",
6
7
  " cockpit books ls <book> <collection> [--after <doc_id>] [--limit <1-500>] [--deleted] [--json]",
@@ -4,7 +4,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
4
4
  import { BOOKS_USAGE } from "./books-usage.js";
5
5
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
6
6
  export { BOOKS_USAGE };
7
- const ACTIONS = ["list", "create", "members", "ls", "get", "put", "delete", "batch", "export", "import"];
7
+ const ACTIONS = ["list", "archive", "create", "members", "ls", "get", "put", "delete", "batch", "export", "import"];
8
8
  const BODY_MAX_CHARS = 4_000_000;
9
9
  function usage() {
10
10
  throw new Error(BOOKS_USAGE);
@@ -21,7 +21,7 @@ function wholeNumber(raw, min, max) {
21
21
  }
22
22
  export function parseBooksArgs(args) {
23
23
  const values = parseNamedArgs(args, {
24
- allowedFlags: ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data", "--json", "--home", "--dashboard-url"],
24
+ allowedFlags: ["--restore", "--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data", "--json", "--home", "--dashboard-url"],
25
25
  valueFlags: ["--title", "--after", "--limit", "--version", "--data", "--home", "--dashboard-url"],
26
26
  });
27
27
  const [action, ...rest] = values.positionals;
@@ -30,7 +30,7 @@ export function parseBooksArgs(args) {
30
30
  const flags = values.flags;
31
31
  const has = (flag) => flags.has(flag) || values.booleans.has(flag);
32
32
  const allowOnly = (...allowed) => {
33
- for (const flag of ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data"])
33
+ for (const flag of ["--restore", "--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data"])
34
34
  if (has(flag) && !allowed.includes(flag))
35
35
  usage();
36
36
  };
@@ -47,6 +47,11 @@ export function parseBooksArgs(args) {
47
47
  usage();
48
48
  allowOnly();
49
49
  return command;
50
+ case "archive":
51
+ if (rest.length !== 1)
52
+ usage();
53
+ allowOnly("--restore");
54
+ return { ...command, book: rest[0], restore: values.booleans.has("--restore") };
50
55
  case "create": {
51
56
  const title = optionalNonEmpty(flags.get("--title"));
52
57
  if (rest.length !== 1 || !title)
@@ -137,10 +142,13 @@ export async function runBooks(command, io) {
137
142
  if (books.length === 0)
138
143
  writeLine(io.stdout, "No books you can open.");
139
144
  for (const b of books)
140
- writeLine(io.stdout, `${b.id}\t${b.access}${b.founder ? " (founder)" : ""}\t${b.title}`);
145
+ writeLine(io.stdout, `${b.id}\t${b.access}${b.founder ? " (founder)" : ""}\t${b.title}${b.archived_at ? " (archived)" : ""}`);
141
146
  return 0;
142
147
  }
143
148
  break;
149
+ case "archive":
150
+ answer = await booksRequest(door, "POST", `/api/books/${book}/archive`, "archive", { archived: !command.restore });
151
+ break;
144
152
  case "create":
145
153
  answer = await booksRequest(door, "POST", "/api/books", "create", { id: command.book, title: command.title });
146
154
  break;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The person's answer to "Auto-trust the BLI Memory hooks in Codex?"
3
+ * (BLI-24349), kept so a "no" is never asked again.
4
+ *
5
+ * `<state_dir>/codex-hooks-trust.json`, one small object. Only a "no" changes
6
+ * behaviour (the question is not asked again); a "yes" is recorded for the
7
+ * operator reading the machine, since the system hooks file itself is what
8
+ * proves the yes. `cockpit doctor --trust-codex-hooks` is how a person who
9
+ * said no changes their mind.
10
+ *
11
+ * A file that will not read or parse counts as no answer: asking again is the
12
+ * harmless direction for a question whose default is yes.
13
+ */
14
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
15
+ import path from "node:path";
16
+ export const CODEX_HOOKS_TRUST_CHOICE_FILE = "codex-hooks-trust.json";
17
+ const SCHEMA = "codex-hooks-trust.v1";
18
+ function choiceFile(paths) {
19
+ return path.join(paths.state_dir, CODEX_HOOKS_TRUST_CHOICE_FILE);
20
+ }
21
+ export async function readCodexHooksTrustChoice(paths) {
22
+ let raw;
23
+ try {
24
+ raw = await readFile(choiceFile(paths), "utf8");
25
+ }
26
+ catch (error) {
27
+ const code = error?.code;
28
+ if (code === "ENOENT" || code === "ENOTDIR")
29
+ return { choice: null, reason: "choice_absent" };
30
+ console.error("[codex-hooks-trust] choice file unreadable; treating as no answer", JSON.stringify({ reason: "choice_unreadable", error_code: code ?? "unknown" }));
31
+ return { choice: null, reason: "choice_unreadable" };
32
+ }
33
+ try {
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed?.schema === SCHEMA && (parsed.choice === "declined" || parsed.choice === "trusted")) {
36
+ return { choice: parsed.choice, reason: "choice_read" };
37
+ }
38
+ }
39
+ catch {
40
+ // fall through to the named reason below
41
+ }
42
+ console.error("[codex-hooks-trust] choice file does not parse; treating as no answer", JSON.stringify({ reason: "choice_unreadable", byte_size: raw.length }));
43
+ return { choice: null, reason: "choice_unreadable" };
44
+ }
45
+ export async function writeCodexHooksTrustChoice(paths, choice, via, now = new Date()) {
46
+ const file = choiceFile(paths);
47
+ await mkdir(paths.state_dir, { recursive: true });
48
+ const staged = `${file}.${process.pid}.tmp`;
49
+ await writeFile(staged, `${JSON.stringify({ schema: SCHEMA, choice, via, at: now.toISOString() }, null, 2)}\n`, "utf8");
50
+ await rename(staged, file);
51
+ }