@bli-cockpit/cli 0.2.237 → 0.2.239

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 (148) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +15 -11
  2. package/dist/adapters/attribution-core-score.js +111 -87
  3. package/dist/adapters/car-state.js +38 -35
  4. package/dist/adapters/claude-attribution-discovery.js +81 -56
  5. package/dist/adapters/claude-attribution-score.js +108 -94
  6. package/dist/adapters/claude-attribution-signals.js +52 -42
  7. package/dist/adapters/codex-attribution.js +42 -33
  8. package/dist/adapters/codex-session-signals.js +11 -9
  9. package/dist/adapters/git-state.js +56 -50
  10. package/dist/adapters/local-sources.js +52 -43
  11. package/dist/adapters/raw-evidence-git-diff.js +4 -2
  12. package/dist/adapters/raw-evidence-sanitize.js +15 -12
  13. package/dist/adapters/raw-evidence-transcript-file.js +32 -31
  14. package/dist/autostart-self-heal.js +18 -2
  15. package/dist/commands/agent-session-report.js +51 -59
  16. package/dist/commands/agent-setup-observed.js +30 -23
  17. package/dist/commands/agent-setup.js +96 -45
  18. package/dist/commands/autostart-heal.js +8 -2
  19. package/dist/commands/autostart-reading.js +3 -0
  20. package/dist/commands/backfill-evidence-outcomes.js +9 -6
  21. package/dist/commands/backfill-issues.js +3 -0
  22. package/dist/commands/backfill-scan.js +27 -22
  23. package/dist/commands/backfill-upload.js +69 -60
  24. package/dist/commands/backfill.js +26 -21
  25. package/dist/commands/brief-done.js +4 -1
  26. package/dist/commands/brief-edit.js +71 -46
  27. package/dist/commands/cal-detail.js +35 -0
  28. package/dist/commands/cal-direct.js +119 -0
  29. package/dist/commands/cal-overlay.js +73 -30
  30. package/dist/commands/cal.js +62 -25
  31. package/dist/commands/clean.js +4 -1
  32. package/dist/commands/docs-read.js +5 -2
  33. package/dist/commands/docs.js +38 -6
  34. package/dist/commands/doctor-backfill.js +88 -71
  35. package/dist/commands/doctor-pipeline.js +59 -46
  36. package/dist/commands/heartbeat-post.js +116 -0
  37. package/dist/commands/heartbeat-token-lock.js +46 -33
  38. package/dist/commands/heartbeat.js +43 -125
  39. package/dist/commands/install-update.js +17 -15
  40. package/dist/commands/issue-show.js +27 -28
  41. package/dist/commands/issue-versions.js +93 -0
  42. package/dist/commands/issue-write.js +70 -23
  43. package/dist/commands/issue.js +73 -37
  44. package/dist/commands/jarvis-live-answer.js +99 -0
  45. package/dist/commands/jarvis-render.js +1 -94
  46. package/dist/commands/jarvis-trace.js +30 -22
  47. package/dist/commands/jarvis-turn-report.js +124 -0
  48. package/dist/commands/jarvis-turn.js +108 -182
  49. package/dist/commands/jarvis.js +26 -17
  50. package/dist/commands/local-args-collector-setup.js +82 -84
  51. package/dist/commands/local-args-collector-work.js +37 -38
  52. package/dist/commands/local-args-tower-admin.js +155 -142
  53. package/dist/commands/local-args-tower-cal.js +69 -54
  54. package/dist/commands/local-args-tower-careers.js +73 -54
  55. package/dist/commands/local-args-tower-docs-msg.js +79 -51
  56. package/dist/commands/local-args-tower-github.js +26 -20
  57. package/dist/commands/local-args-tower-hits.js +32 -20
  58. package/dist/commands/local-args-tower-manager.js +85 -4
  59. package/dist/commands/local-args-tower-memory.js +139 -117
  60. package/dist/commands/local-args-tower-msg.js +24 -6
  61. package/dist/commands/local-args-tower-notes.js +346 -0
  62. package/dist/commands/local-args-tower-pages.js +100 -378
  63. package/dist/commands/local-args-tower-settings.js +28 -29
  64. package/dist/commands/local-args-tower-setup.js +11 -4
  65. package/dist/commands/local-args-tower-work.js +74 -37
  66. package/dist/commands/local-args.js +80 -129
  67. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  68. package/dist/commands/local-help-commands-tower.js +20 -7
  69. package/dist/commands/local-help-commands.js +342 -341
  70. package/dist/commands/local-help.js +87 -83
  71. package/dist/commands/local-run-guards.js +100 -0
  72. package/dist/commands/local.js +137 -206
  73. package/dist/commands/login.js +13 -9
  74. package/dist/commands/logs.js +55 -38
  75. package/dist/commands/mail-attachments.js +44 -26
  76. package/dist/commands/manager-render-home.js +208 -0
  77. package/dist/commands/manager-render.js +7 -3
  78. package/dist/commands/manager-requests.js +33 -0
  79. package/dist/commands/manager.js +39 -5
  80. package/dist/commands/memory-consolidation.js +18 -9
  81. package/dist/commands/memory-daemon-probe.js +37 -28
  82. package/dist/commands/memory-install-config.js +64 -60
  83. package/dist/commands/memory-install.js +21 -41
  84. package/dist/commands/memory-requeue.js +26 -19
  85. package/dist/commands/memory-rules.js +26 -18
  86. package/dist/commands/memory-search-print.js +26 -0
  87. package/dist/commands/memory-store.js +14 -30
  88. package/dist/commands/memory-verb-list.js +49 -48
  89. package/dist/commands/msg-read-line.js +32 -0
  90. package/dist/commands/msg.js +45 -3
  91. package/dist/commands/notes-accounts.js +49 -32
  92. package/dist/commands/notes-sync-loop.js +115 -89
  93. package/dist/commands/notes-wisprflow-login.js +44 -23
  94. package/dist/commands/notes-writes.js +115 -100
  95. package/dist/commands/notes.js +33 -55
  96. package/dist/commands/onboard-flows.js +69 -51
  97. package/dist/commands/onboard.js +11 -9
  98. package/dist/commands/ops-render-memory.js +10 -7
  99. package/dist/commands/ops-render.js +33 -30
  100. package/dist/commands/ops.js +3 -0
  101. package/dist/commands/public-root.js +1 -1
  102. package/dist/commands/session-sync-outside-roots.js +40 -36
  103. package/dist/commands/session-sync-plan.js +14 -10
  104. package/dist/commands/session-sync-upload.js +24 -22
  105. package/dist/commands/session-sync.js +19 -8
  106. package/dist/commands/setup.js +13 -0
  107. package/dist/commands/slack.js +25 -16
  108. package/dist/commands/sources-check.js +13 -7
  109. package/dist/commands/sources-discovery.js +25 -17
  110. package/dist/commands/sources-verdict.js +19 -18
  111. package/dist/commands/speed.js +23 -19
  112. package/dist/commands/start.js +20 -16
  113. package/dist/commands/sync-doctor.js +52 -48
  114. package/dist/commands/sync.js +22 -16
  115. package/dist/commands/usage-telemetry-toml.js +71 -45
  116. package/dist/commands/usage.js +94 -68
  117. package/dist/commands/workbook-render.js +10 -7
  118. package/dist/evidence-reconcile-client.js +75 -64
  119. package/dist/evidence-redelivery-plan.js +37 -34
  120. package/dist/evidence-redelivery.js +32 -29
  121. package/dist/evidence-transcripts-client.js +20 -20
  122. package/dist/evidence-upload-object.js +86 -89
  123. package/dist/evidence-upload-plan.js +94 -83
  124. package/dist/evidence-upload-terminal.js +45 -50
  125. package/dist/image-description-session.js +156 -159
  126. package/dist/local-state-pairing-code.js +39 -31
  127. package/dist/local-state-status.js +9 -2
  128. package/dist/onboarding-roots.js +5 -8
  129. package/dist/process-runner.js +8 -8
  130. package/dist/raw-evidence-gc.js +33 -38
  131. package/dist/raw-evidence-stuck-summary.js +7 -14
  132. package/dist/repo-identity.js +26 -23
  133. package/dist/scheduled-self-update.js +11 -15
  134. package/dist/second-install.js +3 -0
  135. package/dist/slim/codex-rules.js +23 -36
  136. package/dist/spool/local-spool-parse.js +13 -12
  137. package/dist/sync-lock.js +5 -15
  138. package/dist/tower-client.js +36 -53
  139. package/dist/tower-stream.js +7 -24
  140. package/dist/upload-agent-artifacts.js +58 -64
  141. package/dist/upload-envelope-build.js +60 -59
  142. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  143. package/dist/upload-evidence-delivery-summary.js +32 -34
  144. package/dist/upload-ingest-receipt.js +20 -23
  145. package/dist/upload-session-reports-queue.js +4 -1
  146. package/dist/upload-session-reports-wire.js +64 -68
  147. package/dist/upload-sync.js +171 -157
  148. package/package.json +4 -4
@@ -32,20 +32,7 @@ export async function pairLocalCollectorViaLink(options = {}) {
32
32
  if (!fetchImpl) {
33
33
  throw new PairingCodeError("fetch_unavailable", "global fetch is unavailable; use Node.js 20 or newer.");
34
34
  }
35
- const minted = options.pairCode
36
- ? null
37
- : await mintPairingCode(fetchImpl, dashboardUrl, {
38
- device_id: deviceId,
39
- device_name: deviceName,
40
- claimed_owner_email: options.claimedOwnerEmail,
41
- collector_version: LOCAL_COLLECTOR_VERSION,
42
- });
43
- if (minted) {
44
- options.onLinkReady?.({
45
- connect_url: minted.connect_url,
46
- expires_at: minted.expires_at,
47
- });
48
- }
35
+ const minted = await preparePairingLink(options, fetchImpl, dashboardUrl, deviceId, deviceName);
49
36
  const session = await pollExchange(fetchImpl, dashboardUrl, {
50
37
  paths,
51
38
  code: options.pairCode ?? minted?.code ?? "",
@@ -66,6 +53,23 @@ export async function pairLocalCollectorViaLink(options = {}) {
66
53
  direction: minted ? "cli_first" : "browser_first",
67
54
  };
68
55
  }
56
+ async function preparePairingLink(options, fetchImpl, dashboardUrl, deviceId, deviceName) {
57
+ const minted = options.pairCode
58
+ ? null
59
+ : await mintPairingCode(fetchImpl, dashboardUrl, {
60
+ device_id: deviceId,
61
+ device_name: deviceName,
62
+ claimed_owner_email: options.claimedOwnerEmail,
63
+ collector_version: LOCAL_COLLECTOR_VERSION,
64
+ });
65
+ if (minted) {
66
+ options.onLinkReady?.({
67
+ connect_url: minted.connect_url,
68
+ expires_at: minted.expires_at,
69
+ });
70
+ }
71
+ return minted;
72
+ }
69
73
  async function mintPairingCode(fetchImpl, dashboardUrl, body) {
70
74
  const response = await fetchImpl(`${dashboardUrl}/api/devices/pair/code`, {
71
75
  method: "POST",
@@ -107,23 +111,7 @@ async function pollExchange(fetchImpl, dashboardUrl, options) {
107
111
  let attempts = 0;
108
112
  while (Date.now() <= deadline) {
109
113
  attempts += 1;
110
- const response = await fetchImpl(`${dashboardUrl}/api/devices/pair/exchange`, {
111
- method: "POST",
112
- headers: { "Content-Type": "application/json" },
113
- body: JSON.stringify({
114
- code: options.code,
115
- ...(options.clientSecret ? { client_secret: options.clientSecret } : {}),
116
- device_id: options.deviceId,
117
- device_name: options.deviceName,
118
- collector_version: LOCAL_COLLECTOR_VERSION,
119
- }),
120
- });
121
- const parsed = await readResponseJson(response, "exchange");
122
- const record = asRecord(parsed);
123
- const status = readString(record, "status");
124
- if (!response.ok) {
125
- throw refusalFrom(readString(record, "reason") ?? "pair_exchange_refused", response, parsed);
126
- }
114
+ const { record, status } = await requestPairingExchange(fetchImpl, dashboardUrl, options);
127
115
  if (status === "paired") {
128
116
  const session = parseExchangedSession(record, options.paths.session_file);
129
117
  await writeJsonFile(options.paths.session_file, session);
@@ -141,6 +129,26 @@ async function pollExchange(fetchImpl, dashboardUrl, options) {
141
129
  console.error(`${TAG} gave up waiting`, JSON.stringify({ reason: "pair_exchange_timeout", attempts }));
142
130
  throw new PairingCodeError("pair_exchange_timeout", "Timed out waiting for the browser sign-in. Run `cockpit login` again.");
143
131
  }
132
+ async function requestPairingExchange(fetchImpl, dashboardUrl, options) {
133
+ const response = await fetchImpl(`${dashboardUrl}/api/devices/pair/exchange`, {
134
+ method: "POST",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify({
137
+ code: options.code,
138
+ ...(options.clientSecret ? { client_secret: options.clientSecret } : {}),
139
+ device_id: options.deviceId,
140
+ device_name: options.deviceName,
141
+ collector_version: LOCAL_COLLECTOR_VERSION,
142
+ }),
143
+ });
144
+ const parsed = await readResponseJson(response, "exchange");
145
+ const record = asRecord(parsed);
146
+ const status = readString(record, "status");
147
+ if (!response.ok) {
148
+ throw refusalFrom(readString(record, "reason") ?? "pair_exchange_refused", response, parsed);
149
+ }
150
+ return { record, status };
151
+ }
144
152
  function parseExchangedSession(record, sessionFilePath) {
145
153
  const session = record?.["session"];
146
154
  if (!session || typeof session !== "object") {
@@ -18,6 +18,9 @@ import { readLocalWorkContextForRepo, workDisplayLabel, } from "./local-state-wo
18
18
  export async function inspectLocalCollectorStatus(options = {}) {
19
19
  const now = options.now ?? new Date();
20
20
  const reading = await readCollectorStatus(options, now);
21
+ return collectorStatusFromReading(reading, now);
22
+ }
23
+ function collectorStatusFromReading(reading, now) {
21
24
  const { paths, config, session, identity, context, branch } = reading;
22
25
  const { uploadSpool, healthOutbox, stuckEvidence } = reading;
23
26
  const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
@@ -132,7 +135,7 @@ function classifyUploadState(config, uploadSpool, session) {
132
135
  * queue, and each names its own reason and count.
133
136
  */
134
137
  function describeCollectorStatus(reading) {
135
- const { paths, config, session, context, uploadSpool, healthOutbox, stuckEvidence } = reading;
138
+ const { paths, config, session, context, uploadSpool, healthOutbox } = reading;
136
139
  const details = [];
137
140
  details.push(config
138
141
  ? `Config exists at ${paths.config_file}.`
@@ -158,6 +161,11 @@ function describeCollectorStatus(reading) {
158
161
  if (healthOutbox.pending_count > 0) {
159
162
  details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
160
163
  }
164
+ appendEvidenceStatus(details, reading);
165
+ return details;
166
+ }
167
+ function appendEvidenceStatus(details, reading) {
168
+ const { stuckEvidence } = reading;
161
169
  // BLI-3797: the line above only sees objects whose delivery FAILED. This one
162
170
  // sees evidence that is on this disk and not in the bucket, however it got
163
171
  // that way — which is the only question that answers "has everything landed?".
@@ -176,7 +184,6 @@ function describeCollectorStatus(reading) {
176
184
  if (stuckEvidence.ghost_record_count > 0) {
177
185
  details.push(`${stuckEvidence.ghost_record_count} old delivery record(s) have nothing left to send; no action needed.`);
178
186
  }
179
- return details;
180
187
  }
181
188
  /**
182
189
  * Whether this machine looks alive right now. A successful upload counts as
@@ -11,14 +11,8 @@ import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
11
11
  export { HOME_ROOT_DECLINE_PATH_PROMPT, HOME_ROOT_NO_FOLDER_CHOSEN_MESSAGE, homeRootConsentPrompt, missingCollectionRootMessage, rootRejectionExplanation, } from "./onboarding-root-guidance.js";
12
12
  export const COLLECTION_ROOT_REQUIRED = "collection_root_required";
13
13
  /**
14
- * No approved collection root, so there is nothing this machine may look at.
15
- *
16
- * A type rather than a message prefix (BLI-3551): the health-receipt classifier
17
- * used to recognise this by searching the error text, which is the same habit
18
- * that filed `session_report_unposted` as an auth failure. The message is
19
- * unchanged — `collection_root_required: <what to do about it>` — so every
20
- * operator-facing string and every test that reads one still matches; what
21
- * changed is that the classifier reads the type.
14
+ * Keep root failures typed so health classification never guesses from message text.
15
+ * The existing collection_root_required prefix remains a public contract.
22
16
  */
23
17
  export class CollectionRootRequiredError extends Error {
24
18
  reason = COLLECTION_ROOT_REQUIRED;
@@ -64,6 +58,9 @@ export async function resolveOnboardingRoots(options) {
64
58
  }
65
59
  return promptForRoots(options, "Collection root(s), comma-separated: ");
66
60
  }
61
+ return resolveSuggestedOrLikelyRoots(options);
62
+ }
63
+ async function resolveSuggestedOrLikelyRoots(options) {
67
64
  const suggestions = normalizeRootsDetailed(options.suggestedRoots ?? [], { homeDir: options.homeDir, allowHomeRoot: options.allowHomeRoot }).roots;
68
65
  if (options.interactive && suggestions.length > 0 && await requirePrompt(options).confirm(`Sessions were found in:\n${suggestions.join("\n")}\nCollect from these folders? [Y/n] `)) {
69
66
  return resolvedRoots(options, suggestions, "prompt", true);
@@ -37,14 +37,7 @@ export function createCapturedExecRunner(options = {}) {
37
37
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
38
38
  ...(runOptions?.timeoutMs ? { timeout: runOptions.timeoutMs } : {}),
39
39
  }, (error, stdout, stderr) => {
40
- // A child ended by `timeoutMs` reports code 124, never a bare 1.
41
- const code = error == null
42
- ? 0
43
- : error.killed === true && runOptions?.timeoutMs
44
- ? 124
45
- : typeof error.code === "number"
46
- ? error.code
47
- : 1;
40
+ const code = capturedExitCode(error, runOptions?.timeoutMs);
48
41
  resolve({
49
42
  code,
50
43
  stdout: stdout ?? "",
@@ -142,4 +135,11 @@ function escapeWindowsCmdArgument(value) {
142
135
  escaped = `"${escaped}"`;
143
136
  escaped = escaped.replace(CMD_META_CHARS, "^$1");
144
137
  return escaped.replace(CMD_META_CHARS, "^$1");
138
+ }
139
+ function capturedExitCode(error, timeoutMs) {
140
+ if (error == null)
141
+ return 0;
142
+ if (error.killed === true && timeoutMs)
143
+ return 124;
144
+ return typeof error.code === "number" ? error.code : 1;
145
145
  }
@@ -83,23 +83,10 @@ export function rawEvidenceGcSummary(result) {
83
83
  return `Cleaned up: removed ${result.removed_dirs} old folders, freed ~${formatMb(result.freed_bytes)} MB`;
84
84
  }
85
85
  /**
86
- * Collapse byte-identical staged packs down to one survivor.
87
- *
88
- * Runs at the START of every sync, unthrottled and with no age requirement,
89
- * because it is the only thing that drains a machine that already holds 559
90
- * copies of the same rollout (BLI-3066). The GC proper cannot: it only deletes
91
- * packs whose every file is already committed, and these were never committed —
92
- * that is precisely why they piled up.
93
- *
94
- * Deleting an uncommitted copy is safe here and only here: the survivor holds
95
- * the identical bytes, by content hash, so no evidence is lost. Identity comes
96
- * from the manifest's recorded file hashes — cheap (one small JSON per pack)
97
- * and exact. A pack with no readable manifest is counted and left alone rather
98
- * than guessed at.
99
- *
100
- * The survivor is the newest directory: it is the one the current content-keyed
101
- * pack id resolves to, so a fleet machine converges in one extra sync instead
102
- * of oscillating between an old name and a new one.
86
+ * Remove byte-identical staged packs without losing their surviving evidence.
87
+ * Fingerprint manifest hashes, never guess unreadable packs. Prefer a pack the
88
+ * staging index references, then the newest directory, so future collection
89
+ * reuses the survivor instead of recreating and deleting copies each tick.
103
90
  */
104
91
  export async function sweepDuplicateStagedRawEvidence(paths, env = process.env, now = new Date()) {
105
92
  const empty = {
@@ -123,6 +110,31 @@ export async function sweepDuplicateStagedRawEvidence(paths, env = process.env,
123
110
  // that would look like the sweep working while the bytes moved every cycle.
124
111
  const staging = await readRawEvidenceStagingState(paths.state_dir);
125
112
  const referencedPackIds = new Set(Object.values(staging.staged).map((entry) => entry.pack_id));
113
+ const { packs, removedStagingDirs } = await inspectStagedPacks(rawEvidenceRoot, dirEntries, referencedPackIds, now);
114
+ const { duplicateGroups, removedDirs, freedBytes, unfingerprintable } = await removeDuplicatePacks(packs);
115
+ const result = {
116
+ skipped: false,
117
+ duplicate_groups: duplicateGroups,
118
+ removed_dirs: removedDirs,
119
+ freed_bytes: freedBytes,
120
+ removed_staging_dirs: removedStagingDirs,
121
+ unfingerprintable_dirs: unfingerprintable,
122
+ };
123
+ // stderr, which launchd captures to `sync.err.log`. Logged on every sweep,
124
+ // including the boring one: "0 duplicates today" is the only evidence that
125
+ // the drain is still running at all.
126
+ console.error("[raw-evidence] dedup sweep", JSON.stringify({
127
+ reason: removedDirs > 0 ? "dedup_removed" : "dedup_clean",
128
+ pack_count: packs.length,
129
+ duplicate_groups: duplicateGroups,
130
+ removed_dirs: removedDirs,
131
+ freed_bytes: freedBytes,
132
+ removed_staging_dirs: removedStagingDirs,
133
+ unfingerprintable_dirs: unfingerprintable,
134
+ }));
135
+ return result;
136
+ }
137
+ async function inspectStagedPacks(rawEvidenceRoot, dirEntries, referencedPackIds, now) {
126
138
  let removedStagingDirs = 0;
127
139
  const packs = [];
128
140
  for (const entry of dirEntries) {
@@ -151,6 +163,9 @@ export async function sweepDuplicateStagedRawEvidence(paths, env = process.env,
151
163
  preferred: referencedPackIds.has(entry.name),
152
164
  });
153
165
  }
166
+ return { packs, removedStagingDirs };
167
+ }
168
+ async function removeDuplicatePacks(packs) {
154
169
  const groups = new Map();
155
170
  let unfingerprintable = 0;
156
171
  for (const pack of packs) {
@@ -186,27 +201,7 @@ export async function sweepDuplicateStagedRawEvidence(paths, env = process.env,
186
201
  freedBytes += byteSize;
187
202
  }
188
203
  }
189
- const result = {
190
- skipped: false,
191
- duplicate_groups: duplicateGroups,
192
- removed_dirs: removedDirs,
193
- freed_bytes: freedBytes,
194
- removed_staging_dirs: removedStagingDirs,
195
- unfingerprintable_dirs: unfingerprintable,
196
- };
197
- // stderr, which launchd captures to `sync.err.log`. Logged on every sweep,
198
- // including the boring one: "0 duplicates today" is the only evidence that
199
- // the drain is still running at all.
200
- console.error("[raw-evidence] dedup sweep", JSON.stringify({
201
- reason: removedDirs > 0 ? "dedup_removed" : "dedup_clean",
202
- pack_count: packs.length,
203
- duplicate_groups: duplicateGroups,
204
- removed_dirs: removedDirs,
205
- freed_bytes: freedBytes,
206
- removed_staging_dirs: removedStagingDirs,
207
- unfingerprintable_dirs: unfingerprintable,
208
- }));
209
- return result;
204
+ return { duplicateGroups, removedDirs, freedBytes, unfingerprintable };
210
205
  }
211
206
  export function rawEvidenceDedupSummary(result) {
212
207
  return `raw-evidence dedup: removed ${result.removed_dirs} duplicate pack(s) across ${result.duplicate_groups} group(s), freed ~${formatMb(result.freed_bytes)} MB`;
@@ -1,18 +1,8 @@
1
1
  /**
2
- * "Is anything stuck, and is anything being held right now?" — the two
3
- * questions `cockpit status`, the health receipt and the fleet board ask of the
4
- * delivery-attempt history, split out of `raw-evidence-staging.ts` (BLI-4022)
5
- * so that file stays under the repo's readability band.
6
- *
7
- * The distinction this file exists to keep: an object with a live failure
8
- * history is STUCK, and an object whose next attempt is still in the future is
9
- * HELD. Every held object is stuck and most stuck objects are held, but the two
10
- * answer different questions — "has anything been failing for nine days" versus
11
- * "what did this tick withhold, and when does it try again" — and BLI-4022
12
- * needed the second one, which nothing computed.
13
- *
14
- * Metadata only, like the rest of this family: content hashes, byte sizes,
15
- * reason labels, timestamps. Never content, never an absolute path.
2
+ * Report live delivery failures separately from evidence currently held by backoff.
3
+ * A hold carries its own oldest failure and next retry time. Delivery records
4
+ * without staged bytes are counted as ghosts, never as stuck evidence.
5
+ * Logs contain only metadata, never content or absolute paths.
16
6
  */
17
7
  import { deliveryRetryAtMs, terminalDeliveryReason } from "./raw-evidence-staging.js";
18
8
  /**
@@ -40,6 +30,9 @@ export function summarizeStuckEvidence(state, now) {
40
30
  reason: "object_no_longer_staged",
41
31
  }));
42
32
  }
33
+ return summarizeLiveDeliveryAttempts(entries, ghostCount, now);
34
+ }
35
+ function summarizeLiveDeliveryAttempts(entries, ghostCount, now) {
43
36
  const reasons = new Set();
44
37
  let heldCount = 0;
45
38
  let maxAttempts = 0;
@@ -113,29 +113,7 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
113
113
  discovered.set(identity.worktree_fingerprint, identity);
114
114
  continue;
115
115
  }
116
- // A failed read is recorded, never swallowed: anything beneath this folder
117
- // is missing from the scan, and the operator has to be able to see that.
118
- let entries;
119
- try {
120
- entries = await fs.readdir(current.dir, { withFileTypes: true });
121
- }
122
- catch (error) {
123
- if (unreadableDirs.length < MAX_REPORTED_UNREADABLE_DIRS) {
124
- unreadableDirs.push({ path: current.dir, code: errorCode(error) });
125
- }
126
- continue;
127
- }
128
- if (current.depth >= maxDepth) {
129
- if (entries.some((entry) => entry.isDirectory() && !shouldSkipDirectory(entry.name))) {
130
- incompleteReasons.add("max_depth_reached");
131
- }
132
- continue;
133
- }
134
- for (const entry of entries) {
135
- if (!entry.isDirectory() || shouldSkipDirectory(entry.name))
136
- continue;
137
- stack.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
138
- }
116
+ await enqueueChildDirectories(current, maxDepth, stack, unreadableDirs, incompleteReasons);
139
117
  }
140
118
  const expanded = await expandLinkedWorktrees([...discovered.values()].sort(compareIdentity), maxWorktrees, allowedRoots);
141
119
  for (const reason of expanded.incomplete_reasons) {
@@ -150,6 +128,31 @@ export async function discoverGitWorktreesWithStatus(root, options = {}) {
150
128
  unreadable_dirs: unreadableDirs,
151
129
  };
152
130
  }
131
+ async function enqueueChildDirectories(current, maxDepth, stack, unreadableDirs, incompleteReasons) {
132
+ // A failed read is recorded, never swallowed: anything beneath this folder
133
+ // is missing from the scan, and the operator has to be able to see that.
134
+ let entries;
135
+ try {
136
+ entries = await fs.readdir(current.dir, { withFileTypes: true });
137
+ }
138
+ catch (error) {
139
+ if (unreadableDirs.length < MAX_REPORTED_UNREADABLE_DIRS) {
140
+ unreadableDirs.push({ path: current.dir, code: errorCode(error) });
141
+ }
142
+ return;
143
+ }
144
+ if (current.depth >= maxDepth) {
145
+ if (entries.some((entry) => entry.isDirectory() && !shouldSkipDirectory(entry.name))) {
146
+ incompleteReasons.add("max_depth_reached");
147
+ }
148
+ return;
149
+ }
150
+ for (const entry of entries) {
151
+ if (!entry.isDirectory() || shouldSkipDirectory(entry.name))
152
+ continue;
153
+ stack.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
154
+ }
155
+ }
153
156
  /**
154
157
  * Discovers and deduplicates git worktrees across the exact roots supplied by
155
158
  * the caller. An approved root with no current git checkout intentionally
@@ -4,14 +4,8 @@ import { describeError } from "./health-detail.js";
4
4
  const SELF_UPDATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
5
5
  export const SELF_UPDATE_THROTTLE_MARKER = ".last-self-update-check";
6
6
  /**
7
- * launchd starts the scheduled tick with a bare PATH (`/usr/bin:/bin:...`) —
8
- * the plist hardcodes absolute node/CLI paths for exactly that reason — so a
9
- * bare `npm` spawn that works in every interactive shell resolves to nothing
10
- * inside the tick, and every scheduled update would fail `npm_install_failed`
11
- * on the whole Mac fleet while interactive `cockpit doctor` kept working. The
12
- * npm shim ships beside the node binary in the standard layouts on both host
13
- * families, so the running node's own directory is the one PATH entry that is
14
- * always right.
7
+ * Put the running node binary's directory on PATH for scheduled npm commands.
8
+ * Schedulers use a bare PATH; npm's shim ships beside node on both host families.
15
9
  */
16
10
  export function envWithNodeRuntimeOnPath(env, nodeExecutable = process.execPath) {
17
11
  const nodeBinDir = path.dirname(nodeExecutable);
@@ -24,13 +18,9 @@ export function envWithNodeRuntimeOnPath(env, nodeExecutable = process.execPath)
24
18
  };
25
19
  }
26
20
  /**
27
- * BLI-2601. Runs at most once per day from inside `cockpit sync`'s
28
- * post-collection tail, so the fleet converges on npm `latest` without
29
- * anyone re-running `npm i -g @bli-cockpit/cli && cockpit doctor` by hand after day 0.
30
- *
31
- * Installation is verified with npm's installed-version probe. The sync caller
32
- * re-executes the tick on `updated`, carrying a guard that skips another update
33
- * attempt in the replacement process.
21
+ * Converge scheduled collectors on npm latest, throttled to one attempt per day.
22
+ * Verify npm's installed version before callers re-execute the tick. A server
23
+ * floor bypasses the throttle; an operator's incident freeze still wins.
34
24
  */
35
25
  export async function runScheduledSelfUpdate(paths, deps, options = {}) {
36
26
  const env = options.env ?? process.env;
@@ -72,6 +62,9 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
72
62
  // this machine on purpose, and the server must not be able to unpin it.
73
63
  return { status: "skipped", reason: "disabled_by_env", ...forcedFields };
74
64
  }
65
+ return installScheduledUpdate(deps, tag, forced, minVersion, forcedFields);
66
+ }
67
+ async function installScheduledUpdate(deps, tag, forced, minVersion, forcedFields) {
75
68
  const targetVersion = await latestCliVersionFromNpm(deps.exec, tag);
76
69
  if (forced &&
77
70
  targetVersion &&
@@ -105,6 +98,9 @@ export async function runScheduledSelfUpdate(paths, deps, options = {}) {
105
98
  ...forcedFields,
106
99
  };
107
100
  }
101
+ return verifyScheduledUpdate(deps, targetVersion, forcedFields);
102
+ }
103
+ async function verifyScheduledUpdate(deps, targetVersion, forcedFields) {
108
104
  // Verify against what the platform returns, not the exit code npm handed
109
105
  // back (BLI-2541 lesson: a green exit code is not proof). Read the
110
106
  // globally installed package's version back off disk via `npm ls -g
@@ -53,6 +53,9 @@ export async function detectSecondCockpitInstall(options) {
53
53
  paths: installs,
54
54
  };
55
55
  }
56
+ return compareRunningInstall(installs, options, platform);
57
+ }
58
+ async function compareRunningInstall(installs, options, platform) {
56
59
  // Exactly one on PATH. It can still be a DIFFERENT install from the one
57
60
  // running now — the scheduler holds an absolute path to whichever prefix it
58
61
  // was registered with. Only decidable where the bin is a symlink into the
@@ -1,30 +1,12 @@
1
1
  /**
2
- * The slim rules for one Codex rollout line (BLI-16977). Entry point: `slimCodexRecord`.
3
- *
4
- * A Codex line is `{timestamp, type, payload}`. The envelope never changes; only payload goes.
5
- * Measured on 80 local rollouts, almost all the bulk is images (turned into markers before this
6
- * runs) and a second copy of each finished item (`event_msg` `item_completed`) next to the
7
- * `response_item` the model saw. The survey and its skeptic agree on what is a copy:
8
- *
9
- * - `item_completed` CommandExecution: `formatted_output` and `parsed_cmd` go, and `stdout` goes
10
- * when it equals `aggregated_output`. `aggregated_output` and `command` STAY: they are the only
11
- * full copy of what the shell printed.
12
- * - `item_completed` UserMessage, AgentMessage, Reasoning: any long string identical to one in an
13
- * EARLIER `response_item` becomes a reference. A copy that came first in the file is kept.
14
- * - McpToolCall results, FileChange contents, `compacted.replacement_history`, image generations:
15
- * kept (their images are markers by now). `compacted.guardian_history` keeps only what an
16
- * earlier `response_item` did not already hold.
17
- * - `token_count`: `last_token_usage` and `rate_limits` go; `total_token_usage` and
18
- * `model_context_window` stay (the session facts read them).
19
- * - `token_usage_record`: payload emptied (17,964 of 18,109 were byte-equal to a `token_count`).
20
- * - `thread_settings_applied`: reduced to `service_tier`.
21
- * - `response_item`: `internal_chat_message_metadata_passthrough` goes; a developer message, the
22
- * `session_meta` `base_instructions` and each `world_state` block are kept once per distinct
23
- * text and become references after.
24
- * - `task_complete.last_agent_message`: a reference when an earlier message holds the same text.
25
- *
26
- * `task_started`/`task_complete` durations, `session_meta.forked_from_id` and every unnamed shape
27
- * pass through untouched.
2
+ * Remove only proven duplicate Codex payloads, preserving the envelope.
3
+ * Keep command/aggregated_output, unique file/tool content, replacement_history,
4
+ * images after marker replacement, durations, fork identity and unknown shapes.
5
+ * Remember earlier response_item strings; later long copies become references.
6
+ * Keep total usage/context window; drop repeated usage/rate-limit bookkeeping.
7
+ * Keep one distinct developer message, base instructions and world-state block.
8
+ * Guardian history and task_complete messages deduplicate only against earlier
9
+ * response items; a copy appearing first is retained.
28
10
  */
29
11
  import { SLIM_COPY_MIN_CHARS, canonicalText, contentHash, isJsonObject, isSlimRef, slimRefFor, stripKey, } from "./slim-support.js";
30
12
  /** Strings from `response_item` lines remembered for comparison. Past this, new strings are not added. */
@@ -136,16 +118,18 @@ function rememberLongStrings(value, state, depth) {
136
118
  if (typeof value === "string") {
137
119
  if (value.length >= SLIM_COPY_MIN_CHARS)
138
120
  state.earlierStrings.add(contentHash(value));
121
+ return;
139
122
  }
140
- else if (Array.isArray(value)) {
123
+ if (Array.isArray(value)) {
141
124
  for (const item of value)
142
125
  rememberLongStrings(item, state, depth + 1);
126
+ return;
143
127
  }
144
- else if (isJsonObject(value)) {
145
- for (const [key, item] of Object.entries(value)) {
146
- if (key !== "encrypted_content")
147
- rememberLongStrings(item, state, depth + 1);
148
- }
128
+ if (!isJsonObject(value))
129
+ return;
130
+ for (const [key, item] of Object.entries(value)) {
131
+ if (key !== "encrypted_content")
132
+ rememberLongStrings(item, state, depth + 1);
149
133
  }
150
134
  }
151
135
  function refIfEarlierCopy(holder, key, state, rule, edits) {
@@ -167,13 +151,16 @@ function refEarlierCopiesIn(node, state, rule, edits, depth) {
167
151
  const holder = node;
168
152
  const child = holder[key];
169
153
  if (typeof child === "string") {
170
- if (child.length >= SLIM_COPY_MIN_CHARS && state.earlierStrings.has(contentHash(child))) {
171
- holder[key] = slimRefFor(contentHash(child));
172
- edits.strip(rule, child.length);
173
- }
154
+ refEarlierString(holder, key, child, state, rule, edits);
174
155
  }
175
156
  else if ((Array.isArray(child) || isJsonObject(child)) && !isSlimRef(child)) {
176
157
  refEarlierCopiesIn(child, state, rule, edits, depth + 1);
177
158
  }
178
159
  }
160
+ }
161
+ function refEarlierString(holder, key, child, state, rule, edits) {
162
+ if (child.length < SLIM_COPY_MIN_CHARS || !state.earlierStrings.has(contentHash(child)))
163
+ return;
164
+ holder[key] = slimRefFor(contentHash(child));
165
+ edits.strip(rule, child.length);
179
166
  }
@@ -65,24 +65,25 @@ function parseSourceRetryEntry(value, fieldPath) {
65
65
  function parsePendingSessionReport(value, fieldPath) {
66
66
  const record = requireRecord(value, fieldPath);
67
67
  const sessions = parseMetadataOnlySessionReportRows(record["sessions"], `${fieldPath}.sessions`);
68
+ const text = (key) => requireString(record[key], `${fieldPath}.${key}`);
68
69
  return {
69
- report_id: requireString(record["report_id"], `${fieldPath}.report_id`),
70
- created_at: requireString(record["created_at"], `${fieldPath}.created_at`),
71
- last_attempt_at: requireString(record["last_attempt_at"], `${fieldPath}.last_attempt_at`),
72
- dashboard_url: requireString(record["dashboard_url"], `${fieldPath}.dashboard_url`),
73
- generated_at: requireString(record["generated_at"], `${fieldPath}.generated_at`),
74
- work_context_id: requireString(record["work_context_id"], `${fieldPath}.work_context_id`),
75
- repo_label: requireString(record["repo_label"], `${fieldPath}.repo_label`),
76
- branch: requireString(record["branch"], `${fieldPath}.branch`),
77
- repo_fingerprint: requireString(record["repo_fingerprint"], `${fieldPath}.repo_fingerprint`),
70
+ report_id: text("report_id"),
71
+ created_at: text("created_at"),
72
+ last_attempt_at: text("last_attempt_at"),
73
+ dashboard_url: text("dashboard_url"),
74
+ generated_at: text("generated_at"),
75
+ work_context_id: text("work_context_id"),
76
+ repo_label: text("repo_label"),
77
+ branch: text("branch"),
78
+ repo_fingerprint: text("repo_fingerprint"),
78
79
  repo_origin_url: record["repo_origin_url"] === undefined
79
80
  ? null
80
81
  : requireNullableString(record["repo_origin_url"], `${fieldPath}.repo_origin_url`),
81
- worktree_label: requireString(record["worktree_label"], `${fieldPath}.worktree_label`),
82
- worktree_fingerprint: requireString(record["worktree_fingerprint"], `${fieldPath}.worktree_fingerprint`),
82
+ worktree_label: text("worktree_label"),
83
+ worktree_fingerprint: text("worktree_fingerprint"),
83
84
  worktree_is_primary: requireBoolean(record["worktree_is_primary"], `${fieldPath}.worktree_is_primary`),
84
85
  sessions,
85
- failure_reason: requireString(record["failure_reason"], `${fieldPath}.failure_reason`),
86
+ failure_reason: text("failure_reason"),
86
87
  };
87
88
  }
88
89
  export function parseMetadataOnlySessionReportRows(value, fieldPath) {
package/dist/sync-lock.js CHANGED
@@ -3,21 +3,8 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { describeError } from "./health-detail.js";
5
5
  /**
6
- * Single-flight lock for `cockpit sync`. A launchd timer and a manual sync can
7
- * fire at the same time and interleave the cursor read-modify-write; this lock
8
- * lets the second invocation exit cleanly with `sync_already_running` instead.
9
- *
10
- * A held lock refreshes a heartbeat every 30 s. Takeover happens only when the
11
- * heartbeat is more than 2 minutes stale — a fixed short timeout would fire
12
- * during a legitimate long first sync and recreate the interleaving it
13
- * prevents (D33). The lock file stays local and stores a pid, a heartbeat and
14
- * (BLI-12872) when the holder started and when it last finished a unit of
15
- * work; no paths or content.
16
- *
17
- * The heartbeat only proves the process is alive. `started_ms` and
18
- * `progress_ms` are what let `sync-lock-holder.ts` tell a slow tick from a
19
- * stuck one: a holder that keeps finishing work is never called stuck, however
20
- * long it has been running.
6
+ * Serialize collection ticks while tracking heartbeat ownership and progress.
7
+ * A stale owner may neither refresh nor release a replacement owner's lock.
21
8
  */
22
9
  const LOCK_FILENAME = "sync.lock";
23
10
  const HEARTBEAT_INTERVAL_MS = 30_000;
@@ -79,6 +66,9 @@ export async function acquireSyncLock(paths, now = new Date(), options = {}) {
79
66
  }
80
67
  logAcquired(held, takeover, existing, now);
81
68
  heldStates.add(held);
69
+ return maintainOwnedLock(lockPath, token, held);
70
+ }
71
+ function maintainOwnedLock(lockPath, token, held) {
82
72
  const timer = setInterval(() => {
83
73
  // Refresh only while the lock is still ours; if we were taken over, stop
84
74
  // touching the file so we cannot steal it back from the new owner.