@bli-cockpit/cli 0.2.58 → 0.2.60
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.
- package/README.md +1 -1
- package/dist/commands/browser-open.js +88 -0
- package/dist/commands/docs.js +32 -6
- package/dist/commands/doctor-report.js +17 -1
- package/dist/commands/doctor.js +12 -2
- package/dist/commands/heartbeat.js +65 -1
- package/dist/commands/jarvis-answer-envelope.js +82 -0
- package/dist/commands/jarvis-render.js +18 -0
- package/dist/commands/jarvis-turn.js +29 -2
- package/dist/commands/jarvis.js +3 -0
- package/dist/commands/local-args-collector-setup.js +17 -0
- package/dist/commands/local-args-tower-admin.js +12 -2
- package/dist/commands/local-args-tower-chat.js +5 -0
- package/dist/commands/local-args-tower-docs-msg.js +105 -6
- package/dist/commands/local-args-tower-search.js +50 -0
- package/dist/commands/local-args-tower.js +4 -1
- package/dist/commands/local-args.js +28 -13
- package/dist/commands/local-command-shapes.js +12 -0
- package/dist/commands/local-help-commands.js +655 -0
- package/dist/commands/local-help.js +11 -582
- package/dist/commands/local.js +3 -0
- package/dist/commands/login.js +91 -8
- package/dist/commands/memory-install-claude.js +35 -15
- package/dist/commands/memory-install-codex-hooks.js +200 -0
- package/dist/commands/memory-install-codex.js +12 -2
- package/dist/commands/memory-install-receipt.js +222 -0
- package/dist/commands/memory-install-report.js +25 -1
- package/dist/commands/memory-install.js +76 -2
- package/dist/commands/msg.js +85 -2
- package/dist/commands/onboard-completion.js +47 -0
- package/dist/commands/onboard-setup.js +82 -2
- package/dist/commands/ops-render-memory.js +76 -0
- package/dist/commands/ops-render.js +6 -0
- package/dist/commands/ops.js +59 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/search.js +122 -0
- package/dist/commands/setup-receipt-lines.js +71 -0
- package/dist/commands/setup-receipt.js +241 -0
- package/dist/commands/status.js +20 -1
- package/dist/commands/tower-mcp-install.js +4 -2
- package/dist/local-state-pairing-code.js +200 -0
- package/dist/local-state.js +6 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ normal user, on both macOS and Windows.
|
|
|
65
65
|
| Command | What it does | Why it exists / why this name |
|
|
66
66
|
|---|---|---|
|
|
67
67
|
| `cockpit onboard` | The named setup subset: signs you in (email code), registers this machine, starts capture, uploads once, completes all-history Codex and Claude backfill for saved approved roots, installs the 15-min background sync, and only then prints proof you're live. | You are boarding the crew. `do-everything` calls this flow when setup is missing; rerunning it directly is safe. Partial or failed backfill blocks readiness with an exact retry reason. |
|
|
68
|
-
| `cockpit do-everything` / `cockpit fix` | Converges a Mac or Windows PC from blank or already-onboarded state: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. Interactive first runs prompt for email OTP and collection roots; headless/`--json` runs explain and exit instead of blocking. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
|
|
68
|
+
| `cockpit do-everything` / `cockpit fix` / `cockpit doctor` | Converges a Mac or Windows PC from blank or already-onboarded state: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. Interactive first runs prompt for email OTP and collection roots; headless/`--json` runs explain and exit instead of blocking. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess; `doctor` is the alias the ops board, the setup receipt and the heartbeat all tell you to run, and it is the same command (it checks each row, then fixes it). |
|
|
69
69
|
| `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
|
|
70
70
|
| `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this machine). Discovery defaults to depth 3 and 50 repos; raise `--max-depth` or `--max-repos` when the returned retry command says a cap was reached. | One-time catch-up so your past work counts too. "Backfill" = fill in the back-catalog. |
|
|
71
71
|
| `cockpit sync` | Captures and uploads once, right now. This is what the background agent runs every 15 min — you almost never type it yourself. | Named for what it does: synchronize local session files up to the dashboard. |
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a URL in the person's own browser (BLI-3731).
|
|
3
|
+
*
|
|
4
|
+
* One sign-in means the terminal hands the browser a link. That is the whole
|
|
5
|
+
* job here, and it follows the same discipline `commands/editor.ts` wrote down
|
|
6
|
+
* for spawning a program on somebody's behalf:
|
|
7
|
+
*
|
|
8
|
+
* 1. **`spawnSync` with an ARRAY of arguments and `shell: false`.** Nothing is
|
|
9
|
+
* interpolated into a command line, ever.
|
|
10
|
+
* 2. **Windows does NOT go through `cmd /c start`.** `start` is a cmd builtin,
|
|
11
|
+
* so reaching it means handing cmd.exe a string it re-parses — and `&` in a
|
|
12
|
+
* query string is a command separator there. `rundll32 url.dll,
|
|
13
|
+
* FileProtocolHandler <url>` takes the URL as one argv and never sees a
|
|
14
|
+
* shell. (Our own URL has no `&` today; the point is that it cannot ever
|
|
15
|
+
* matter.)
|
|
16
|
+
* 3. **The URL is validated before it is spawned.** Only `https:` and a
|
|
17
|
+
* loopback `http:` are opened, so a bad dashboard URL cannot turn into a
|
|
18
|
+
* `file:` or a custom protocol handler.
|
|
19
|
+
*
|
|
20
|
+
* Opening is BEST EFFORT and never fatal: the caller has already printed the
|
|
21
|
+
* link, so a machine with no browser — an SSH session, a locked-down Windows
|
|
22
|
+
* host — loses nothing but a convenience. Every branch returns a reason label,
|
|
23
|
+
* success included, and the caller logs it.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
/** `https:` anywhere, `http:` only on loopback — a dev dashboard is the reason. */
|
|
27
|
+
export function isOpenableUrl(url) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(url);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol === "https:")
|
|
36
|
+
return true;
|
|
37
|
+
return (parsed.protocol === "http:" &&
|
|
38
|
+
(parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"));
|
|
39
|
+
}
|
|
40
|
+
export function openInBrowser(url, options = {}) {
|
|
41
|
+
if (!isOpenableUrl(url)) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: "url_not_openable",
|
|
45
|
+
detail: "Only https:// (or http:// on localhost) links are opened.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const platform = options.platform ?? process.platform;
|
|
49
|
+
const spawnImpl = options.spawn ?? spawnSync;
|
|
50
|
+
const opener = openerFor(platform, url);
|
|
51
|
+
if (!opener) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: "no_opener_for_platform",
|
|
55
|
+
detail: `No known browser opener for ${platform}; open the link by hand.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const result = spawnImpl(opener.program, opener.args, {
|
|
59
|
+
stdio: "ignore",
|
|
60
|
+
shell: false,
|
|
61
|
+
});
|
|
62
|
+
if (result.error) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: "opener_not_started",
|
|
66
|
+
detail: `${opener.program} could not be started; open the link by hand.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: "opener_exited_nonzero",
|
|
73
|
+
detail: `${opener.program} exited ${result.status}; open the link by hand.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, reason: "launched", program: opener.program };
|
|
77
|
+
}
|
|
78
|
+
function openerFor(platform, url) {
|
|
79
|
+
if (platform === "darwin")
|
|
80
|
+
return { program: "open", args: [url] };
|
|
81
|
+
if (platform === "win32") {
|
|
82
|
+
// See rule 2 in the header: never `cmd /c start`.
|
|
83
|
+
return { program: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
84
|
+
}
|
|
85
|
+
if (platform === "linux")
|
|
86
|
+
return { program: "xdg-open", args: [url] };
|
|
87
|
+
return null;
|
|
88
|
+
}
|
package/dist/commands/docs.js
CHANGED
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
* /api/docs/documents` already returns — the same read `docs list` prints,
|
|
15
15
|
* never a second query the server does not already answer.
|
|
16
16
|
*
|
|
17
|
+
* THE LIST IS METADATA (BLI-3737). It carries id, slug, title, visibility,
|
|
18
|
+
* parent, author, updated_at and `body_chars` — never a body. On 2026-09-05
|
|
19
|
+
* `cockpit docs list --json` answered 603,779 bytes for 155 documents,
|
|
20
|
+
* because the door's select still carried `body_markdown`; an agent had to
|
|
21
|
+
* spend the whole library to learn 155 titles. `--parent`, `--query` and
|
|
22
|
+
* `--limit` ride to the door as query parameters, so the narrowing happens in
|
|
23
|
+
* the database rather than here.
|
|
24
|
+
*
|
|
17
25
|
* A refusal keeps the door's own `reason` label verbatim (`agent-door.ts`):
|
|
18
26
|
* `needs_rls_client`, `document_not_found_or_unreadable`,
|
|
19
27
|
* `document_not_writable`, `title_too_long`, `content_too_long`,
|
|
@@ -30,7 +38,7 @@ export async function runDocs(command, io) {
|
|
|
30
38
|
const door = await openAgentDoor("docs", command, io);
|
|
31
39
|
switch (command.action) {
|
|
32
40
|
case "list":
|
|
33
|
-
return listDocs(door);
|
|
41
|
+
return listDocs(command, door);
|
|
34
42
|
case "tree":
|
|
35
43
|
return treeDocs(door);
|
|
36
44
|
case "read":
|
|
@@ -41,9 +49,21 @@ export async function runDocs(command, io) {
|
|
|
41
49
|
return updateDoc(command, door);
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
|
-
|
|
52
|
+
/** `--parent`, `--query`, `--limit` as the door's own query parameters (BLI-3737). */
|
|
53
|
+
export function docsListPath(command) {
|
|
54
|
+
const params = new URLSearchParams();
|
|
55
|
+
if (command.parentId)
|
|
56
|
+
params.set("parent_id", command.parentId);
|
|
57
|
+
if (command.query)
|
|
58
|
+
params.set("query", command.query);
|
|
59
|
+
if (command.limit !== undefined)
|
|
60
|
+
params.set("limit", String(command.limit));
|
|
61
|
+
const search = params.toString();
|
|
62
|
+
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
63
|
+
}
|
|
64
|
+
async function listDocs(command, door) {
|
|
45
65
|
const answer = await askAgentDoor(door, {
|
|
46
|
-
path:
|
|
66
|
+
path: docsListPath(command),
|
|
47
67
|
method: "GET",
|
|
48
68
|
label: "docs list",
|
|
49
69
|
timeoutMs: READ_DEADLINE_MS,
|
|
@@ -54,14 +74,15 @@ async function listDocs(door) {
|
|
|
54
74
|
if (door.json)
|
|
55
75
|
return emitAgentDoor(door, { ok: true, documents });
|
|
56
76
|
if (documents.length === 0) {
|
|
57
|
-
writeLine(door.io.stdout, "No documents.");
|
|
77
|
+
writeLine(door.io.stdout, command.query ? `No document matches "${command.query}".` : "No documents.");
|
|
58
78
|
return 0;
|
|
59
79
|
}
|
|
60
80
|
for (const doc of documents) {
|
|
61
|
-
|
|
81
|
+
const size = typeof doc.body_chars === "number" ? `${doc.body_chars}c`.padStart(8) : " ?";
|
|
82
|
+
writeLine(door.io.stdout, `${doc.id} ${doc.visibility.padEnd(7)} ${size} ${doc.slug ?? "(no slug)"} ${doc.title}`);
|
|
62
83
|
}
|
|
63
84
|
writeLine(door.io.stdout, "");
|
|
64
|
-
writeLine(door.io.stdout, `${documents.length} document(s)
|
|
85
|
+
writeLine(door.io.stdout, `${documents.length} document(s). Bodies come from \`cockpit docs read <id|slug>\`.`);
|
|
65
86
|
return 0;
|
|
66
87
|
}
|
|
67
88
|
function renderTree(nodes, io, depth) {
|
|
@@ -214,6 +235,11 @@ async function updateDoc(command, door) {
|
|
|
214
235
|
...(bodyMarkdown !== undefined ? { body_markdown: bodyMarkdown } : {}),
|
|
215
236
|
...(command.visibility !== undefined ? { visibility: command.visibility } : {}),
|
|
216
237
|
...(command.clearParent ? { parent_id: null } : command.parentId ? { parent_id: command.parentId } : {}),
|
|
238
|
+
// BLI-3759: only ever sent when the operator asked for it. Without the
|
|
239
|
+
// key, a body that would empty a document holding text comes back as the
|
|
240
|
+
// door's own `refused_empty_body` (409), which `failAgentDoor` prints
|
|
241
|
+
// verbatim — the CLI never rephrases it and never retries with the flag.
|
|
242
|
+
...(command.allowEmpty ? { allow_empty: true } : {}),
|
|
217
243
|
},
|
|
218
244
|
});
|
|
219
245
|
if (!answer.ok)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { redactedHealthDetail } from "../health-detail.js";
|
|
2
|
+
import { setupReceiptBlock } from "./setup-receipt-lines.js";
|
|
2
3
|
export function ok(id, code, message) {
|
|
3
4
|
return { id, status: "ok", code, message };
|
|
4
5
|
}
|
|
@@ -37,7 +38,9 @@ export async function maybeReportDoctorEvents(context, rows) {
|
|
|
37
38
|
io: context.io,
|
|
38
39
|
});
|
|
39
40
|
}
|
|
40
|
-
export function writeDoctorOutput(command, io, rows
|
|
41
|
+
export function writeDoctorOutput(command, io, rows,
|
|
42
|
+
/** BLI-3731. Absent means it could not be read; doctor says so rather than nothing. */
|
|
43
|
+
setupReceipt) {
|
|
41
44
|
if (command.json) {
|
|
42
45
|
writeLine(io.stdout, JSON.stringify({
|
|
43
46
|
status: rows.some((row) => row.status === "fail" || row.hardStop)
|
|
@@ -45,6 +48,8 @@ export function writeDoctorOutput(command, io, rows) {
|
|
|
45
48
|
: "pass",
|
|
46
49
|
dry_run: command.dryRun,
|
|
47
50
|
steps: rows,
|
|
51
|
+
setup_receipt: setupReceipt?.receipt ?? null,
|
|
52
|
+
memory_install: setupReceipt?.memory ?? null,
|
|
48
53
|
}, null, 2));
|
|
49
54
|
return;
|
|
50
55
|
}
|
|
@@ -61,6 +66,17 @@ export function writeDoctorOutput(command, io, rows) {
|
|
|
61
66
|
writeLine(io.stderr, `${row.id}:`);
|
|
62
67
|
writeLine(io.stderr, row.message);
|
|
63
68
|
}
|
|
69
|
+
// BLI-3731. The invariant table says whether collection is healthy; this
|
|
70
|
+
// says whether the machine is CONNECTED — the browser sign-in, the device
|
|
71
|
+
// session, both agent hosts and the scheduler, every word read back off this
|
|
72
|
+
// host. Printed last because it is what a person came to check on day one.
|
|
73
|
+
writeLine(io.stdout, "");
|
|
74
|
+
writeLine(io.stdout, "Connected:");
|
|
75
|
+
for (const line of setupReceipt
|
|
76
|
+
? setupReceiptBlock(setupReceipt, { indent: " " })
|
|
77
|
+
: [" unknown — this machine could not be read this run."]) {
|
|
78
|
+
writeLine(io.stdout, line);
|
|
79
|
+
}
|
|
64
80
|
}
|
|
65
81
|
function doctorEvent(row) {
|
|
66
82
|
const status = row.status === "fail" || row.hardStop
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, rea
|
|
|
2
2
|
import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
|
|
3
3
|
import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
4
4
|
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
|
|
5
|
+
import { refreshSetupReceipt } from "./setup-receipt.js";
|
|
5
6
|
import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
|
|
6
7
|
export async function runDoctor(command, io, hooks, overrides = {}) {
|
|
7
8
|
const deps = { ...defaultDoctorDeps(hooks), ...overrides };
|
|
@@ -40,12 +41,15 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
40
41
|
break;
|
|
41
42
|
if (fixed.reexecExitCode !== undefined) {
|
|
42
43
|
await maybeReportDoctorEvents(context, rows);
|
|
43
|
-
writeDoctorOutput(command, io, rows);
|
|
44
|
+
writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
|
|
44
45
|
return fixed.reexecExitCode;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
await maybeReportDoctorEvents(context, rows);
|
|
48
|
-
|
|
49
|
+
// Doctor is the surface a person opens when something is wrong, so it
|
|
50
|
+
// re-READS the machine rather than quoting a cache — and caching what it
|
|
51
|
+
// read is what keeps the 15-minute heartbeat from paying for the probe.
|
|
52
|
+
writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
|
|
49
53
|
return rows.some((row) => row.status === "fail" || row.hardStop) ? 1 : 0;
|
|
50
54
|
}
|
|
51
55
|
function doctorInvariants() {
|
|
@@ -129,6 +133,12 @@ function defaultDoctorDeps(hooks) {
|
|
|
129
133
|
fixDisk: fixDiskState,
|
|
130
134
|
checkSync: checkSyncState,
|
|
131
135
|
fixSync: fixSyncState,
|
|
136
|
+
readSetupReceipt: (context) => refreshSetupReceipt(context.io, {
|
|
137
|
+
...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
|
|
138
|
+
...(context.command.dashboardUrl
|
|
139
|
+
? { dashboardUrl: context.command.dashboardUrl }
|
|
140
|
+
: {}),
|
|
141
|
+
}),
|
|
132
142
|
};
|
|
133
143
|
}
|
|
134
144
|
// Re-exported so every consumer keeps importing from `./doctor.js` regardless
|
|
@@ -23,10 +23,12 @@ import crypto from "node:crypto";
|
|
|
23
23
|
import fs from "node:fs";
|
|
24
24
|
import os from "node:os";
|
|
25
25
|
import path from "node:path";
|
|
26
|
-
import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, } from "@bli-cockpit/telemetry-core";
|
|
26
|
+
import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, memoryInstallGaps, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
|
|
27
|
+
import { readCachedSetupReceipt } from "./setup-receipt.js";
|
|
27
28
|
import { describeError } from "../health-detail.js";
|
|
28
29
|
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
29
30
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
31
|
+
import { readMemoryReceiptFile } from "./memory-install-receipt.js";
|
|
30
32
|
const HEARTBEAT_TIMEOUT_MS = 5_000;
|
|
31
33
|
/**
|
|
32
34
|
* The label form of one approved root.
|
|
@@ -108,8 +110,40 @@ export function buildCollectorHeartbeat(options) {
|
|
|
108
110
|
...(typeof options.facts.sessionsPendingUpload === "number"
|
|
109
111
|
? { sessions_pending_upload: options.facts.sessionsPendingUpload }
|
|
110
112
|
: {}),
|
|
113
|
+
...(options.memoryInstall ? { memory_install: options.memoryInstall } : {}),
|
|
114
|
+
...(options.setupReceipt ? { setup_receipt: options.setupReceipt } : {}),
|
|
111
115
|
};
|
|
112
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The cached memory receipt for this tick, or null with a named reason
|
|
119
|
+
* (BLI-3729).
|
|
120
|
+
*
|
|
121
|
+
* Reads the file `cockpit memory install` / `cockpit memory status` left
|
|
122
|
+
* behind; it does NOT re-inspect the host configs, which would cost five file
|
|
123
|
+
* reads and a `--print-config` spawn every fifteen minutes to re-prove a state
|
|
124
|
+
* that changes about once a month. The reason is returned rather than logged
|
|
125
|
+
* here so the ONE heartbeat log line carries it.
|
|
126
|
+
*/
|
|
127
|
+
export async function readHeartbeatMemoryReceipt(options) {
|
|
128
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
129
|
+
const result = await readMemoryReceiptFile({
|
|
130
|
+
stateDir: paths.state_dir,
|
|
131
|
+
readText: async (file) => {
|
|
132
|
+
try {
|
|
133
|
+
return await fs.promises.readFile(file, "utf8");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
...(options.now ? { now: options.now } : {}),
|
|
140
|
+
parse: (value) => {
|
|
141
|
+
const parsed = MemoryInstallReceiptSchema.safeParse(value);
|
|
142
|
+
return parsed.success ? parsed.data : null;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
return { receipt: result.receipt, reason: result.reason };
|
|
146
|
+
}
|
|
113
147
|
/**
|
|
114
148
|
* Sends the heartbeat. Returns whether it landed; never throws.
|
|
115
149
|
*
|
|
@@ -148,10 +182,34 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
148
182
|
}));
|
|
149
183
|
return false;
|
|
150
184
|
}
|
|
185
|
+
const memory = await readHeartbeatMemoryReceipt({
|
|
186
|
+
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
187
|
+
...(options.now ? { now: options.now } : {}),
|
|
188
|
+
}).catch(() => ({ receipt: null, reason: "receipt_read_threw" }));
|
|
189
|
+
// BLI-3731. Read the CACHE, never re-probe: computing the receipt costs five
|
|
190
|
+
// file reads and a `--print-config` spawn, and these facts change about
|
|
191
|
+
// monthly. `cockpit memory install`, `onboard` and `doctor` refresh it; a
|
|
192
|
+
// tick only carries it. Both branches say something, because "the ops board
|
|
193
|
+
// shows no receipt for this machine" has two very different causes.
|
|
194
|
+
const setupReceipt = await readCachedSetupReceipt(options.homeDir, options.now ?? new Date());
|
|
195
|
+
console.error(setupReceipt
|
|
196
|
+
? "[heartbeat] carrying this machine's setup receipt"
|
|
197
|
+
: "[heartbeat] no usable setup receipt to carry; the ops board keeps the last one it has", JSON.stringify(setupReceipt
|
|
198
|
+
? {
|
|
199
|
+
reason: "setup_receipt_cached",
|
|
200
|
+
gap_count: setupReceiptGaps(setupReceipt).length,
|
|
201
|
+
gaps: setupReceiptGaps(setupReceipt),
|
|
202
|
+
}
|
|
203
|
+
: {
|
|
204
|
+
reason: "setup_receipt_absent_or_stale",
|
|
205
|
+
next_action: "run `cockpit doctor` to refresh it",
|
|
206
|
+
}));
|
|
151
207
|
const heartbeat = buildCollectorHeartbeat({
|
|
152
208
|
roots: options.roots,
|
|
153
209
|
facts: options.facts,
|
|
210
|
+
setupReceipt,
|
|
154
211
|
...(options.now ? { now: options.now } : {}),
|
|
212
|
+
memoryInstall: memory.receipt,
|
|
155
213
|
});
|
|
156
214
|
const controller = new AbortController();
|
|
157
215
|
const timeout = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
|
|
@@ -184,6 +242,12 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
184
242
|
sessions_outside_root: heartbeat.sessions_outside_root ?? null,
|
|
185
243
|
sessions_new_this_tick: heartbeat.sessions_new_this_tick ?? null,
|
|
186
244
|
sessions_pending_upload: heartbeat.sessions_pending_upload ?? null,
|
|
245
|
+
// BLI-3729. Both branches: a tick that reports no receipt says WHY,
|
|
246
|
+
// and a tick that reports one says whether memory is fully on. "Is BLI
|
|
247
|
+
// Memory switched on for this machine?" is now answerable from
|
|
248
|
+
// sync.err.log alone.
|
|
249
|
+
memory_receipt: memory.reason,
|
|
250
|
+
memory_gaps: memory.receipt ? memoryInstallGaps(memory.receipt) : null,
|
|
187
251
|
}));
|
|
188
252
|
return true;
|
|
189
253
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE shape a JARVIS answer wears when nobody is reading it with their
|
|
3
|
+
* eyes (BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* `cockpit jarvis --json` and the `bli-tower` MCP server's `jarvis_ask` reach
|
|
6
|
+
* the same door (`POST /api/jarvis/cli`) with the same device token and get
|
|
7
|
+
* the same reply body back. Before this file they described that body two
|
|
8
|
+
* different ways, so a script and an agent read two contracts for one turn.
|
|
9
|
+
* Now both build this envelope, and the five fields a consumer actually asks
|
|
10
|
+
* for — the answer, where it came from, which turn it was, which conversation
|
|
11
|
+
* it belongs to, and whether anything about it was second-rate — have one
|
|
12
|
+
* spelling each.
|
|
13
|
+
*
|
|
14
|
+
* ## Where the second copy is, and why
|
|
15
|
+
*
|
|
16
|
+
* `packages/bli-cockpit-mcp/src/jarvis-answer-envelope.ts` is a deliberate
|
|
17
|
+
* copy of this file. That package declares no dependency on this one — see
|
|
18
|
+
* `packages/bli-cockpit-mcp/src/agent-door-session.ts`, whose own header makes
|
|
19
|
+
* the same call for the same reason — so the shape is duplicated rather than
|
|
20
|
+
* imported. Both copies are pinned by a test on the same literal key list
|
|
21
|
+
* (`JARVIS_ANSWER_ENVELOPE_KEYS` below), so a field added on one side and not
|
|
22
|
+
* the other fails a suite instead of drifting quietly.
|
|
23
|
+
*
|
|
24
|
+
* ## What `--json` still prints
|
|
25
|
+
*
|
|
26
|
+
* These keys are ADDED to what `cockpit jarvis --json` already printed; the
|
|
27
|
+
* older `reply` / `thread` / `traceId` / `model` / `trace` / `latency` /
|
|
28
|
+
* `clientLatency` keys are untouched, because scripts already read them and a
|
|
29
|
+
* one-contract ticket that broke the contract would be a joke.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Every key of the envelope, in the order it is written. The literal list IS
|
|
33
|
+
* the contract: both copies assert against it, so a drift is a red suite.
|
|
34
|
+
*/
|
|
35
|
+
export const JARVIS_ANSWER_ENVELOPE_KEYS = [
|
|
36
|
+
"ok",
|
|
37
|
+
"answer",
|
|
38
|
+
"sources",
|
|
39
|
+
"turn_id",
|
|
40
|
+
"thread_id",
|
|
41
|
+
"trace_thread_id",
|
|
42
|
+
"proposal_id",
|
|
43
|
+
"degraded",
|
|
44
|
+
"degraded_reasons",
|
|
45
|
+
];
|
|
46
|
+
/** A `Source:` line, as the grounding gate renders it into the answer. */
|
|
47
|
+
const SOURCE_LINE = /^\s*Source:\s*\S/;
|
|
48
|
+
/**
|
|
49
|
+
* The `Source:` lines inside an answer. Pure string work on what the server
|
|
50
|
+
* already sent — this side never decides what a source IS, it only finds the
|
|
51
|
+
* lines the server wrote, so a new citation kind needs no change here.
|
|
52
|
+
*/
|
|
53
|
+
export function extractSourceLines(answer) {
|
|
54
|
+
return answer
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((line) => line.trim())
|
|
57
|
+
.filter((line) => SOURCE_LINE.test(line));
|
|
58
|
+
}
|
|
59
|
+
export function buildJarvisAnswerEnvelope(input) {
|
|
60
|
+
const reasons = [];
|
|
61
|
+
if (input.modelFallback === true)
|
|
62
|
+
reasons.push("model_fallback");
|
|
63
|
+
if (input.revised === true)
|
|
64
|
+
reasons.push("answer_revised");
|
|
65
|
+
if ((input.trace ?? []).some((step) => step?.status === "failed")) {
|
|
66
|
+
reasons.push("tool_step_failed");
|
|
67
|
+
}
|
|
68
|
+
const turnId = input.traceId ?? null;
|
|
69
|
+
if (!turnId)
|
|
70
|
+
reasons.push("no_turn_id");
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
answer: input.reply,
|
|
74
|
+
sources: extractSourceLines(input.reply),
|
|
75
|
+
turn_id: turnId,
|
|
76
|
+
thread_id: input.thread ?? null,
|
|
77
|
+
trace_thread_id: input.traceThread ?? null,
|
|
78
|
+
proposal_id: input.proposalId ?? null,
|
|
79
|
+
degraded: reasons.length > 0,
|
|
80
|
+
degraded_reasons: reasons,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -37,6 +37,19 @@ export function validatePrompt(raw) {
|
|
|
37
37
|
* (BLI-3414) — same field names the request handler parses, mirroring the
|
|
38
38
|
* JSON body's fields plus one `image` file field.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* Whether a PROGRAM is reading this turn's answer (BLI-3755).
|
|
42
|
+
*
|
|
43
|
+
* `--json` is a script's contract, and the coding arm's approval gate assumes
|
|
44
|
+
* the code it hands back reaches a person. So a `--json` turn declares itself
|
|
45
|
+
* an agent surface and JARVIS answers with a proposal id in place of the code;
|
|
46
|
+
* `--show-approval-code` opts a script that really is driving a dispatch for a
|
|
47
|
+
* human back in, and the terminal logs that it did. An interactive turn is
|
|
48
|
+
* always a person's and never sends this.
|
|
49
|
+
*/
|
|
50
|
+
export function agentSurface(command) {
|
|
51
|
+
return command.json === true && command.showApprovalCode !== true;
|
|
52
|
+
}
|
|
40
53
|
export function buildAttachmentForm(command, prompt, attachment) {
|
|
41
54
|
const form = new FormData();
|
|
42
55
|
form.set("question", prompt);
|
|
@@ -47,6 +60,11 @@ export function buildAttachmentForm(command, prompt, attachment) {
|
|
|
47
60
|
form.set("model", command.model);
|
|
48
61
|
if (command.date)
|
|
49
62
|
form.set("date", command.date);
|
|
63
|
+
// BLI-3755: an attached-image turn is still a turn on some surface, and the
|
|
64
|
+
// coding arm rides the same belt — dropping this here would hand a code to a
|
|
65
|
+
// program that sent a screenshot.
|
|
66
|
+
if (agentSurface(command))
|
|
67
|
+
form.set("surface", "agent");
|
|
50
68
|
form.set("image", new File([attachment.bytes], attachment.fileName, { type: attachment.mimeType }));
|
|
51
69
|
return form;
|
|
52
70
|
}
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* `jarvis-render.ts` — imported one direction only.
|
|
8
8
|
*/
|
|
9
9
|
import { colorEnabled, dim, writeLine } from "./cli-io.js";
|
|
10
|
+
import { buildJarvisAnswerEnvelope } from "./jarvis-answer-envelope.js";
|
|
10
11
|
import { readAttachedImage } from "./jarvis-attachment.js";
|
|
11
12
|
import { rememberTurnTrace } from "./jarvis-trace.js";
|
|
12
13
|
import { towerFailureDetail, towerJsonRequest, towerRequest } from "../tower-client.js";
|
|
13
14
|
import { readTowerTurn, streamFailureDetail } from "../tower-stream.js";
|
|
14
|
-
import { activityToTrace, ANSWER_UPDATING_LINE, buildAttachmentForm, clientTimingFields, createLiveAnswer, latencyFields, latencyLogFields, TURN_DEADLINE_MS, writeActivityLine, writeAttachmentRefusal, writeFailure, writeModelReceipt, writeRevision, writeTraceBlock, } from "./jarvis-render.js";
|
|
15
|
+
import { activityToTrace, agentSurface, ANSWER_UPDATING_LINE, buildAttachmentForm, clientTimingFields, createLiveAnswer, latencyFields, latencyLogFields, TURN_DEADLINE_MS, writeActivityLine, writeAttachmentRefusal, writeFailure, writeModelReceipt, writeRevision, writeTraceBlock, } from "./jarvis-render.js";
|
|
15
16
|
export async function sendOneTurn(context, prompt, io) {
|
|
16
17
|
const startedAt = Date.now();
|
|
17
18
|
// BLI-3414: an attached file is read and locally screened (exists,
|
|
@@ -40,6 +41,11 @@ export async function sendOneTurn(context, prompt, io) {
|
|
|
40
41
|
// all three are named. If this number is ever large, the step that made it
|
|
41
42
|
// large is on the same line.
|
|
42
43
|
const preRequestMs = Date.now() - startedAt;
|
|
44
|
+
// BLI-3755: never silently. A script that asked for the code is a script
|
|
45
|
+
// that can spend a human's approval, and the one line saying so is here.
|
|
46
|
+
if (context.command.json && context.command.showApprovalCode) {
|
|
47
|
+
log("[jarvis] approval code requested by a --json caller (--show-approval-code)");
|
|
48
|
+
}
|
|
43
49
|
const requested = await towerRequest({
|
|
44
50
|
dashboardUrl: context.dashboardUrl,
|
|
45
51
|
path: "/api/jarvis/cli",
|
|
@@ -64,6 +70,12 @@ export async function sendOneTurn(context, prompt, io) {
|
|
|
64
70
|
// there is nothing above to revise.
|
|
65
71
|
previousAnswer: context.previous?.answer,
|
|
66
72
|
previousQuestion: context.previous?.question,
|
|
73
|
+
// BLI-3755: who is reading this answer. A `--json` turn is a
|
|
74
|
+
// PROGRAM's, so the coding arm withholds its approval code and
|
|
75
|
+
// answers with a proposal id instead; an interactive turn is a
|
|
76
|
+
// person's and is unchanged. `--show-approval-code` opts a script
|
|
77
|
+
// back in, and says so on stderr below.
|
|
78
|
+
surface: agentSurface(context.command) ? "agent" : undefined,
|
|
67
79
|
},
|
|
68
80
|
log,
|
|
69
81
|
});
|
|
@@ -131,7 +143,22 @@ export async function sendOneTurn(context, prompt, io) {
|
|
|
131
143
|
await rememberTurnTrace({ traceId: body.traceId ?? null, threadId: body.traceThread ?? null }, io, context.command.homeDir);
|
|
132
144
|
if (context.command.json) {
|
|
133
145
|
writeLine(io.stdout, JSON.stringify({
|
|
134
|
-
|
|
146
|
+
// BLI-3732: the shared envelope FIRST — `answer`, `sources`,
|
|
147
|
+
// `turn_id`, `thread_id`, `trace_thread_id`, `degraded`,
|
|
148
|
+
// `degraded_reasons` — so a script here and an agent on the
|
|
149
|
+
// `jarvis_ask` MCP tool read one contract for one turn. The keys
|
|
150
|
+
// below it are the ones this command has always printed and are
|
|
151
|
+
// untouched; `reply` and `answer` are the same string.
|
|
152
|
+
...buildJarvisAnswerEnvelope({
|
|
153
|
+
reply: body.reply,
|
|
154
|
+
thread: body.thread ?? context.command.thread,
|
|
155
|
+
traceId: body.traceId,
|
|
156
|
+
traceThread: body.traceThread,
|
|
157
|
+
modelFallback: body.model?.fallback,
|
|
158
|
+
revised: body.revised,
|
|
159
|
+
trace,
|
|
160
|
+
proposalId: body.proposalId,
|
|
161
|
+
}),
|
|
135
162
|
reply: body.reply,
|
|
136
163
|
thread: body.thread ?? context.command.thread,
|
|
137
164
|
model: body.model ?? null,
|
package/dist/commands/jarvis.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* - `jarvis-contracts.ts` — the parsed `cockpit jarvis` command type, and the
|
|
13
13
|
* loose reply shapes read off `/api/jarvis/cli`, `--threads` and
|
|
14
14
|
* `--history`.
|
|
15
|
+
* - `jarvis-answer-envelope.ts` — the shape `--json` prints (BLI-3732), which
|
|
16
|
+
* is the same shape the `bli-tower` MCP server's `jarvis_ask` returns, so a
|
|
17
|
+
* script and an agent read one contract for one turn.
|
|
15
18
|
* - `jarvis-turn.ts` — the turn engine: `sendOneTurn` (attach, request,
|
|
16
19
|
* stream, settle, print, log — the one send for both a one-shot invocation
|
|
17
20
|
* and the interactive loop below) and `readHistory` (`--threads` /
|
|
@@ -31,6 +31,11 @@ function parseOnboardLikeArgs(args, command) {
|
|
|
31
31
|
"--max-depth",
|
|
32
32
|
"--max-repos",
|
|
33
33
|
"--allow-home-root",
|
|
34
|
+
// BLI-3731, the one-sign-in ceremony. Same three flags as `login`, so a
|
|
35
|
+
// person who learned them on one door does not relearn them on another.
|
|
36
|
+
"--pair",
|
|
37
|
+
"--no-browser",
|
|
38
|
+
"--legacy-pair",
|
|
34
39
|
],
|
|
35
40
|
valueFlags: [
|
|
36
41
|
"--home",
|
|
@@ -45,6 +50,7 @@ function parseOnboardLikeArgs(args, command) {
|
|
|
45
50
|
"--timeout-ms",
|
|
46
51
|
"--max-depth",
|
|
47
52
|
"--max-repos",
|
|
53
|
+
"--pair",
|
|
48
54
|
],
|
|
49
55
|
});
|
|
50
56
|
assertNoPositionals(values.positionals, command);
|
|
@@ -65,6 +71,9 @@ function parseOnboardLikeArgs(args, command) {
|
|
|
65
71
|
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
66
72
|
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
67
73
|
allowHomeRoot: values.booleans.has("--allow-home-root"),
|
|
74
|
+
noBrowser: values.booleans.has("--no-browser"),
|
|
75
|
+
legacyPair: values.booleans.has("--legacy-pair"),
|
|
76
|
+
pairCode: optionalNonEmpty(values.flags.get("--pair")),
|
|
68
77
|
};
|
|
69
78
|
}
|
|
70
79
|
export function parseOnboardArgs(args) {
|
|
@@ -210,6 +219,10 @@ export function parseLoginArgs(args) {
|
|
|
210
219
|
"--no-auth",
|
|
211
220
|
"--poll-interval-ms",
|
|
212
221
|
"--timeout-ms",
|
|
222
|
+
// BLI-3731, the one-sign-in ceremony.
|
|
223
|
+
"--pair",
|
|
224
|
+
"--no-browser",
|
|
225
|
+
"--legacy-pair",
|
|
213
226
|
],
|
|
214
227
|
valueFlags: [
|
|
215
228
|
"--home",
|
|
@@ -218,6 +231,7 @@ export function parseLoginArgs(args) {
|
|
|
218
231
|
"--device-name",
|
|
219
232
|
"--poll-interval-ms",
|
|
220
233
|
"--timeout-ms",
|
|
234
|
+
"--pair",
|
|
221
235
|
],
|
|
222
236
|
});
|
|
223
237
|
assertNoPositionals(values.positionals, "login");
|
|
@@ -231,6 +245,9 @@ export function parseLoginArgs(args) {
|
|
|
231
245
|
noAuth: values.booleans.has("--no-auth"),
|
|
232
246
|
pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
|
|
233
247
|
timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
|
|
248
|
+
pairCode: optionalNonEmpty(values.flags.get("--pair")),
|
|
249
|
+
noBrowser: values.booleans.has("--no-browser"),
|
|
250
|
+
legacyPair: values.booleans.has("--legacy-pair"),
|
|
234
251
|
};
|
|
235
252
|
}
|
|
236
253
|
export function parseLogoutArgs(args) {
|
|
@@ -48,7 +48,7 @@ export function parseScoutArgs(args) {
|
|
|
48
48
|
return { kind: "scout", action: rawAction, experimentRef, ...base };
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* `cockpit ops status [--job <id>] [--skips]` and
|
|
51
|
+
* `cockpit ops status [--job <id>] [--skips] [--memory [--memory-days N]]` and
|
|
52
52
|
* `cockpit ops recompile --person <p> [--dry-run]` (BLI-3462).
|
|
53
53
|
*
|
|
54
54
|
* There is deliberately **no `--cadence`** on `recompile`. The compile path
|
|
@@ -64,11 +64,13 @@ export function parseOpsArgs(args) {
|
|
|
64
64
|
"--dashboard-url",
|
|
65
65
|
"--job",
|
|
66
66
|
"--skips",
|
|
67
|
+
"--memory",
|
|
68
|
+
"--memory-days",
|
|
67
69
|
"--person",
|
|
68
70
|
"--dry-run",
|
|
69
71
|
"--json",
|
|
70
72
|
],
|
|
71
|
-
valueFlags: ["--home", "--dashboard-url", "--job", "--person"],
|
|
73
|
+
valueFlags: ["--home", "--dashboard-url", "--job", "--person", "--memory-days"],
|
|
72
74
|
});
|
|
73
75
|
if (values.positionals.length > 1) {
|
|
74
76
|
throw new Error("ops accepts one action: status or recompile.");
|
|
@@ -83,11 +85,19 @@ export function parseOpsArgs(args) {
|
|
|
83
85
|
json: values.booleans.has("--json"),
|
|
84
86
|
};
|
|
85
87
|
if (rawAction === "status") {
|
|
88
|
+
// BLI-3729: `--memory-days N` implies `--memory`, because asking for a
|
|
89
|
+
// window and getting nothing back is the silent breakage BLI-2490 forbids.
|
|
90
|
+
const memoryDays = optionalNonEmpty(values.flags.get("--memory-days"));
|
|
91
|
+
if (memoryDays !== undefined && !/^[0-9]{1,2}$/u.test(memoryDays)) {
|
|
92
|
+
throw new Error("ops --memory-days takes a whole number of days, 1 to 30.");
|
|
93
|
+
}
|
|
86
94
|
return {
|
|
87
95
|
kind: "ops",
|
|
88
96
|
action: "status",
|
|
89
97
|
job: optionalNonEmpty(values.flags.get("--job")),
|
|
90
98
|
skips: values.booleans.has("--skips"),
|
|
99
|
+
memory: values.booleans.has("--memory") || memoryDays !== undefined,
|
|
100
|
+
...(memoryDays === undefined ? {} : { memoryDays: Number(memoryDays) }),
|
|
91
101
|
...base,
|
|
92
102
|
};
|
|
93
103
|
}
|