@bli-cockpit/cli 0.2.197 → 0.2.198

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.
@@ -16,6 +16,18 @@ export async function runBrain(command, io) {
16
16
  writeLine(io.stderr, detail);
17
17
  return 1;
18
18
  }
19
+ if (command.action === "retry") {
20
+ const answer = await askAgentDoor(door, { path: "/api/brain/incidents/retry", method: "POST", label: "brain retry", timeoutMs: READ_DEADLINE_MS,
21
+ body: { ids: command.ids, all_needs_human: command.allNeedsHuman, project: command.project, reason: command.reason } });
22
+ if (!answer.ok)
23
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
24
+ const body = answer.body;
25
+ writeLine(io.stderr, `${TAG} retry answered ${JSON.stringify({ reason: body.reason, retried: body.retried.length, skipped: body.skipped.length, missing: body.missing.length, failures: body.failures.length })}`);
26
+ if (command.json)
27
+ return emitAgentDoor(door, body, body.ok ? 0 : 1);
28
+ writeLine(io.stdout, body.text);
29
+ return body.ok ? 0 : 1;
30
+ }
19
31
  const query = new URLSearchParams();
20
32
  if (command.project)
21
33
  query.set("project", command.project);
@@ -25,7 +25,7 @@ import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-cale
25
25
  import { runCalDelete } from "./cal-delete.js";
26
26
  import { runCalSyncStatus } from "./cal-sync-status.js";
27
27
  import { writeLine } from "./cli-io.js";
28
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
28
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
29
29
  const TAG = "[cal cli]";
30
30
  const READ_DEADLINE_MS = 30_000;
31
31
  const WRITE_DEADLINE_MS = 60_000;
@@ -118,7 +118,7 @@ async function window(command, door) {
118
118
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
119
119
  const body = answer.body;
120
120
  if (door.json)
121
- return emitAgentDoor(door, { ok: true, ...body });
121
+ return emitAgentDoor(door, { ok: true, ...body, ...cacheCoverage(answer) });
122
122
  if (answer.fromCache) {
123
123
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
124
124
  }
@@ -47,8 +47,8 @@ export async function syncStatusDocs(command, door) {
47
47
  const body = answer.body;
48
48
  if (door.json) {
49
49
  return emitAgentDoor(door, {
50
+ ...answer.body,
50
51
  ok: true,
51
- counts: body.counts,
52
52
  documents: body.documents ?? [],
53
53
  last_run: body.last_run ?? { push: null, pull: null },
54
54
  });
@@ -57,6 +57,8 @@ export async function syncStatusDocs(command, door) {
57
57
  for (const doc of documents) {
58
58
  writeLine(door.io.stdout, `${doc.id} ${doc.state.padEnd(13)} ${(doc.notion_synced_at ?? "never").padEnd(25)} ${doc.slug ?? "(no slug)"} ${doc.title}`);
59
59
  }
60
+ if (body.coverage)
61
+ writeLine(door.io.stdout, body.coverage);
60
62
  const byState = body.counts?.by_state ?? {};
61
63
  writeLine(door.io.stdout, "");
62
64
  writeLine(door.io.stdout, `${body.counts?.linked ?? documents.length} linked: ${Object.entries(byState)
@@ -32,7 +32,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
32
32
  import { readDoc } from "./docs-read.js";
33
33
  import { linkDoc, syncStatusDocs } from "./docs-notion.js";
34
34
  import { readNoteFile } from "./notes-file.js";
35
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
35
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
36
36
  const TAG = "[docs cli]";
37
37
  const READ_DEADLINE_MS = 30_000;
38
38
  const WRITE_DEADLINE_MS = 60_000;
@@ -97,10 +97,13 @@ async function listDocs(command, door) {
97
97
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
98
98
  const documents = (answer.body.documents ?? []);
99
99
  if (door.json)
100
- return emitAgentDoor(door, { ok: true, documents });
100
+ return emitAgentDoor(door, { ok: true, ...answer.body, documents, ...cacheCoverage(answer) });
101
101
  if (answer.fromCache) {
102
102
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
103
103
  }
104
+ const counts = answer.body.counts;
105
+ if (counts)
106
+ writeLine(door.io.stdout, `${counts.total} documents match in all. ${counts.coverage}`);
104
107
  if (documents.length === 0) {
105
108
  writeLine(door.io.stdout, command.query ? `No document matches "${command.query}".` : "No documents.");
106
109
  return 0;
@@ -53,6 +53,7 @@ setupReceipt, repairs = []) {
53
53
  repairs,
54
54
  needs_person: needsPerson,
55
55
  setup_receipt: setupReceipt?.receipt ?? null,
56
+ setup_receipt_coverage: setupReceipt ? setupReceiptBlock(setupReceipt).join("; ") : "no data yet: no cached receipt",
56
57
  memory_install: setupReceipt?.memory ?? null,
57
58
  }, null, 2));
58
59
  return;
@@ -270,7 +270,7 @@ export async function sendCollectorHeartbeatBestEffort(options) {
270
270
  const heartbeat = buildCollectorHeartbeat({
271
271
  roots: options.roots,
272
272
  facts: options.facts,
273
- setupReceipt: { ...(setupReceipt ?? { schema_version: "setup-receipt.v1", checked_at: new Date().toISOString(), browser: { status: "unknown" }, device: { status: "unknown" }, collector: { autostart: { status: "unknown" } } }), agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) },
273
+ setupReceipt: setupReceipt ? { ...setupReceipt, agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) } : null,
274
274
  ...(options.now ? { now: options.now } : {}),
275
275
  memoryInstall: memory.receipt,
276
276
  });
@@ -28,7 +28,7 @@
28
28
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
29
29
  import { writeLine } from "./cli-io.js";
30
30
  import { assigneeLabel, beginAssigneeDirectoryFetch, finishAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
31
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
31
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
32
32
  import { showIssue } from "./issue-show.js";
33
33
  import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
34
34
  import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
@@ -154,11 +154,15 @@ async function listIssues(command, door) {
154
154
  if (door.json) {
155
155
  return emitAgentDoor(door, {
156
156
  ok: true,
157
+ ...(body.counts ? { counts: body.counts } : {}),
158
+ ...cacheCoverage(answer),
157
159
  issues: issues.map((issue) => withSizeLabel(withAssigneeIdentity(issue, directory))),
158
160
  ...(body.completeness ? { completeness: body.completeness } : {}),
159
161
  ...(body.outside_project ? { outside_project: body.outside_project } : {}),
160
162
  });
161
163
  }
164
+ if (body.counts)
165
+ writeLine(door.io.stdout, `${body.counts.total} match in all. ${body.counts.coverage}`);
162
166
  if (issues.length === 0) {
163
167
  writeLine(door.io.stdout, "No issues match that.");
164
168
  return 0;
@@ -13,12 +13,12 @@ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-value
13
13
  export const BRAIN_SHOW_STATUSES = ["open", "running", "needs_human", "fixed", "not_actionable", "cleared"];
14
14
  export function parseBrainArgs(args) {
15
15
  const values = parseNamedArgs(args, {
16
- allowedFlags: ["--project", "--show", "--home", "--dashboard-url", "--json"],
17
- valueFlags: ["--project", "--show", "--home", "--dashboard-url"],
16
+ allowedFlags: ["--project", "--show", "--home", "--dashboard-url", "--json", "--all-needs-human", "--reason"],
17
+ valueFlags: ["--project", "--show", "--home", "--dashboard-url", "--reason"],
18
18
  });
19
19
  const action = values.positionals[0];
20
- if (action !== "status" || values.positionals.length > 1) {
21
- throw new Error("brain takes one verb: `status`. Example: `cockpit brain status` or `cockpit brain status --project pearl-portal`.");
20
+ if ((action !== "status" && action !== "retry") || (action === "status" && values.positionals.length > 1)) {
21
+ throw new Error("brain takes one verb: `status` or `retry`. Example: `cockpit brain status` or `cockpit brain status --project pearl-portal`.");
22
22
  }
23
23
  const project = optionalNonEmpty(values.flags.get("--project"));
24
24
  if (project && !LOG_PROJECT_PATTERN.test(project))
@@ -26,9 +26,25 @@ export function parseBrainArgs(args) {
26
26
  const show = optionalNonEmpty(values.flags.get("--show"));
27
27
  if (show && !BRAIN_SHOW_STATUSES.includes(show))
28
28
  throw new Error(`--show wants one of ${BRAIN_SHOW_STATUSES.join(", ")}.`);
29
+ const ids = values.positionals.slice(1);
30
+ const reason = optionalNonEmpty(values.flags.get("--reason"))?.trim();
31
+ const allNeedsHuman = values.booleans.has("--all-needs-human");
32
+ if (action === "retry") {
33
+ if (!reason || reason.length > 4000)
34
+ throw new Error("--reason requires text (at most 4000 characters).");
35
+ if (!ids.length && !allNeedsHuman)
36
+ throw new Error("retry requires incident ids or --all-needs-human.");
37
+ if (ids.length > 100 || ids.some(id => !/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|BLI-[0-9]+)$/i.test(id)))
38
+ throw new Error("retry wants incident UUIDs or BLI issue identifiers (at most 100).");
39
+ if (show)
40
+ throw new Error("--show belongs to brain status.");
41
+ }
42
+ else if (reason || allNeedsHuman)
43
+ throw new Error("--reason and --all-needs-human belong to brain retry.");
29
44
  return {
30
45
  kind: "brain",
31
- action: "status",
46
+ action,
47
+ ...(action === "retry" ? { ids, reason, allNeedsHuman } : {}),
32
48
  ...(project ? { project } : {}),
33
49
  ...(show ? { show } : {}),
34
50
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
@@ -49,6 +49,10 @@ export const TOWER_OPS_COMMAND_HELP = [
49
49
  [
50
50
  "Usage: cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
51
51
  "",
52
+ "Retry: cockpit brain retry <id...> [--all-needs-human] [--project <p>] --reason <text> [--json]",
53
+ "Ids are incident UUIDs or BLI issue identifiers. Retry reopens needs_human or not_actionable rows, resets attempts and claims,",
54
+ "and records who, why and when. --all-needs-human selects only needs_human; --project narrows every selection. Other states are skipped.",
55
+ "Only members and super admins can retry. One comment is posted per incident issue. MCP twin: brain_retry.",
52
56
  "Tower's incident queue. Every 15 minutes the log-digest cron turns each recurring problem in a project's logs (classes",
53
57
  "our_bug, config_drift, capacity, unknown) into ONE incident with ONE Tower issue; a repeat adds to its count, never a twin.",
54
58
  "A template quiet for 24 hours is marked cleared. This prints, per project, how many incidents are open, being worked,",
@@ -26,7 +26,7 @@ import { writeFile } from "node:fs/promises";
26
26
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
27
27
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
28
28
  import { composeMessage, failAttachmentsUnconfirmed, readMailBody, runMailDraft, unconfirmedAttachments, } from "./mail-compose.js";
29
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
29
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
30
30
  import { towerRequest } from "../tower-client.js";
31
31
  const TAG = "[mail cli]";
32
32
  const READ_DEADLINE_MS = 30_000;
@@ -177,7 +177,7 @@ async function inbox(command, door) {
177
177
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
178
178
  const body = answer.body;
179
179
  if (door.json)
180
- return emitAgentDoor(door, { ok: true, ...body });
180
+ return emitAgentDoor(door, { ok: true, ...body, ...cacheCoverage(answer) });
181
181
  if (answer.fromCache) {
182
182
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
183
183
  }
@@ -208,11 +208,9 @@ export async function readMemoryReceiptFile(options) {
208
208
  if (!receipt)
209
209
  return { receipt: null, reason: "receipt_unparseable" };
210
210
  const age = (options.now ?? new Date()).getTime() - Date.parse(receipt.checked_at);
211
- // A reading a week old is not a reading. Better to send nothing and let the
212
- // board say "not reported" than to publish a claim about a machine that has
213
- // not been looked at since.
211
+ // Keep last-known host facts, with the original checked_at and reason.
214
212
  if (Number.isFinite(age) && age > MEMORY_RECEIPT_MAX_AGE_MS) {
215
- return { receipt: null, reason: "receipt_expired" };
213
+ return { receipt, reason: "receipt_expired" };
216
214
  }
217
215
  return { receipt, reason: "receipt_read" };
218
216
  }
@@ -49,7 +49,7 @@ import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.
49
49
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
50
50
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
51
51
  import { runMemoryConsolidation } from "./memory-consolidation.js";
52
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
52
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
53
53
  import { runMemoryRequeue } from "./memory-requeue.js";
54
54
  import { runMemoryVisibility } from "./memory-visibility.js";
55
55
  import { readNoteFile } from "./notes-file.js";
@@ -194,7 +194,7 @@ async function searchMemory(command, door) {
194
194
  degraded: body.degraded ?? null,
195
195
  })}`);
196
196
  if (door.json)
197
- return emitAgentDoor(door, answer.body);
197
+ return emitAgentDoor(door, { ...answer.body, ...cacheCoverage(answer) });
198
198
  if (answer.fromCache) {
199
199
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
200
200
  }
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
20
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
21
21
  const TAG = "[msg cli]";
22
22
  const READ_DEADLINE_MS = 30_000;
23
23
  const WRITE_DEADLINE_MS = 30_000;
@@ -64,6 +64,7 @@ async function fetchChannels(door) {
64
64
  channels: answer.body.channels ?? [],
65
65
  fromCache: answer.fromCache,
66
66
  ageMs: answer.ageMs,
67
+ coverage: answer.coverage,
67
68
  };
68
69
  }
69
70
  async function listChannels(door) {
@@ -71,7 +72,7 @@ async function listChannels(door) {
71
72
  if (!fetched.ok)
72
73
  return failAgentDoor(door, TAG, fetched.reason, fetched.detail);
73
74
  if (door.json)
74
- return emitAgentDoor(door, { ok: true, channels: fetched.channels });
75
+ return emitAgentDoor(door, { ok: true, channels: fetched.channels, ...cacheCoverage(fetched) });
75
76
  if (fetched.fromCache) {
76
77
  writeLine(door.io.stderr, `(${cacheAgeLabel(fetched.ageMs ?? 0)}, refreshing in the background)`);
77
78
  }
@@ -31,5 +31,7 @@ export async function runFolderCommand(command, door) {
31
31
  const label = command.tree ? `${" ".repeat(folder.path.split("/").length - 1)}${folder.name}` : folder.path;
32
32
  writeLine(door.io.stdout, `${label} (${folder.note_count})`);
33
33
  }
34
+ if (body.coverage)
35
+ writeLine(door.io.stdout, body.coverage);
34
36
  return 0;
35
37
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.197");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.198");
25
25
  return 0;
26
26
  }
27
27
 
@@ -6,7 +6,7 @@
6
6
  * Text mode: a cache hit prints INSTANTLY (its age travels with it), then a
7
7
  * background refresh is spawned so the next call is live — never blocking
8
8
  * this process on a network round trip it already has a recent answer for.
9
- * `--json` (an agent, not a person) trusts cache only under
9
+ * `--json` attempts live first when the cache is older than
10
10
  * `JSON_FRESH_BOUND_MS`, unless `--allow-stale` says otherwise; older than
11
11
  * that runs the live request inline, same as a miss. `--fresh` skips the
12
12
  * read entirely — both flags are process-wide (BLI-10450's `--quiet`
@@ -73,7 +73,7 @@ export async function readThroughCache(options) {
73
73
  else {
74
74
  log(`[read-cache] refresh skipped ${JSON.stringify({ reason: "too_recent", age_ms: ageMs })}`);
75
75
  }
76
- return { ok: true, httpStatus: cached.status, body: cached.body, fromCache: true, ageMs };
76
+ return { ok: true, httpStatus: cached.status, body: cached.body, fromCache: true, ageMs, coverage: cacheAgeLabel(ageMs) };
77
77
  }
78
78
  log(`[read-cache] too stale for --json ${JSON.stringify({ surface: options.surface, label: options.label, age_ms: ageMs })}`);
79
79
  }
@@ -86,6 +86,18 @@ export async function readThroughCache(options) {
86
86
  writeReadCacheEntryAtomic(entryPath, { status: live.httpStatus, body: live.body });
87
87
  log(`[read-cache] refreshed ${JSON.stringify({ surface: options.surface, label: options.label })}`);
88
88
  }
89
+ else if (!fresh && live.httpStatus !== 401 && live.httpStatus !== 403) {
90
+ const cached = readReadCacheEntry(entryPath);
91
+ if (cached) {
92
+ const ageMs = Date.now() - cached.storedAt;
93
+ const coverage = `served stale: ${cacheAgeLabel(ageMs)}, live failed: ${live.reason}`;
94
+ log(`[read-cache] ${coverage} ${JSON.stringify({ reason: live.reason, age_ms: ageMs })}`);
95
+ const body = cached.body && typeof cached.body === "object" && !Array.isArray(cached.body)
96
+ ? { ...cached.body, cache_coverage: coverage, cache_age_ms: ageMs, live_failure_reason: live.reason }
97
+ : cached.body;
98
+ return { ok: true, httpStatus: cached.status, body, fromCache: true, ageMs, coverage };
99
+ }
100
+ }
89
101
  return { ...live, fromCache: false, ageMs: null };
90
102
  }
91
103
  /**
@@ -112,4 +124,8 @@ export function spawnBackgroundRefresh(io, log, spawnImpl = spawn) {
112
124
  catch (error) {
113
125
  log(`[read-cache] background refresh failed to spawn ${JSON.stringify({ reason: error instanceof Error ? error.name : "unknown" })}`);
114
126
  }
127
+ }
128
+ /** JSON readers carry the same age sentence as the terminal. */
129
+ export function cacheCoverage(answer) {
130
+ return answer.fromCache ? { fromCache: true, ageMs: answer.ageMs, cache_coverage: answer.coverage ?? cacheAgeLabel(answer.ageMs ?? 0) } : {};
115
131
  }
@@ -20,7 +20,7 @@
20
20
  * being written to stdout, stderr, or an argument vector.
21
21
  */
22
22
  import { isInteractiveStdin, readLine, settleYesNo, writeLine } from "./cli-io.js";
23
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
23
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
24
24
  import { asList, asRecord, callTower, isForbidden, openTower, parseModelKey, readAllStdin, writeCommandFailure, } from "./tower-command.js";
25
25
  import { showOverview } from "./settings-overview.js";
26
26
  import { renderCliFloor, renderEnvBlobs, renderModelRouting, renderPersonal, renderSwitches, } from "./settings-render.js";
@@ -75,7 +75,7 @@ async function showPersonal(command, tower, io) {
75
75
  if (result.fromCache && !command.json) {
76
76
  writeLine(io.stderr, `(${cacheAgeLabel(result.ageMs ?? 0)}, refreshing in the background)`);
77
77
  }
78
- return writeBody(io, command.json, result.body, () => renderPersonal(asRecord(result.body)));
78
+ return writeBody(io, command.json, { ...asRecord(result.body), ...cacheCoverage(result) }, () => renderPersonal(asRecord(result.body)));
79
79
  }
80
80
  async function setPersonal(command, tower, io) {
81
81
  const result = await callTower(tower, {
@@ -10,6 +10,8 @@
10
10
  * gets ONE command or ONE click, never a paragraph and never "see the docs".
11
11
  */
12
12
  import { setupPieceMark, setupReceiptLine, setupReceiptPieces, } from "@bli-cockpit/telemetry-core";
13
+ /** Cached host facts are old after the same seven-day window as their readers. */
14
+ const OLD_READING_MS = 7 * 24 * 60 * 60 * 1000;
13
15
  /** The one fix for each piece, by key. Present for every key the block prints. */
14
16
  const FIXES = {
15
17
  browser: "Run `cockpit login` and open the link it prints.",
@@ -57,8 +59,18 @@ export function setupReceiptBlock(reading, options = {}) {
57
59
  continue;
58
60
  lines.push(`${indent} ${label} ${setupPieceMark(piece.status)}${reasonSuffix(piece)} — ${fixFor(key, piece)}`);
59
61
  }
60
- if (options.showCheckedAt) {
61
- lines.push(`${indent}Last read: ${receipt.checked_at}`);
62
+ const now = reading.readAt ? Date.parse(reading.readAt) : Date.now();
63
+ const ageMs = now - Date.parse(receipt.checked_at);
64
+ if (options.showCheckedAt || reading.refreshFailure || !Number.isFinite(ageMs) || ageMs > OLD_READING_MS) {
65
+ const age = Number.isFinite(ageMs) ? `${Math.max(0, Math.floor(ageMs / 3_600_000))}h ago` : "age unavailable";
66
+ lines.push(`${indent}As of ${receipt.checked_at} (${age})${reading.refreshFailure ? `; this run could not re-read the machine: ${reading.refreshFailure}` : ""}`);
67
+ }
68
+ if (memory) {
69
+ const memoryAgeMs = now - Date.parse(memory.checked_at);
70
+ if (!Number.isFinite(memoryAgeMs) || memoryAgeMs > OLD_READING_MS) {
71
+ const age = Number.isFinite(memoryAgeMs) ? `${Math.max(0, Math.floor(memoryAgeMs / 3_600_000))}h ago` : "age unavailable";
72
+ lines.push(`${indent}Agent hosts as of ${memory.checked_at} (${age})`);
73
+ }
62
74
  }
63
75
  return lines;
64
76
  }
@@ -39,7 +39,7 @@
39
39
  * owns the target vocabulary this translates from.
40
40
  */
41
41
  import os from "node:os";
42
- import { SETUP_RECEIPT_SCHEMA_VERSION, setupReceiptGaps, setupReceiptLine, } from "@bli-cockpit/telemetry-core";
42
+ import { SETUP_RECEIPT_SCHEMA_VERSION, SetupReceiptSchema, setupReceiptGaps, setupReceiptLine, } from "@bli-cockpit/telemetry-core";
43
43
  import { readHeartbeatMemoryReceipt } from "./heartbeat.js";
44
44
  import { autostartSetupPiece, readAutostartRegistration, } from "./autostart-reading.js";
45
45
  import { readLocalSessionReference } from "../local-state.js";
@@ -89,7 +89,7 @@ export async function buildSetupReceipt(io, options = {}) {
89
89
  gaps,
90
90
  memory_receipt: memory ? "read" : "absent",
91
91
  }));
92
- return { receipt, memory };
92
+ return { receipt, memory, readAt: checkedAt };
93
93
  }
94
94
  function withDefaultProbes(io, homeDir, options) {
95
95
  return {
@@ -167,8 +167,8 @@ export async function cacheSetupReceipt(receipt, homeDir) {
167
167
  }
168
168
  /**
169
169
  * The cached receipt, or null with a named reason. A receipt older than
170
- * `SETUP_RECEIPT_MAX_AGE_MS` is refused rather than sent: a stale word is
171
- * worse than an absent one, because absence asks and staleness answers.
170
+ * `SETUP_RECEIPT_MAX_AGE_MS` is logged as stale and sent with its original
171
+ * checked_at; readers show its age rather than discarding the last reading.
172
172
  */
173
173
  export async function readCachedSetupReceipt(homeDir, now = new Date()) {
174
174
  let raw;
@@ -187,8 +187,7 @@ export async function readCachedSetupReceipt(homeDir, now = new Date()) {
187
187
  }
188
188
  const age = now.getTime() - Date.parse(parsed.checked_at);
189
189
  if (!Number.isFinite(age) || age > SETUP_RECEIPT_MAX_AGE_MS) {
190
- console.error(`${TAG} cache too old to send`, JSON.stringify({ reason: "cache_stale", age_days: Math.round(age / 86_400_000) }));
191
- return null;
190
+ console.error(`${TAG} serving old cache`, JSON.stringify({ reason: "cache_stale", age_days: Math.round(age / 86_400_000) }));
192
191
  }
193
192
  return parsed;
194
193
  }
@@ -196,7 +195,8 @@ function safeParseReceipt(raw) {
196
195
  const record = raw;
197
196
  if (!record || record.schema_version !== SETUP_RECEIPT_SCHEMA_VERSION)
198
197
  return null;
199
- return raw;
198
+ const parsed = SetupReceiptSchema.safeParse(raw);
199
+ return parsed.success ? parsed.data : null;
200
200
  }
201
201
  /** The one line, plus the one fix per gap. Rendering lives in `setup-receipt-lines.ts`. */
202
202
  export { setupReceiptLine };
@@ -212,11 +212,23 @@ export { setupReceiptLine };
212
212
  export async function refreshSetupReceipt(io, options = {}) {
213
213
  try {
214
214
  const reading = await buildSetupReceipt(io, options);
215
+ const probeFailed = [reading.receipt.browser, reading.receipt.device, reading.receipt.collector.autostart].some(piece => piece.reason?.endsWith("probe_failed"));
216
+ if (probeFailed) {
217
+ const cached = await readCachedSetupReceipt(options.homeDir, options.now);
218
+ if (cached) {
219
+ console.error(`${TAG} could not be computed`, JSON.stringify({ reason: "receipt_build_failed" }));
220
+ return { receipt: cached, memory: reading.memory, readAt: reading.readAt, refreshFailure: "receipt_build_failed" };
221
+ }
222
+ }
215
223
  await cacheSetupReceipt(reading.receipt, options.homeDir);
216
224
  return reading;
217
225
  }
218
226
  catch (error) {
219
227
  console.error(`${TAG} could not be computed`, JSON.stringify({ reason: "receipt_build_failed", error_name: errorName(error) }));
220
- return null;
228
+ const receipt = await readCachedSetupReceipt(options.homeDir, options.now);
229
+ if (!receipt)
230
+ return null;
231
+ const memory = await readHeartbeatMemoryReceipt({ homeDir: options.homeDir, now: options.now });
232
+ return { receipt, memory: memory.receipt, readAt: (options.now ?? new Date()).toISOString(), refreshFailure: "receipt_build_failed" };
221
233
  }
222
234
  }
@@ -48,6 +48,8 @@ export async function runSetup(command, io) {
48
48
  if (pair.use?.[side])
49
49
  writeLine(io.stdout, ` ${side === "first" ? pair.first : pair.second}: tokens by model: ${pair.use[side].models ?? "not available right now."} Skills used: ${pair.use[side].skills ?? "not available right now."}`);
50
50
  }
51
+ for (const line of body.read_coverage ?? [])
52
+ writeLine(io.stdout, line);
51
53
  if (body.note)
52
54
  writeLine(io.stdout, body.note);
53
55
  return 0;
@@ -1,11 +1,5 @@
1
1
  /**
2
- * `cockpit status` — what this machine believes about itself: install, pairing,
3
- * active work, upload state, retry backlog, and how far historical backfill
4
- * has got.
5
2
  *
6
- * Read-only. Split out of commands/local.ts (BLI-3104). BLI-3194 rewrote the
7
- * human block into plain English, because these lines are what an intern
8
- * pastes into Slack when something looks wrong. The `--json` payload is the
9
3
  * machine contract and is byte-for-byte unchanged — `status-json-shape.test.ts`
10
4
  * holds it to that.
11
5
  *
@@ -131,7 +125,7 @@ function writeSingleRepoLines(io, status, backfillCursor, setupReceipt, disk) {
131
125
  if (status.linear_ticket_id)
132
126
  writeLine(io.stdout, `Linear: ${status.linear_ticket_id}`);
133
127
  writeLine(io.stdout, `Work: ${displayWorkLabel(status)}`);
134
- writeLine(io.stdout, `Last collected: ${lastCollectedLine(status.collector_freshness)}`);
128
+ writeLine(io.stdout, `Last collected: ${lastCollectedLine(status.collector_freshness, status.last_collected_at)}`);
135
129
  writeLine(io.stdout, `Version: ${status.collector_version}`);
136
130
  writeLine(io.stdout, `Uploads: ${uploadsLine(status.upload_state)}`);
137
131
  writeLine(io.stdout, `Last upload tried: ${status.last_upload_attempt_at ?? "never"}`);
@@ -239,7 +233,14 @@ function signedInLine(sessionState) {
239
233
  return "cannot tell";
240
234
  }
241
235
  }
242
- function lastCollectedLine(freshness) {
236
+ export function lastCollectedLine(freshness, timestamp) {
237
+ if (timestamp) {
238
+ const ageMs = Date.now() - Date.parse(timestamp);
239
+ const minutes = Math.max(0, Math.floor(ageMs / 60_000));
240
+ const age = minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`;
241
+ const label = lastCollectedLine(freshness);
242
+ return `${label} (${timestamp}; ${age} ago)`;
243
+ }
243
244
  switch (freshness) {
244
245
  case "fresh":
245
246
  return "recently";
@@ -41,6 +41,7 @@ export function githubWords(github, people) {
41
41
  `GitHub: ${noun(sum("pull_requests_opened"), "pull request")} opened, ${sum("pull_requests_merged")} merged, ${noun(sum("commits"), "commit")}, ` +
42
42
  `from ${noun((github.repositories ?? []).length, "mirrored repository", "mirrored repositories")}${range}.`,
43
43
  ];
44
+ lines.push(...(github.read_coverage ?? []));
44
45
  const unmatched = github.unattributed;
45
46
  if (unmatched && unmatched.pull_requests_opened + unmatched.pull_requests_merged + unmatched.commits > 0) {
46
47
  lines.push(`Not matched to a person: ${noun(unmatched.pull_requests_opened, "pull request")} opened, ${unmatched.pull_requests_merged} merged, ${noun(unmatched.commits, "commit")}.`);
@@ -116,6 +116,8 @@ export async function runUsage(command, io) {
116
116
  // date it cannot know.
117
117
  writeLine(io.stdout, "");
118
118
  writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent, not actual spend (this dashboard sent no price-sheet version)");
119
+ for (const line of body.read_coverage ?? [])
120
+ writeLine(io.stdout, line);
119
121
  writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
120
122
  // BLI-4356: the gap says why. A session observed with no tokens is either
121
123
  // waiting on a count that has not run yet, or an automated one this view
@@ -43,6 +43,10 @@ export async function inspectLocalCollectorStatus(options = {}) {
43
43
  worktree_fingerprint: context?.worktree_fingerprint ?? identity.worktree_fingerprint,
44
44
  worktree_is_primary: context?.worktree_is_primary ?? identity.worktree_is_primary,
45
45
  collector_freshness: freshness,
46
+ last_collected_at: (() => {
47
+ const timestamp = context ? latestTimestamp([context.updated_at ?? context.started_at, uploadSpool.last_upload_success_at]) : null;
48
+ return timestamp === null ? null : new Date(timestamp).toISOString();
49
+ })(),
46
50
  upload_state: uploadState,
47
51
  last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
48
52
  last_upload_success_at: uploadSpool.last_upload_success_at,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.197",
3
+ "version": "0.2.198",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.60",
31
- "@bli-cockpit/mcp": "0.1.109",
31
+ "@bli-cockpit/mcp": "0.1.110",
32
32
  "@bli-cockpit/telemetry-core": "0.1.69"
33
33
  },
34
34
  "repository": {