@bli-cockpit/cli 0.2.197 → 0.2.199

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 (34) hide show
  1. package/dist/commands/brain.js +12 -0
  2. package/dist/commands/cal-delete.js +3 -1
  3. package/dist/commands/cal.js +19 -3
  4. package/dist/commands/docs-notion.js +3 -1
  5. package/dist/commands/docs.js +5 -2
  6. package/dist/commands/doctor-report.js +1 -0
  7. package/dist/commands/heartbeat.js +1 -1
  8. package/dist/commands/issue.js +5 -1
  9. package/dist/commands/local-args-tower-brain.js +21 -5
  10. package/dist/commands/local-args-tower-cal.js +6 -5
  11. package/dist/commands/local-args-tower-mail.js +10 -5
  12. package/dist/commands/local-args-tower-pages.js +1 -0
  13. package/dist/commands/local-help-commands-tower-ops.js +4 -0
  14. package/dist/commands/local-help-commands-tower.js +8 -4
  15. package/dist/commands/mail-account-preferences.js +16 -0
  16. package/dist/commands/mail-compose.js +2 -2
  17. package/dist/commands/mail.js +11 -4
  18. package/dist/commands/memory-install-receipt.js +2 -4
  19. package/dist/commands/memory-store.js +2 -2
  20. package/dist/commands/msg.js +3 -2
  21. package/dist/commands/notes-bot-upcoming.js +16 -0
  22. package/dist/commands/notes-folders.js +2 -0
  23. package/dist/commands/notes.js +3 -0
  24. package/dist/commands/public-root.js +1 -1
  25. package/dist/commands/read-cache.js +18 -2
  26. package/dist/commands/settings.js +2 -2
  27. package/dist/commands/setup-receipt-lines.js +14 -2
  28. package/dist/commands/setup-receipt.js +20 -8
  29. package/dist/commands/setup.js +2 -0
  30. package/dist/commands/status.js +9 -8
  31. package/dist/commands/usage-format.js +1 -0
  32. package/dist/commands/usage.js +2 -0
  33. package/dist/local-state-status.js +4 -0
  34. package/package.json +2 -2
@@ -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);
@@ -4,7 +4,7 @@ const TAG = "[cal cli]";
4
4
  const DELETE_DEADLINE_MS = 60_000;
5
5
  export async function runCalDelete(command, door) {
6
6
  const answer = await askAgentDoor(door, {
7
- path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}`,
7
+ path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}${command.accountName ? `?account=${encodeURIComponent(command.accountName)}` : ""}`,
8
8
  method: "DELETE",
9
9
  label: "cal delete",
10
10
  timeoutMs: DELETE_DEADLINE_MS,
@@ -18,6 +18,8 @@ export async function runCalDelete(command, door) {
18
18
  writeLine(door.io.stdout, "Tower answered without a result. Nothing to report.");
19
19
  return 0;
20
20
  }
21
+ if (deleted.account_name)
22
+ writeLine(door.io.stdout, `Account: ${deleted.account_name}.`);
21
23
  const alreadyGone = deleted.google === "already_gone_at_google";
22
24
  const stored = deleted.local_store === "removed"
23
25
  ? "Tower's copy is removed."
@@ -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;
@@ -52,6 +52,9 @@ export async function runCal(command, io) {
52
52
  return runCalSync(command, door);
53
53
  case "sync-status":
54
54
  return runCalSyncStatus(command, door);
55
+ case "change":
56
+ return changeEvent(command, door);
57
+ case "cancel":
55
58
  case "delete":
56
59
  return runCalDelete(command, door);
57
60
  }
@@ -118,7 +121,7 @@ async function window(command, door) {
118
121
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
119
122
  const body = answer.body;
120
123
  if (door.json)
121
- return emitAgentDoor(door, { ok: true, ...body });
124
+ return emitAgentDoor(door, { ok: true, ...body, ...cacheCoverage(answer) });
122
125
  if (answer.fromCache) {
123
126
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
124
127
  }
@@ -260,7 +263,7 @@ async function listCalendars(door) {
260
263
  ? (calendar.last_sync_at ? `synced ${calendar.last_sync_at.slice(0, 16).replace("T", " ")}` : "never synced")
261
264
  : `${calendar.status}: ${calendar.status_reason ?? "no reason recorded"}`;
262
265
  const flags = `${calendar.org_visible ? "shared" : "private"}${calendar.selected ? "" : ", hidden"}`;
263
- writeLine(door.io.stdout, `${calendar.name.slice(0, 32).padEnd(32)} ${calendar.provider.padEnd(12)} ${flags.padEnd(16)} ${health}`);
266
+ writeLine(door.io.stdout, `${calendar.account_label ?? calendar.provider} ${calendar.name.slice(0, 32).padEnd(32)} ${calendar.provider.padEnd(12)} ${flags.padEnd(16)} ${health}`);
264
267
  }
265
268
  writeLine(door.io.stdout, "");
266
269
  writeLine(door.io.stdout, `${calendars.length} calendar(s). ids: ${calendars.map((calendar) => calendar.id).join(", ")}`);
@@ -274,6 +277,7 @@ async function create(command, door) {
274
277
  timeoutMs: WRITE_DEADLINE_MS,
275
278
  body: {
276
279
  calendar_id: command.calendarId,
280
+ ...(command.accountName ? { account: command.accountName } : {}),
277
281
  title: command.title,
278
282
  starts_at: command.startsAt,
279
283
  ends_at: command.endsAt,
@@ -299,6 +303,8 @@ async function create(command, door) {
299
303
  }
300
304
  if (event?.html_link)
301
305
  writeLine(door.io.stdout, event.html_link);
306
+ if (event?.account_name)
307
+ writeLine(door.io.stdout, `Account: ${event.account_name}.`);
302
308
  if (command.meet) {
303
309
  writeLine(door.io.stdout, event?.meet_link
304
310
  ? `Google Meet: ${event.meet_link}`
@@ -314,4 +320,14 @@ function meetingLinkText(event) {
314
320
  const meeting = event.meeting_url ? ` ${label}: ${event.meeting_url}` : "";
315
321
  console.error("[cal render] meeting link", JSON.stringify({ reason: event.meeting_url ? "meeting_link_shown" : "no_meeting_link" }));
316
322
  return meeting;
323
+ }
324
+ async function changeEvent(command, door) {
325
+ const answer = await askAgentDoor(door, { path: `/api/cal/events/${encodeURIComponent(command.subjectId)}`, method: "PATCH", label: "cal change", timeoutMs: WRITE_DEADLINE_MS,
326
+ body: { ...(command.accountName ? { account: command.accountName } : {}), ...(command.title ? { title: command.title } : {}), ...(command.startsAt ? { starts_at: command.startsAt } : {}), ...(command.endsAt ? { ends_at: command.endsAt } : {}), ...(command.location ? { location: command.location } : {}) } });
327
+ if (!answer.ok)
328
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
329
+ if (door.json)
330
+ return emitAgentDoor(door, { ok: true, ...answer.body });
331
+ writeLine(door.io.stdout, `Changed event in ${answer.body.event?.account_name ?? "its account"}.`);
332
+ return 0;
317
333
  }
@@ -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")),
@@ -26,6 +26,7 @@ const CAL_ACTIONS = new Set([
26
26
  "find",
27
27
  "calendars",
28
28
  "add-ical",
29
+ "change", "cancel",
29
30
  "create",
30
31
  "share",
31
32
  "detach",
@@ -34,7 +35,7 @@ const CAL_ACTIONS = new Set([
34
35
  "delete",
35
36
  ]);
36
37
  /** Every verb that names ONE thing first — a calendar id, or (delete) an event id. */
37
- const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["sync", "detach", "share", "delete"]);
38
+ const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["sync", "detach", "share", "delete", "change", "cancel"]);
38
39
  /** `sync-status [<calendar id>]` — every calendar, or one's. Ticket 10730. */
39
40
  const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status"]);
40
41
  export function parseCalArgs(args) {
@@ -43,6 +44,7 @@ export function parseCalArgs(args) {
43
44
  "--home",
44
45
  "--dashboard-url",
45
46
  "--calendar",
47
+ "--account",
46
48
  "--tz",
47
49
  "--offset",
48
50
  "--hours",
@@ -68,6 +70,7 @@ export function parseCalArgs(args) {
68
70
  "--home",
69
71
  "--dashboard-url",
70
72
  "--calendar",
73
+ "--account",
71
74
  "--tz",
72
75
  "--offset",
73
76
  "--hours",
@@ -134,9 +137,6 @@ function readCalCreateDetails(action, values) {
134
137
  const startsAt = optionalNonEmpty(values.flags.get("--at"));
135
138
  const endsAt = optionalNonEmpty(values.flags.get("--until"));
136
139
  if (action === "create") {
137
- if (!optionalNonEmpty(values.flags.get("--calendar"))) {
138
- throw new Error("cal create needs --calendar <id>: with several calendars attached, which one this goes on is yours to say. `cockpit cal calendars` lists them.");
139
- }
140
140
  if (!title)
141
141
  throw new Error('cal create needs --title "<what it is>".');
142
142
  if (!startsAt)
@@ -155,7 +155,7 @@ function validateCalSharing(action, values) {
155
155
  }
156
156
  /** `delete` notifies attendees, exactly like `create`'s invitations — never without saying so first. */
157
157
  function validateCalDelete(action, values) {
158
- if (action === "delete" && !values.booleans.has("--yes")) {
158
+ if ((action === "delete" || action === "cancel") && !values.booleans.has("--yes")) {
159
159
  throw new Error("cal delete needs --yes: it removes the event at Google and can email everybody on it. Add --yes once you mean it.");
160
160
  }
161
161
  }
@@ -175,6 +175,7 @@ function buildCalCommand(values, action, subject, query, title, startsAt, endsAt
175
175
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
176
176
  ...(subject ? { subjectId: subject } : {}),
177
177
  ...(query ? { query } : {}),
178
+ accountName: optionalNonEmpty(values.flags.get("--account")),
178
179
  calendarId: optionalNonEmpty(values.flags.get("--calendar")),
179
180
  // An absent `--tz` means "this machine's zone", resolved at run time in
180
181
  // `commands/cal.ts` rather than here, so the parser stays pure.
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
32
32
  const MAIL_ACTIONS = new Set([
33
+ "rename", "enable", "disable", "default", "remove",
33
34
  "accounts",
34
35
  "add-imap",
35
36
  "detach",
@@ -54,6 +55,7 @@ export function parseMailArgs(args) {
54
55
  "--home",
55
56
  "--dashboard-url",
56
57
  "--account",
58
+ "--source",
57
59
  "--address",
58
60
  "--name",
59
61
  "--limit",
@@ -74,6 +76,7 @@ export function parseMailArgs(args) {
74
76
  "--home",
75
77
  "--dashboard-url",
76
78
  "--account",
79
+ "--source",
77
80
  "--address",
78
81
  "--name",
79
82
  "--limit",
@@ -90,6 +93,12 @@ export function parseMailArgs(args) {
90
93
  });
91
94
  const { action, rest } = readMailAction(values);
92
95
  const { subject, query } = readMailSubjectOrQuery(action, rest);
96
+ if (["rename", "enable", "disable", "default", "remove"].includes(action) && !values.flags.get("--account"))
97
+ throw new Error("Name the account with --account <name>.");
98
+ if (action === "rename" && !values.flags.get("--name"))
99
+ throw new Error("Rename needs --name <new name>.");
100
+ if (["enable", "disable"].includes(action) && !["mail", "calendar"].includes(values.flags.get("--source") ?? ""))
101
+ throw new Error("Name --source mail or --source calendar.");
93
102
  const address = readImapAddress(action, values);
94
103
  const { to, cc } = readMailRecipients(action, values);
95
104
  const outPath = readAttachmentPath(action, values);
@@ -146,11 +155,6 @@ function readMailRecipients(action, values) {
146
155
  if (MAIL_ACTIONS_COMPOSING.has(action) && to.length === 0) {
147
156
  throw new Error(`mail ${action} needs --to "someone@example.com" (comma-separated for several).`);
148
157
  }
149
- if (MAIL_ACTIONS_COMPOSING.has(action) && !optionalNonEmpty(values.flags.get("--account"))) {
150
- throw new Error(action === "draft"
151
- ? "mail draft needs --account <id>: with several mailboxes attached, which one the draft is saved in is yours to say. `cockpit mail accounts` lists them."
152
- : "mail send needs --account <id>: with several mailboxes attached, which address this goes out from is yours to say. `cockpit mail accounts` lists them.");
153
- }
154
158
  return { to, cc };
155
159
  }
156
160
  function readAttachmentPath(action, values) {
@@ -194,6 +198,7 @@ function buildMailCommand(values, action, subject, query, address, to, cc, outPa
194
198
  ...(subject ? { subjectId: subject } : {}),
195
199
  ...(query ? { query } : {}),
196
200
  accountId: optionalNonEmpty(values.flags.get("--account")),
201
+ source: values.flags.get("--source"),
197
202
  address,
198
203
  displayName: optionalNonEmpty(values.flags.get("--name")),
199
204
  ...(limit === undefined ? {} : { limit }),
@@ -215,6 +215,7 @@ const NOTES_ACTIONS = new Set([
215
215
  "webhook",
216
216
  // Ticket 10718: the Granola cron's own last-several-runs health.
217
217
  "granola-status",
218
+ "bot-upcoming",
218
219
  ]);
219
220
  /** Actions whose first positional is the note it acts on. */
220
221
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -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,",
@@ -115,7 +115,8 @@ export const TOWER_COMMAND_HELP = [
115
115
  [
116
116
  "Usage: cockpit mail [accounts|add-imap|inbox|read <thread>|search \"<words>\"|send|draft|attachment <id>|sync <account>|sync-status [<account>]|detach <account>] [flags]",
117
117
  "",
118
- "Every mailbox you attached, in one place. One person, several addresses — nothing here has a \"current\" mailbox.",
118
+ "Every mailbox you attached, in one place. One person, several addresses. Writes use a named account or your default.",
119
+ "rename --account <name> --name <label>; enable|disable --account <name> --source mail|calendar; default|remove --account <name>. Switches keep stored data; remove disconnects and deletes Tower copies.",
119
120
  "accounts — every mailbox, its provider (gmail_oauth or imap), its health and when it last synced. The ids other verbs take are on the last line.",
120
121
  "add-imap --address <you@gmail.com> [--name \"<display name>\"] — attaches a personal Google account over IMAP + SMTP. The APP PASSWORD is read from stdin and there is no flag for it:",
121
122
  " macOS: printf \"%s\" \"abcd efgh ijkl mnop\" | cockpit mail add-imap --address you@gmail.com",
@@ -124,8 +125,8 @@ export const TOWER_COMMAND_HELP = [
124
125
  "inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment. Every row (text or --json) carries the thread id — `[thread <id>]` in text — so `mail read <thread>` never costs a second --json call to learn it (BLI-10450).",
125
126
  "read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox` or `cockpit mail search`, in either --json or plain text.",
126
127
  "search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box. Every row (text or --json) carries the thread id.",
127
- "send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
128
- "draft --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
128
+ "send [--account <name|id>] --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — the BODY comes from --file or stdin. Name --account to override your default; with several accounts and no default, Tower asks which one.",
129
+ "draft [--account <name|id>] --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
129
130
  "--attach <path> (send and draft; repeat it for each file) — a file that rides along with the message: up to 10, and 3,000,000 bytes together (about 3 MB: a request to Tower is capped at 4.5 MB and a file travels as base64, a third bigger than itself). The type comes from the extension (.docx .pdf .png .jpg .jpeg .txt .csv .md .zip and more; anything else goes as application/octet-stream), the name is the file's own. --file is still the BODY and only the body. Refused BEFORE anything is sent, by name and with the numbers: too_many_attachments, attachment_unreadable (a path that is missing, a folder or empty), attachment_too_large. A reply (--reply-to) keeps its thread with files in it. Works on Gmail mailboxes and on IMAP mailboxes for send; a draft is Gmail only. --json adds { attachments: { count, bytes, content_types } } to the answer. The terminal reports from Tower's own answer, not from what it posted: if Tower does not confirm the files (an older Tower), it exits 1 with attachments_not_confirmed and says whether the message went. Names, paths and contents are never logged; a refusal says \"Attachment 2\", not which file (BLI-12661).",
130
131
  "attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
131
132
  "sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
@@ -152,12 +153,14 @@ export const TOWER_COMMAND_HELP = [
152
153
  ' PowerShell: "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
153
154
  " Find it at calendar.google.com -> the calendar's Settings -> \"Secret address in iCal format\". Anybody who has that link can read the calendar, which is why it never goes on a command line.",
154
155
  " A work @buildlaunchiterate.ca calendar does not need this: connect Google in the browser and `cockpit cal calendars` will list them.",
155
- 'create --calendar <id> --title "<what>" --at <iso> --until <iso> [--location "<where>"] [--attendee a@x,b@y] [--all-day] [--meet] — creates the event AT GOOGLE, then here. Attendees are invited by Google; --meet asks for a Google Meet room and prints its address (or says meet_not_available and keeps the event).',
156
+ 'create [--account <name>|--calendar <id>] --title "<what>" --at <iso> --until <iso> [--location "<where>"] [--attendee a@x,b@y] [--all-day] [--meet] — creates the event AT GOOGLE, then here. Attendees are invited by Google; --meet asks for a Google Meet room and prints its address (or says meet_not_available and keeps the event).',
156
157
  " Only a Google calendar can be written to: an iCal secret address is a read address, and `write_not_supported_for_provider` says so by name.",
157
158
  "share <id> --org-visible|--private — makes one of YOUR calendars readable by everyone at BLI, or private again. Sharing never lets anybody else change it.",
158
159
  "sync <id> [--full] — reads that calendar now instead of waiting for the cron. --full ignores the sync token / ETag and re-reads everything.",
159
160
  "sync-status [<id>] — every calendar's health, or one's: last successful sync, the last pass's own reason, how many events it carried, failures in the last 24h, and the lag between an event's own provider clock and when Tower stored it (ticket 10730).",
160
161
  "detach <id> — removes the calendar and its stored events from Tower. The calendar itself is untouched at Google.",
162
+ "change <event id> [--account <name>] [--title <title>] [--at <iso> --until <iso>] [--location <place>] — changes an event on its own account.",
163
+ "cancel <event id> --yes [--account <name>] — same door as delete; a named account must own the event.",
161
164
  "delete <event id> --yes — removes ONE event, AT GOOGLE first, then here. Refuses without --yes: a delete can email everybody on it, exactly like create's invitations do. Google already having it gone (404/410) is answered as success, not a refusal.",
162
165
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
163
166
  "A refusal keeps Tower's own reason label — needs_rls_client, calendar_not_found_or_unreadable, calendar_not_yours, event_not_found_or_unreadable, ical_url_invalid, google_oauth_not_configured, write_not_supported_for_provider, and so on.",
@@ -392,6 +395,7 @@ export const TOWER_COMMAND_HELP = [
392
395
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
393
396
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
394
397
  "sync-status [<account-id>] — every notetaker's health, or one's: last successful sync, the last pass's own reason, how many meetings it carried, failures in the last 24h, and the lag between the newest meeting it has read and when it last ran (ticket 10730).",
398
+ "bot-upcoming [--json] — your own Google Meet meetings today that the bot will join, with claim state. Decline or mark private to opt out.",
395
399
  "granola-status [--limit <n>] [--json] — the Granola cron's own last several runs (ticket 10718): reason, counts, model, spend and which lookback it used. Granola runs on a shared server-side key, not a connected account, so this is its own read rather than `accounts`.",
396
400
  "A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
397
401
  "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
@@ -0,0 +1,16 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export async function runMailAccountPreference(command, door) {
4
+ const removing = command.action === "remove";
5
+ const answer = await askAgentDoor(door, {
6
+ path: removing ? `/api/mail/accounts?account=${encodeURIComponent(command.accountId)}` : "/api/mail/accounts",
7
+ method: removing ? "DELETE" : "PATCH", label: `mail ${command.action}`, timeoutMs: 30_000,
8
+ ...(!removing ? { body: { account: command.accountId, ...(command.action === "rename" ? { account_label: command.displayName } : command.action === "default" ? { is_default: true } : { [`${command.source}_enabled`]: command.action === "enable" }) } } : {}),
9
+ });
10
+ if (!answer.ok)
11
+ return failAgentDoor(door, "[accounts cli]", answer.reason, answer.detail);
12
+ if (door.json)
13
+ return emitAgentDoor(door, { ok: true, ...answer.body });
14
+ writeLine(door.io.stdout, `Account ${command.accountId}: ${command.action} completed.`);
15
+ return 0;
16
+ }
@@ -63,7 +63,7 @@ export async function readMailBody(command, door) {
63
63
  */
64
64
  export function composedMessageBody(command, text, attachments = []) {
65
65
  return {
66
- account_id: command.accountId,
66
+ ...(command.accountId ? /^[0-9a-f-]{36}$/i.test(command.accountId) ? { account_id: command.accountId } : { account: command.accountId } : {}),
67
67
  to: command.to.map((address) => ({ address })),
68
68
  ...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
69
69
  subject: command.subjectLine ?? "",
@@ -164,7 +164,7 @@ export async function runMailDraft(command, door) {
164
164
  return failAttachmentsUnconfirmed(door, "draft", unconfirmed, draft);
165
165
  if (door.json)
166
166
  return emitAgentDoor(door, { ok: true, draft, ...message.receipt.json });
167
- writeLine(door.io.stdout, `Saved a draft in ${draft?.address ?? "that mailbox"} for ${command.to.join(", ")}. `
167
+ writeLine(door.io.stdout, `Saved a draft in ${draft?.account_name ?? draft?.address ?? "that mailbox"} for ${command.to.join(", ")}. `
168
168
  + "Nothing was sent: it is in Drafts for a person to read, edit and send.");
169
169
  if (message.receipt.sentence)
170
170
  writeLine(door.io.stdout, message.receipt.sentence);
@@ -26,7 +26,8 @@ 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
+ import { runMailAccountPreference } from "./mail-account-preferences.js";
30
31
  import { towerRequest } from "../tower-client.js";
31
32
  const TAG = "[mail cli]";
32
33
  const READ_DEADLINE_MS = 30_000;
@@ -37,6 +38,12 @@ const PASSWORD_MAX_CHARS = 200;
37
38
  export async function runMail(command, io) {
38
39
  const door = await openAgentDoor("mail", command, io);
39
40
  switch (command.action) {
41
+ case "rename":
42
+ case "enable":
43
+ case "disable":
44
+ case "default":
45
+ case "remove":
46
+ return runMailAccountPreference(command, door);
40
47
  case "accounts":
41
48
  return listAccounts(door);
42
49
  case "add-imap":
@@ -82,7 +89,7 @@ async function listAccounts(door) {
82
89
  }
83
90
  for (const account of accounts) {
84
91
  const health = account.status === "active" ? account.backfill_state : `${account.status}: ${account.status_reason ?? "no reason recorded"}`;
85
- writeLine(door.io.stdout, `${account.address.padEnd(34)} ${account.provider.padEnd(12)} ${health.padEnd(18)} ${account.last_sync_at ?? "never synced"}`);
92
+ writeLine(door.io.stdout, `${(account.account_label ?? account.address).padEnd(20)} ${account.is_default ? "default" : ""} mail=${account.mail_enabled !== false} calendar=${account.calendar_enabled !== false} ${account.address.padEnd(34)} ${account.provider.padEnd(12)} ${health.padEnd(18)} ${account.last_sync_at ?? "never synced"}`);
86
93
  }
87
94
  writeLine(door.io.stdout, "");
88
95
  writeLine(door.io.stdout, `${accounts.length} mailbox(es). ids: ${accounts.map((account) => account.id).join(", ")}`);
@@ -177,7 +184,7 @@ async function inbox(command, door) {
177
184
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
178
185
  const body = answer.body;
179
186
  if (door.json)
180
- return emitAgentDoor(door, { ok: true, ...body });
187
+ return emitAgentDoor(door, { ok: true, ...body, ...cacheCoverage(answer) });
181
188
  if (answer.fromCache) {
182
189
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
183
190
  }
@@ -279,7 +286,7 @@ async function send(command, door) {
279
286
  return failAttachmentsUnconfirmed(door, "send", unconfirmed, sent);
280
287
  if (door.json)
281
288
  return emitAgentDoor(door, { ok: true, sent, ...message.receipt.json });
282
- writeLine(door.io.stdout, `Sent from ${sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
289
+ writeLine(door.io.stdout, `Sent from ${sent?.account_name ?? sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
283
290
  if (message.receipt.sentence)
284
291
  writeLine(door.io.stdout, message.receipt.sentence);
285
292
  return 0;
@@ -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
  }
@@ -0,0 +1,16 @@
1
+ import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export async function showBotUpcoming(_command, door) {
4
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
5
+ const answer = await ask(door, { path: `/api/notetaker/upcoming?tz=${encodeURIComponent(tz)}`, method: "GET", label: "bot upcoming", timeoutMs: READ_DEADLINE_MS });
6
+ if (!answer.ok)
7
+ return fail(door, answer.reason, answer.detail);
8
+ if (door.json)
9
+ return emit(door, answer.body);
10
+ const body = answer.body;
11
+ if (!body.meetings.length)
12
+ writeLine(door.io.stdout, "No meetings for the bot today.");
13
+ for (const meeting of body.meetings)
14
+ writeLine(door.io.stdout, `${meeting.startsAt} ${meeting.claim.status} ${meeting.meetUrl}`);
15
+ return 0;
16
+ }
@@ -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
  }
@@ -44,6 +44,7 @@ import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
44
44
  import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
45
45
  import { runFolderCommand } from "./notes-folders.js";
46
46
  import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
47
+ import { showBotUpcoming } from "./notes-bot-upcoming.js";
47
48
  import { showGranolaStatus } from "./notes-granola-status.js";
48
49
  import { listRecordings, showRecording } from "./notes-recordings.js";
49
50
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
@@ -106,6 +107,8 @@ export async function runNotes(command, io) {
106
107
  return showRecording(command, door);
107
108
  case "webhook":
108
109
  return armNotesWebhook(command, door);
110
+ case "bot-upcoming":
111
+ return showBotUpcoming(command, door);
109
112
  case "granola-status":
110
113
  return showGranolaStatus(command, door);
111
114
  }
@@ -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.199");
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.199",
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.111",
32
32
  "@bli-cockpit/telemetry-core": "0.1.69"
33
33
  },
34
34
  "repository": {