@bli-cockpit/cli 0.2.180 → 0.2.184

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.
@@ -45,10 +45,12 @@ import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-
45
45
  import { runFolderCommand } from "./notes-folders.js";
46
46
  import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
47
47
  import { showGranolaStatus } from "./notes-granola-status.js";
48
+ import { listRecordings, showRecording } from "./notes-recordings.js";
48
49
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
49
50
  export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
50
51
  export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
51
52
  export { showGranolaStatus } from "./notes-granola-status.js";
53
+ export { listRecordings, showRecording } from "./notes-recordings.js";
52
54
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
53
55
  export async function runNotes(command, io) {
54
56
  const session = await loadPairedSession("notes", command.homeDir);
@@ -98,6 +100,10 @@ export async function runNotes(command, io) {
98
100
  return syncNotetaker(command, door);
99
101
  case "sync-status":
100
102
  return syncStatusNotetakers(command, door);
103
+ case "recordings":
104
+ return listRecordings(command, door);
105
+ case "recording":
106
+ return showRecording(command, door);
101
107
  case "webhook":
102
108
  return armNotesWebhook(command, door);
103
109
  case "granola-status":
@@ -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.180");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.184");
25
25
  return 0;
26
26
  }
27
27
 
@@ -0,0 +1,39 @@
1
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
2
+ import { writeLine } from "./cli-io.js";
3
+ export async function runSetup(command, io) {
4
+ const door = await openAgentDoor("setup", command, io);
5
+ const query = new URLSearchParams();
6
+ if (command.person)
7
+ query.set("person", command.person);
8
+ if (command.compare)
9
+ query.set("compare", command.compare);
10
+ const answer = await askAgentDoor(door, { method: "GET", path: `/api/setup?${query}`, label: "setup", timeoutMs: 30_000 });
11
+ if (!answer.ok)
12
+ return failAgentDoor(door, "[setup]", answer.reason, answer.detail);
13
+ if (door.json)
14
+ return emitAgentDoor(door, answer.body);
15
+ const body = answer.body;
16
+ const number = (value) => value === null ? "unknown" : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
17
+ for (const person of body.people ?? []) {
18
+ writeLine(io.stdout, `${person.rank ? `#${person.rank} ` : "Unranked · "}${person.display_name ?? person.email ?? "Unknown person"}: ${number(person.prs_per_week)} merged PRs/week · ${number(person.tokens_per_pr)} tokens/merged PR · ${person.yield === null ? "unknown yield" : `${number(person.yield * 100)}% session yield`}`);
19
+ if (!person.machines.length)
20
+ writeLine(io.stdout, " No setup received.");
21
+ for (const machine of person.machines) {
22
+ if (!machine.setup) {
23
+ writeLine(io.stdout, " Setup is unknown.");
24
+ continue;
25
+ }
26
+ writeLine(io.stdout, ` ${machine.setup.os} · TOWER CLI ${machine.setup.cli_version}${machine.stale ? " · stale setup" : ""}`);
27
+ for (const tool of ["claude", "codex"])
28
+ writeLine(io.stdout, ` ${tool}: ${machine.setup[tool].model ?? "unknown model"}, ${machine.setup[tool].effort ?? "unknown effort"}`);
29
+ }
30
+ }
31
+ for (const pair of body.comparison ?? []) {
32
+ writeLine(io.stdout, `${pair.first ?? "Unknown person"} / ${pair.second ?? "Unknown person"}`);
33
+ for (const difference of pair.differences)
34
+ writeLine(io.stdout, ` ${difference}`);
35
+ }
36
+ if (body.note)
37
+ writeLine(io.stdout, body.note);
38
+ return 0;
39
+ }
@@ -0,0 +1,13 @@
1
+ /** Only a doctor request is accepted. Nothing from Tower is ever a command line. */
2
+ let requestId = null;
3
+ export function noteDoctorRequest(value) {
4
+ if (!value || typeof value !== "object")
5
+ return;
6
+ const request = value;
7
+ if (request.action === "doctor" && typeof request.request_id === "string" && /^[0-9a-f-]{36}$/u.test(request.request_id)) {
8
+ requestId = request.request_id;
9
+ console.error("[sync-doctor] request received", JSON.stringify({ reason: "doctor_requested", request_id: requestId }));
10
+ }
11
+ }
12
+ export function requestedDoctorId() { return requestId; }
13
+ export function clearDoctorRequest() { requestId = null; }
@@ -0,0 +1,145 @@
1
+ /** Full, non-interactive doctor after collection, with a durable receipt retry. */
2
+ import { shouldSuppressFleetReceipts } from "../dev-build.js";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { Writable } from "node:stream";
6
+ import { acquireBackfillLock } from "../backfill-lock.js";
7
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile } from "../local-state.js";
8
+ import { isAutostartOptedOut } from "../autostart-opt-out.js";
9
+ import { runAutostartSelfHealAfterSync } from "./sync-followups-autostart.js";
10
+ import { checkAutostartState } from "./doctor-registration.js";
11
+ import { heartbeatRefusalStatus } from "./heartbeat-token.js";
12
+ import { runDoctor } from "./doctor.js";
13
+ import { doctorNeedsPerson, sanitizeEventCode } from "./doctor-report.js";
14
+ import { runSelfUpdate } from "./install-update.js";
15
+ import { reportInstallEventsBestEffort } from "./install-receipts.js";
16
+ import { clearDoctorRequest, requestedDoctorId } from "./sync-doctor-request.js";
17
+ export async function runDoctorAfterSync(command, io, dashboardUrl, overrides = {}, facts) {
18
+ if (shouldSuppressFleetReceipts({ dashboardUrl }).suppressed) {
19
+ console.error("[sync-doctor] checkout does not repair the fleet machine", JSON.stringify({ reason: "receipt_suppressed:dev_build" }));
20
+ return;
21
+ }
22
+ const paths = getCollectorRuntimePaths(command.homeDir);
23
+ // A separate namespace: holding this lock must not block doctor's backfill.
24
+ const lock = await acquireBackfillLock({ ...paths, cursors_dir: path.join(paths.state_dir, "doctor") }).catch(() => {
25
+ console.error("[sync-doctor] could not acquire its lock", JSON.stringify({ reason: "doctor_lock_failed" }));
26
+ return null;
27
+ });
28
+ if (!lock)
29
+ return;
30
+ if (!lock.acquired) {
31
+ console.error("[sync-doctor] another doctor is running", JSON.stringify({ reason: "doctor_running" }));
32
+ return;
33
+ }
34
+ const outbox = path.join(paths.state_dir, "doctor-receipt.json");
35
+ try {
36
+ const pending = await fs.readFile(outbox, "utf8").then((text) => JSON.parse(text)).catch((error) => {
37
+ if (error.code === "ENOENT")
38
+ return null;
39
+ throw error;
40
+ });
41
+ if (pending) {
42
+ if (!await postReceipt(pending, command, io, dashboardUrl))
43
+ return;
44
+ await fs.rm(outbox, { force: true });
45
+ if (pending.request_id === requestedDoctorId())
46
+ clearDoctorRequest();
47
+ // The completed requested run is never repeated just to resend its receipt.
48
+ if (pending.request_id)
49
+ return;
50
+ }
51
+ const requestId = requestedDoctorId();
52
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
53
+ let receipt = null;
54
+ const quiet = new Writable({ write(_chunk, _encoding, done) { done(); } });
55
+ const doctorIo = { ...io, stdout: quiet };
56
+ await runDoctor({ kind: "doctor", alias: "doctor", automaticTick: true, homeDir: command.homeDir, dashboardUrl, json: true, dryRun: false }, doctorIo, {
57
+ selfUpdate: (updateIo, options) => runSelfUpdate({ ...updateIo, stdout: quiet }, options),
58
+ runLogin: async () => { throw new Error("automatic_doctor_cannot_prompt"); },
59
+ resolveAndSaveRoots: async () => { throw new Error("automatic_doctor_cannot_pick_folders"); },
60
+ reportInstallEvents: (options) => reportInstallEventsBestEffort({ ...options, homeDir: command.homeDir,
61
+ // Automatic receipts carry labels, never terminal instructions or paths.
62
+ events: options.events.map((event) => ({ step: event.step, status: event.status, ...(event.error_code ? { error_code: event.error_code } : {}) })),
63
+ }),
64
+ onReceipt: (rows, repairs, setup) => {
65
+ const actions = new Set();
66
+ for (const item of doctorNeedsPerson(rows, setup)) {
67
+ if (item.step === "authed" && (!session?.device_token || [401, 403].includes(heartbeatRefusalStatus() ?? 0)))
68
+ actions.add("browser_sign_in");
69
+ if (item.step === "roots-ok")
70
+ actions.add("folder_selection");
71
+ if (item.step === "single-install")
72
+ actions.add("duplicate_install");
73
+ if (item.reason === "needs_trust" || item.action?.includes("/hooks"))
74
+ actions.add("hook_trust");
75
+ if (/permission|eacces|eperm|access_denied/u.test(item.reason))
76
+ actions.add("os_permission");
77
+ }
78
+ receipt = {
79
+ request_id: requestId, completed_at: new Date().toISOString(),
80
+ status: rows.some((row) => row.status === "fail" || row.status === "needs_fix") || actions.size ? "attention" : "pass",
81
+ repairs: repairs.map((repair) => ({ step: repair.step, before: safeVerdict(repair.before), after: safeVerdict(repair.after), fix_attempted: repair.fix_attempted ?? false })),
82
+ person_actions: [...actions],
83
+ };
84
+ },
85
+ }, {
86
+ // The collection phase already ran. Never recurse through another sync.
87
+ checkSync: async () => ({ id: "sync-fresh", status: facts?.status === "fail" ? "needs_fix" : "skipped", code: facts?.status === "fail" ? "tick_collection_failed" : "tick_collection_reported", message: "This tick already reported its collection outcome; it retries on the next tick." }),
88
+ fixSync: async (context) => context.deps.checkSync(context),
89
+ checkAutostart: async (context) => await isAutostartOptedOut(paths)
90
+ ? { id: "autostart-alive", status: "skipped", code: "operator_uninstalled", message: "Background sync was deliberately turned off." }
91
+ : checkAutostartState(context),
92
+ fixAutostart: async (context) => {
93
+ // macOS uses the detached healer; Windows repairs in place. Neither
94
+ // overrides an explicit opt-out nor unloads this tick's macOS job.
95
+ await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
96
+ return checkAutostartState(context);
97
+ },
98
+ ...overrides,
99
+ });
100
+ if (!receipt)
101
+ throw new Error("doctor_receipt_missing");
102
+ await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
103
+ await fs.writeFile(`${outbox}.tmp`, JSON.stringify(receipt), { mode: 0o600 });
104
+ await fs.rename(`${outbox}.tmp`, outbox);
105
+ console.error("[sync-doctor] run complete", JSON.stringify({ reason: "receipt_saved", request_id: receipt.request_id, rows: receipt.repairs.length }));
106
+ if (await postReceipt(receipt, command, io, dashboardUrl)) {
107
+ await fs.rm(outbox, { force: true });
108
+ if (requestId === requestedDoctorId())
109
+ clearDoctorRequest();
110
+ }
111
+ }
112
+ catch (error) {
113
+ console.error("[sync-doctor] run will retry next tick", JSON.stringify({ reason: "doctor_followup_failed", error_name: error instanceof Error ? error.name : typeof error }));
114
+ }
115
+ finally {
116
+ await lock.handle.release();
117
+ }
118
+ }
119
+ function safeVerdict(value) {
120
+ const [status, code] = (value ?? "fail:receipt_missing").split(":");
121
+ return `${status}:${sanitizeEventCode(code ?? "receipt_missing")}`;
122
+ }
123
+ async function postReceipt(receipt, command, io, dashboardUrl) {
124
+ if (shouldSuppressFleetReceipts({ dashboardUrl }).suppressed) {
125
+ console.error("[sync-doctor] checkout receipt retained locally", JSON.stringify({ reason: "receipt_suppressed:dev_build" }));
126
+ return false;
127
+ }
128
+ const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(command.homeDir));
129
+ if (!session?.device_token || !session.device_id) {
130
+ console.error("[sync-doctor] receipt retained", JSON.stringify({ reason: "device_session_missing" }));
131
+ return false;
132
+ }
133
+ try {
134
+ const response = await io.fetch(`${dashboardUrl}/api/ambient/devices/${encodeURIComponent(session.device_id)}/doctor`, {
135
+ method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.device_token}` },
136
+ body: JSON.stringify(receipt), signal: AbortSignal.timeout(5_000),
137
+ });
138
+ console.error("[sync-doctor] receipt delivery", JSON.stringify({ reason: response.ok ? "receipt_recorded" : "receipt_rejected", http_status: response.status, request_id: receipt.request_id }));
139
+ return response.ok;
140
+ }
141
+ catch {
142
+ console.error("[sync-doctor] receipt retained", JSON.stringify({ reason: "receipt_transport_failed", request_id: receipt.request_id }));
143
+ return false;
144
+ }
145
+ }
@@ -103,7 +103,7 @@ async function runSyncTick(command, io, tick) {
103
103
  // so it is what separates a quiet machine from a dead one.
104
104
  await tick.heartbeat(() => sendSyncHeartbeat(command, io, dashboardUrl, result.heartbeat));
105
105
  await tick.outcomeReport(() => reportTickOutcome(command, io, dashboardUrl, result.completion));
106
- await tick.converge(() => convergeAfterTick(command, io, dashboardUrl));
106
+ await tick.converge(() => convergeAfterTick(command, io, dashboardUrl, result.heartbeat));
107
107
  // BLI-3619 / BLI-11106: every tick, the staged copies of whatever the
108
108
  // server just committed go; a follow-up never blocks or fails collection.
109
109
  await tick.prune(() => runStagingPruneAfterSync(command, io, dashboardUrl));
@@ -119,7 +119,7 @@ async function runSyncTick(command, io, tick) {
119
119
  reason: errorCode,
120
120
  }));
121
121
  await tick.outcomeReport(() => reportTickThrew(command, io, dashboardUrl, error, errorCode));
122
- await tick.converge(() => convergeAfterTick(command, io, dashboardUrl));
122
+ await tick.converge(() => convergeAfterTick(command, io, dashboardUrl, { status: "fail", reason: errorCode }));
123
123
  await tick.prune(() => runStagingPruneAfterSync(command, io, dashboardUrl));
124
124
  throw error;
125
125
  }
@@ -186,11 +186,12 @@ async function reportTickThrew(command, io, dashboardUrl, error, errorCode) {
186
186
  * Memory's registration — each at most once a day, each with its own receipt,
187
187
  * and none of them able to block or fail collection (BLI-3580).
188
188
  */
189
- async function convergeAfterTick(command, io, dashboardUrl) {
189
+ async function convergeAfterTick(command, io, dashboardUrl, facts) {
190
190
  await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
191
191
  await runMemoryInstallAfterSync(command, io, dashboardUrl);
192
192
  // BLI-12828: the repairs `cockpit doctor` also makes (agent rules, a lapsed
193
193
  // sign-in), so nobody has to be told to run it.
194
194
  await runMachineHealAfterSync(command, io, dashboardUrl);
195
+ await io.runDoctorAfterSync?.(command, io, dashboardUrl, {}, facts);
195
196
  await runMemoryExperienceAfterSync({ ...command, dashboardUrl }, io);
196
197
  }
@@ -31,6 +31,8 @@ export async function runTeam(command, io) {
31
31
  return changeRole(command, tower, io);
32
32
  if (command.action === "device-list")
33
33
  return deviceList(command, tower, io);
34
+ if (command.action === "device-doctor")
35
+ return deviceDoctor(command, tower, io);
34
36
  return deviceRevoke(command, tower, io);
35
37
  }
36
38
  async function showMembers(command, tower, io) {
@@ -188,4 +190,21 @@ async function deviceRevoke(command, tower, io) {
188
190
  }
189
191
  writeLine(io.stdout, renderDeviceRevoked(body));
190
192
  return 0;
193
+ }
194
+ async function deviceDoctor(command, tower, io) {
195
+ const listed = await callTower(tower, { path: "/api/team/devices", label: "team device doctor lookup" });
196
+ if (!listed.ok)
197
+ return writeCommandFailure(io, command.json, listed);
198
+ const device = findDevice(devicesOf(asRecord(listed.body)), command.deviceRef ?? "");
199
+ if (!device)
200
+ return writeCommandFailure(io, command.json, { reason: "device_not_found", detail: "No unique device matches. Run cockpit team device list to see ids and names." });
201
+ const result = await callTower(tower, {
202
+ path: `/api/ambient/devices/${encodeURIComponent(device.device_id)}/doctor`,
203
+ method: "POST", label: "team device doctor", body: { action: "doctor" },
204
+ });
205
+ console.error("[team cli] device doctor", JSON.stringify({ device_id: device.device_id, reason: result.ok ? "requested" : result.reason }));
206
+ if (!result.ok)
207
+ return writeCommandFailure(io, command.json, result);
208
+ writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, ...asRecord(result.body) }) : `Doctor requested for ${device.device_name ?? device.device_id}. Waiting for its next check-in.`);
209
+ return 0;
191
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.180",
3
+ "version": "0.2.184",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.53",
31
- "@bli-cockpit/mcp": "0.1.95",
32
- "@bli-cockpit/telemetry-core": "0.1.65"
30
+ "@bli-cockpit/memory-mcp": "0.1.55",
31
+ "@bli-cockpit/mcp": "0.1.98",
32
+ "@bli-cockpit/telemetry-core": "0.1.66"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",