@bli-cockpit/cli 0.2.98 → 0.2.99
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/dist/cli.js +13 -0
- package/dist/commands/doctor.js +6 -2
- package/dist/commands/local-args-tower-admin.js +17 -6
- package/dist/commands/local-args-tower-pages.js +6 -1
- package/dist/commands/local-args-tower-usage.js +8 -0
- package/dist/commands/local-args-tower.js +2 -1
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower.js +31 -5
- package/dist/commands/local-help-commands.js +4 -1
- package/dist/commands/local-help.js +6 -3
- package/dist/commands/local.js +10 -0
- package/dist/commands/memory-hook-counts.js +29 -8
- package/dist/commands/notes-writes.js +37 -0
- package/dist/commands/notes.js +5 -3
- package/dist/commands/ops-render.js +5 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/usage.js +23 -0
- package/dist/crash-guard.js +167 -0
- package/dist/process-runner.js +39 -1
- package/package.json +5 -5
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runCockpitCli } from "./commands/public-root.js";
|
|
3
|
+
import {
|
|
4
|
+
EXIT_DIED_MID_RUN,
|
|
5
|
+
installCrashGuard,
|
|
6
|
+
markCommandReturned,
|
|
7
|
+
} from "./crash-guard.js";
|
|
8
|
+
|
|
9
|
+
// BLI-4110. Success is EARNED, not assumed — see crash-guard.js. This
|
|
10
|
+
// entry point is GENERATED, so it must mirror src/cli.ts; the packed CLI
|
|
11
|
+
// is the one the fleet runs.
|
|
12
|
+
process.exitCode = EXIT_DIED_MID_RUN;
|
|
13
|
+
installCrashGuard();
|
|
3
14
|
|
|
4
15
|
const exitCode = await runCockpitCli(process.argv.slice(2));
|
|
16
|
+
|
|
17
|
+
markCommandReturned();
|
|
5
18
|
process.exitCode = exitCode;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withStage } from "../crash-guard.js";
|
|
1
2
|
import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
|
|
2
3
|
import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
|
|
3
4
|
import { checkMcpAnswersState } from "./doctor-mcp.js";
|
|
@@ -14,7 +15,7 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
14
15
|
const context = { command, io, deps };
|
|
15
16
|
const rows = [];
|
|
16
17
|
for (const invariant of doctorInvariants()) {
|
|
17
|
-
const checked = await invariant.check(context);
|
|
18
|
+
const checked = await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context));
|
|
18
19
|
if (checked.status === "ok" || checked.status === "skipped") {
|
|
19
20
|
rows.push(checked);
|
|
20
21
|
continue;
|
|
@@ -31,7 +32,10 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
31
32
|
break;
|
|
32
33
|
continue;
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
// BLI-4110: named so a crash inside a repair says WHICH repair. The
|
|
36
|
+
// 2026-09-09 incident printed a bare `read ENOTCONN` stack and the only
|
|
37
|
+
// way to place it was the log line that happened to precede it.
|
|
38
|
+
const fixed = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, checked));
|
|
35
39
|
rows.push({ ...fixed, fixed: fixed.status !== "fail" });
|
|
36
40
|
if (fixed.hardStop)
|
|
37
41
|
break;
|
|
@@ -14,9 +14,17 @@ import { isTeamDeviceRevokeReasonLabel, TEAM_DEVICE_REVOKE_REASON_LABELS, } from
|
|
|
14
14
|
/** The shortest prefix `cockpit scout start` will resolve. Below this, ids collide. */
|
|
15
15
|
export const SCOUT_MIN_PREFIX_LENGTH = 6;
|
|
16
16
|
/**
|
|
17
|
-
* `cockpit scout [--days <n>]` reads the board; `cockpit scout
|
|
18
|
-
* <id>` moves one card. Positional shape modelled on
|
|
19
|
-
* action word first, then what it acts on.
|
|
17
|
+
* `cockpit scout [board] [--days <n>]` reads the board; `cockpit scout
|
|
18
|
+
* start|dismiss|undo <id>` moves one card. Positional shape modelled on
|
|
19
|
+
* `autostart`: an optional action word first, then what it acts on.
|
|
20
|
+
*
|
|
21
|
+
* `board` is spellable as well as implied (BLI-4048). The parsed command has
|
|
22
|
+
* always carried `action: "board"`, the MCP twin has always been `scout_board`,
|
|
23
|
+
* and three documents printed `cockpit scout board` under a "CLI verb" heading
|
|
24
|
+
* while the parser answered "scout action must be start, dismiss, or undo" —
|
|
25
|
+
* the promise was older than the refusal. Every sibling noun (`ops status`,
|
|
26
|
+
* `slack coverage`, `notes list`) lets a person name the read it defaults to,
|
|
27
|
+
* so scout does too, and the word costs one branch.
|
|
20
28
|
*/
|
|
21
29
|
export function parseScoutArgs(args) {
|
|
22
30
|
const values = parseNamedArgs(args, {
|
|
@@ -24,7 +32,7 @@ export function parseScoutArgs(args) {
|
|
|
24
32
|
valueFlags: ["--home", "--dashboard-url", "--days"],
|
|
25
33
|
});
|
|
26
34
|
if (values.positionals.length > 2) {
|
|
27
|
-
throw new Error("scout accepts at most an action (start|dismiss|undo) and one experiment id.");
|
|
35
|
+
throw new Error("scout accepts at most an action (board|start|dismiss|undo) and one experiment id.");
|
|
28
36
|
}
|
|
29
37
|
const [rawAction, rawRef] = values.positionals;
|
|
30
38
|
const base = {
|
|
@@ -33,10 +41,13 @@ export function parseScoutArgs(args) {
|
|
|
33
41
|
days: optionalPositiveInteger(values.flags.get("--days"), "--days"),
|
|
34
42
|
json: values.booleans.has("--json"),
|
|
35
43
|
};
|
|
36
|
-
if (!rawAction)
|
|
44
|
+
if (!rawAction || rawAction === "board") {
|
|
45
|
+
if (rawRef)
|
|
46
|
+
throw new Error("scout board reads the whole board; it takes no experiment id.");
|
|
37
47
|
return { kind: "scout", action: "board", ...base };
|
|
48
|
+
}
|
|
38
49
|
if (rawAction !== "start" && rawAction !== "dismiss" && rawAction !== "undo") {
|
|
39
|
-
throw new Error("scout action must be start, dismiss, or undo.");
|
|
50
|
+
throw new Error("scout action must be board, start, dismiss, or undo.");
|
|
40
51
|
}
|
|
41
52
|
const experimentRef = optionalNonEmpty(rawRef);
|
|
42
53
|
if (!experimentRef) {
|
|
@@ -162,6 +162,7 @@ const NOTES_ACTIONS = new Set([
|
|
|
162
162
|
"share",
|
|
163
163
|
"unshare",
|
|
164
164
|
"move",
|
|
165
|
+
"place",
|
|
165
166
|
]);
|
|
166
167
|
/** Actions whose first positional is the note it acts on. */
|
|
167
168
|
const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
|
|
@@ -169,6 +170,7 @@ const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
|
|
|
169
170
|
"share",
|
|
170
171
|
"unshare",
|
|
171
172
|
"move",
|
|
173
|
+
"place",
|
|
172
174
|
]);
|
|
173
175
|
export function parseNotesArgs(args) {
|
|
174
176
|
const values = parseNamedArgs(args, {
|
|
@@ -180,6 +182,7 @@ export function parseNotesArgs(args) {
|
|
|
180
182
|
"--exclude",
|
|
181
183
|
"--to",
|
|
182
184
|
"--clear-shelf",
|
|
185
|
+
"--apply",
|
|
183
186
|
"--series",
|
|
184
187
|
"--kind",
|
|
185
188
|
"--since",
|
|
@@ -207,7 +210,7 @@ export function parseNotesArgs(args) {
|
|
|
207
210
|
const first = values.positionals[0];
|
|
208
211
|
const action = (first === undefined ? "list" : first);
|
|
209
212
|
if (!NOTES_ACTIONS.has(action)) {
|
|
210
|
-
throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, or
|
|
213
|
+
throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, or place.`);
|
|
211
214
|
}
|
|
212
215
|
const rest = values.positionals.slice(first === undefined ? 0 : 1);
|
|
213
216
|
const json = values.booleans.has("--json");
|
|
@@ -259,6 +262,8 @@ export function parseNotesArgs(args) {
|
|
|
259
262
|
exclude: optionalNonEmpty(values.flags.get("--exclude")),
|
|
260
263
|
...(to ? { to } : {}),
|
|
261
264
|
...(clearShelf ? { clearShelf } : {}),
|
|
265
|
+
// BLI-4058. `place` says where a note belongs; only `--apply` moves it.
|
|
266
|
+
...(values.booleans.has("--apply") ? { apply: true } : {}),
|
|
262
267
|
series: optionalNonEmpty(values.flags.get("--series")),
|
|
263
268
|
meetingKind: optionalNonEmpty(values.flags.get("--kind")),
|
|
264
269
|
since: optionalNonEmpty(values.flags.get("--since")),
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
|
+
export function parseUsageArgs(args) {
|
|
3
|
+
const values = parseNamedArgs(args, { allowedFlags: ["--since", "--until", "--include-automated", "--home", "--dashboard-url", "--json"], valueFlags: ["--since", "--until", "--home", "--dashboard-url"] });
|
|
4
|
+
const action = values.positionals[0] ?? "people";
|
|
5
|
+
if (action !== "people" || values.positionals.length > 1)
|
|
6
|
+
throw new Error("usage takes one verb: people.");
|
|
7
|
+
return { kind: "usage", action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
|
|
8
|
+
}
|
|
@@ -35,4 +35,5 @@ export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-to
|
|
|
35
35
|
export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
|
|
36
36
|
export { parseModelsArgs } from "./local-args-tower-models.js";
|
|
37
37
|
export { parseMailArgs } from "./local-args-tower-mail.js";
|
|
38
|
-
export { parseCalArgs } from "./local-args-tower-cal.js";
|
|
38
|
+
export { parseCalArgs } from "./local-args-tower-cal.js";
|
|
39
|
+
export { parseUsageArgs } from "./local-args-tower-usage.js";
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* verbatim, no logic change.
|
|
17
17
|
*/
|
|
18
18
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
19
|
-
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
|
|
19
|
+
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, } from "./local-args-tower.js";
|
|
20
20
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
21
21
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
22
22
|
// even though it now lives next door. The same goes for the four names the
|
|
@@ -115,6 +115,8 @@ export function parseLocalArgs(argv) {
|
|
|
115
115
|
return parseSearchArgs(argv.slice(1));
|
|
116
116
|
case "release":
|
|
117
117
|
return parseReleaseArgs(argv.slice(1));
|
|
118
|
+
case "usage":
|
|
119
|
+
return parseUsageArgs(argv.slice(1));
|
|
118
120
|
default:
|
|
119
121
|
throw new Error(`Unknown local command: ${command ?? ""}`);
|
|
120
122
|
}
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
* Every string moved verbatim. Help output is what an intern pastes back when
|
|
12
12
|
* something breaks, so it is a user-visible contract like any other.
|
|
13
13
|
*/
|
|
14
|
+
import { SEARCH_KINDS } from "./local-args-tower-search.js";
|
|
15
|
+
/**
|
|
16
|
+
* The corpora `cockpit search` can name, spelled the way `--kind` takes them.
|
|
17
|
+
* Read off `SEARCH_KINDS` rather than typed out: the help string said five for
|
|
18
|
+
* the two months `mail` and `cal` were searchable, so a person was told the
|
|
19
|
+
* door was narrower than it is (BLI-4048).
|
|
20
|
+
*/
|
|
21
|
+
const SEARCH_KIND_LIST = SEARCH_KINDS.join(",");
|
|
22
|
+
const SEARCH_CORPORA_COUNT = SEARCH_KINDS.length;
|
|
14
23
|
/** One entry per Tower noun, in the order `cockpit --help` lists them. */
|
|
15
24
|
export const TOWER_COMMAND_HELP = [
|
|
16
25
|
[
|
|
@@ -130,12 +139,27 @@ export const TOWER_COMMAND_HELP = [
|
|
|
130
139
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
131
140
|
],
|
|
132
141
|
],
|
|
142
|
+
[
|
|
143
|
+
"usage",
|
|
144
|
+
[
|
|
145
|
+
"Usage: cockpit usage people [--since 30d|<iso>] [--until <iso>] [--include-automated] [--json]",
|
|
146
|
+
"",
|
|
147
|
+
"Claude Code and Codex usage per person: sessions observed and extracted, tokens (total, output,",
|
|
148
|
+
"and the input / cache split when the row carries it), and an API list-price equivalent that is",
|
|
149
|
+
"labelled as such and is never actual spend. A super_admin sees everyone; a member sees their own row.",
|
|
150
|
+
"--since takes 7d, 30d, 90d or an ISO timestamp; --until an ISO timestamp (default now).",
|
|
151
|
+
"--include-automated adds harness, subagent and scheduled sessions, which are excluded by default.",
|
|
152
|
+
"It presses the same door as the /usage page and the usage_people MCP tool (GET /api/usage/people).",
|
|
153
|
+
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
154
|
+
],
|
|
155
|
+
],
|
|
133
156
|
[
|
|
134
157
|
"search",
|
|
135
158
|
[
|
|
136
|
-
|
|
159
|
+
`Usage: cockpit search "<words>" [--kind ${SEARCH_KIND_LIST}] [--limit <n>] [--json]`,
|
|
137
160
|
"",
|
|
138
|
-
|
|
161
|
+
`One bar over ${SEARCH_CORPORA_COUNT} corpora: documents, messages, issues, meeting notes, mail, calendar`,
|
|
162
|
+
"events and memory.",
|
|
139
163
|
"It presses the SAME door the browser's search bar presses (GET /api/search), so what you",
|
|
140
164
|
"read here is the same row, the same ranking and the same snippet a person sees in Tower.",
|
|
141
165
|
"",
|
|
@@ -144,12 +168,14 @@ export const TOWER_COMMAND_HELP = [
|
|
|
144
168
|
"Quoted phrases and -word work inside the query itself — the query goes to Postgres's",
|
|
145
169
|
"websearch parser, which shrugs at anything a person can type instead of raising.",
|
|
146
170
|
"",
|
|
147
|
-
|
|
171
|
+
`--kind narrows to one or more corpora, comma separated: ${SEARCH_KIND_LIST}.`,
|
|
172
|
+
`Omit it and all ${SEARCH_CORPORA_COUNT} are searched.`,
|
|
148
173
|
"--limit caps the number of results (default 20, maximum 50).",
|
|
149
174
|
"--json writes the door's whole answer to stdout, hits, per-kind counts, failures and all.",
|
|
150
175
|
"",
|
|
151
|
-
|
|
152
|
-
"own database session, so a document or a channel you cannot open is not in
|
|
176
|
+
`Every result is scoped by what YOU may read: ${SEARCH_CORPORA_COUNT - 1} of the ${SEARCH_CORPORA_COUNT} corpora are`,
|
|
177
|
+
"searched on your own database session, so a document or a channel you cannot open is not in",
|
|
178
|
+
"the list; memory runs on the service role behind the memory doors' own gate.",
|
|
153
179
|
"",
|
|
154
180
|
"A corpus that could not ANSWER gets its own line, separately from \"nothing matched\" —",
|
|
155
181
|
"those are different facts and folding them together would let a broken search read as silence.",
|
|
@@ -305,12 +305,14 @@ export function localSubcommandHelp(command) {
|
|
|
305
305
|
[
|
|
306
306
|
"scout",
|
|
307
307
|
[
|
|
308
|
-
"Usage: cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
|
|
308
|
+
"Usage: cockpit scout [board|start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
|
|
309
309
|
"",
|
|
310
310
|
"Prints the same Scout board the Tower page shows, in the same words: the standing",
|
|
311
311
|
"watch line, the cards waiting on a decision, and the raw signal watch underneath.",
|
|
312
312
|
"This is the board itself. To ask a QUESTION about it — what it means, whether it is",
|
|
313
313
|
"worth doing here — use `cockpit jarvis`, whose readScout tool reads the same rows.",
|
|
314
|
+
"`cockpit scout board` and a bare `cockpit scout` are the same command; the MCP twin",
|
|
315
|
+
"is `scout_board` on the bli-tower server, over the same GET /api/cockpit/scout door.",
|
|
314
316
|
"--days <n> widens or narrows the window (1 to 90; the board defaults to 14).",
|
|
315
317
|
"Reading follows the Scout page audience setting, so whoever can open /scout can run this.",
|
|
316
318
|
"start, dismiss, and undo move one card and are super_admin actions on every surface;",
|
|
@@ -461,6 +463,7 @@ export function localSubcommandHelp(command) {
|
|
|
461
463
|
"share <id> [--yes] — let everyone signed in read the statements that are safe to share. Asks first in a terminal; --yes is required without one, and --json implies --yes.",
|
|
462
464
|
"unshare <id> — take it back. Never asks: it only ever narrows who can read.",
|
|
463
465
|
"move <id> (--to \"<shelf>\"|--clear-shelf) — put it on a different shelf, or take the shelf off.",
|
|
466
|
+
"place <id> [--apply] — say which shelf this note belongs on and why, from the meeting it is part of, who was in the room and its own name. Moves nothing without --apply, and never overrules a shelf somebody typed.",
|
|
464
467
|
"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.",
|
|
465
468
|
"--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
|
|
466
469
|
"Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* user-visible contract like any other.
|
|
9
9
|
*/
|
|
10
10
|
import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
|
|
11
|
+
import { SEARCH_KINDS } from "./local-args-tower-search.js";
|
|
11
12
|
import { localSubcommandHelp } from "./local-help-commands.js";
|
|
12
13
|
export const rootCommandNames = new Set([
|
|
13
14
|
"onboard",
|
|
@@ -51,6 +52,7 @@ export const rootCommandNames = new Set([
|
|
|
51
52
|
"project",
|
|
52
53
|
"search",
|
|
53
54
|
"release",
|
|
55
|
+
"usage",
|
|
54
56
|
]);
|
|
55
57
|
export function localCommandHelp(command) {
|
|
56
58
|
if (command)
|
|
@@ -72,7 +74,7 @@ export function localCommandHelp(command) {
|
|
|
72
74
|
" cockpit jarvis [question] [--prompt <question>] [--as <person>] [--date <YYYY-MM-DD>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--trace <id|last>] [--dashboard-url <url>] [--json [--show-approval-code]]",
|
|
73
75
|
" cockpit model [show|set <provider:model>] [--json]",
|
|
74
76
|
" cockpit models [list|show <provider:model>|compare <provider:model> <provider:model> [...]] [--highlight] [--dashboard-url <url>] [--json]",
|
|
75
|
-
" cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
|
|
77
|
+
" cockpit scout [board|start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
|
|
76
78
|
" cockpit ops [status [--job <id>] [--skips] [--memory] [--models] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
|
|
77
79
|
" cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] | read [--person <p>] [--channel <c>] [--query <text>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>]] [--json]",
|
|
78
80
|
" cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | cli-floor [<version>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
|
|
@@ -81,7 +83,7 @@ export function localCommandHelp(command) {
|
|
|
81
83
|
" cockpit brief [edit|rewrite|history] [--for <person>] [--date <YYYY-MM-DD>] [--delta [--against <YYYY-MM-DD>]] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--days <n>] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
|
|
82
84
|
" cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
|
|
83
85
|
" cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
|
|
84
|
-
" cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--yes] [--dashboard-url <url>] [--json]",
|
|
86
|
+
" cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>|place <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
|
|
85
87
|
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
86
88
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
87
89
|
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
@@ -96,8 +98,9 @@ export function localCommandHelp(command) {
|
|
|
96
98
|
" cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
|
|
97
99
|
" cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
|
|
98
100
|
" cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
|
|
99
|
-
|
|
101
|
+
` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
|
|
100
102
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
103
|
+
" cockpit usage people [--since 30d|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
|
|
101
104
|
"",
|
|
102
105
|
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
|
103
106
|
].join("\n");
|
package/dist/commands/local.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defaultIo, errorMessage, writeLine } from "./cli-io.js";
|
|
2
2
|
import { isLocalHelpRequest, localCommandHelp, rootCommandNames } from "./local-help.js";
|
|
3
|
+
import { beginStage, endStage } from "../crash-guard.js";
|
|
3
4
|
import { describeError } from "../health-detail.js";
|
|
4
5
|
import { reportInstallEventsBestEffort } from "./install-receipts.js";
|
|
5
6
|
import { persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
|
|
@@ -42,6 +43,7 @@ import { runCal } from "./cal.js";
|
|
|
42
43
|
import { runMail } from "./mail.js";
|
|
43
44
|
import { runProject } from "./project.js";
|
|
44
45
|
import { runSearch } from "./search.js";
|
|
46
|
+
import { runUsage } from "./usage.js";
|
|
45
47
|
import { parseLocalArgs } from "./local-args.js";
|
|
46
48
|
// `./local.js` is the published entry point for this command surface: the
|
|
47
49
|
// public CLI's generated root, commands/root.ts, doctor.ts and the test suite
|
|
@@ -77,6 +79,9 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
77
79
|
// including the scheduled one nobody types into (BLI-2362). Done here so it
|
|
78
80
|
// applies to whichever command carried the flag.
|
|
79
81
|
await rememberDiscoveryLimits(command);
|
|
82
|
+
// BLI-4110: the outermost stage name, so a crash that escapes every catch
|
|
83
|
+
// below still says which subcommand was running.
|
|
84
|
+
beginStage(`command:${command.kind}`);
|
|
80
85
|
try {
|
|
81
86
|
switch (command.kind) {
|
|
82
87
|
case "install":
|
|
@@ -175,6 +180,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
175
180
|
return await runSearch(command, io);
|
|
176
181
|
case "release":
|
|
177
182
|
return await runRelease(command, io);
|
|
183
|
+
case "usage":
|
|
184
|
+
return await runUsage(command, io);
|
|
178
185
|
}
|
|
179
186
|
}
|
|
180
187
|
catch (error) {
|
|
@@ -192,6 +199,9 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
192
199
|
writeLine(io.stderr, errorMessage(error));
|
|
193
200
|
return 1;
|
|
194
201
|
}
|
|
202
|
+
finally {
|
|
203
|
+
endStage(`command:${command.kind}`);
|
|
204
|
+
}
|
|
195
205
|
}
|
|
196
206
|
async function runDoctorLogin(command, io) {
|
|
197
207
|
return runLogin({
|
|
@@ -11,13 +11,19 @@
|
|
|
11
11
|
* rule are `@bli-cockpit/telemetry-core`'s `memory-hook-stats.ts`, which the
|
|
12
12
|
* writing package spends too. That is the whole point of putting them there.
|
|
13
13
|
*
|
|
14
|
-
* ## Which event, and why
|
|
14
|
+
* ## Which event, and why the detail is on one of them
|
|
15
15
|
*
|
|
16
|
-
* The PROMPT hook. It is the one a person waits on
|
|
17
|
-
* the one whose budget QA tick 18 caught it
|
|
18
|
-
* every turn — so it is the only one whose
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* The PROMPT hook gets the full breakdown. It is the one a person waits on
|
|
17
|
+
* with their sentence typed, the one whose budget QA tick 18 caught it
|
|
18
|
+
* losing, and the one that runs on every turn — so it is the only one whose
|
|
19
|
+
* MISS RATE means anything as a daily number.
|
|
20
|
+
*
|
|
21
|
+
* BLI-4057 added the other two as a run count and a failure count each. Until
|
|
22
|
+
* then the fleet heard from one hook of three, so a SAVE hook that had
|
|
23
|
+
* stopped firing was invisible on every surface: `hooks ok` on the install
|
|
24
|
+
* receipt, 40 prompt recalls in the counts, and nothing anywhere saying that
|
|
25
|
+
* no memory had been written in ten days. Two numbers each is the smallest
|
|
26
|
+
* thing that can say "it ran" and "it ran and could not finish" apart.
|
|
21
27
|
*
|
|
22
28
|
* ## Absent is not zero
|
|
23
29
|
*
|
|
@@ -37,8 +43,14 @@ export function readMemoryHookCounts(options) {
|
|
|
37
43
|
const parsed = parseMemoryHookStats(raw);
|
|
38
44
|
if (!parsed.ok)
|
|
39
45
|
return { counts: null, reason: parsed.reason };
|
|
40
|
-
const
|
|
41
|
-
|
|
46
|
+
const at = options.now ?? new Date();
|
|
47
|
+
const window = summariseMemoryHookWindow(parsed.file, "prompt", { now: at, hours: 24 });
|
|
48
|
+
// BLI-4057. The same rows, the same function, the other two events — said
|
|
49
|
+
// out loud in the module header: the file always held all three and only one
|
|
50
|
+
// of them ever left the machine.
|
|
51
|
+
const stop = summariseMemoryHookWindow(parsed.file, "stop", { now: at, hours: 24 });
|
|
52
|
+
const sessionStart = summariseMemoryHookWindow(parsed.file, "session-start", {
|
|
53
|
+
now: at,
|
|
42
54
|
hours: 24,
|
|
43
55
|
});
|
|
44
56
|
return {
|
|
@@ -55,6 +67,15 @@ export function readMemoryHookCounts(options) {
|
|
|
55
67
|
: {}),
|
|
56
68
|
hook_skipped_trivial_24h: window.skippedTrivial,
|
|
57
69
|
hook_billing_exhausted_24h: window.billingExhausted,
|
|
70
|
+
// A lost deadline and a refusal are ONE number for these two, unlike the
|
|
71
|
+
// prompt hook above: for a recall the difference decides whether to
|
|
72
|
+
// re-fit a budget or pay a bill, and for a save both mean the same
|
|
73
|
+
// thing — nothing was written. The prompt hook keeps them apart because
|
|
74
|
+
// it is the only one whose miss rate is read as a rate.
|
|
75
|
+
hook_stop_runs_24h: stop.runs,
|
|
76
|
+
hook_stop_failed_24h: stop.failed + stop.timeouts,
|
|
77
|
+
hook_session_start_runs_24h: sessionStart.runs,
|
|
78
|
+
hook_session_start_failed_24h: sessionStart.failed + sessionStart.timeouts,
|
|
58
79
|
// Ticket 3934. Reported as the bin's LOWER bound — the smaller, true
|
|
59
80
|
// claim — and only when something was measured. A machine that recorded
|
|
60
81
|
// no histogram carries no key at all, which the board reads as
|
|
@@ -180,6 +180,43 @@ export async function shareNote(command, door) {
|
|
|
180
180
|
sayUpload(door, body);
|
|
181
181
|
return 0;
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* `cockpit notes place <note-id> [--apply]` — BLI-4058.
|
|
185
|
+
*
|
|
186
|
+
* Asks Tower where a note belongs and prints the reason. Without `--apply` it
|
|
187
|
+
* moves nothing: knowing where a note would go and filing it there are two
|
|
188
|
+
* different acts, and the read-only one is the default because it is the one
|
|
189
|
+
* somebody types while they are still deciding.
|
|
190
|
+
*/
|
|
191
|
+
export async function placeNote(command, door) {
|
|
192
|
+
const applying = command.apply === true;
|
|
193
|
+
const answer = await ask(door, {
|
|
194
|
+
path: "/api/notes/place",
|
|
195
|
+
method: "POST",
|
|
196
|
+
label: applying ? "notes place --apply" : "notes place",
|
|
197
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
198
|
+
body: { note_id: command.noteId, apply: applying },
|
|
199
|
+
});
|
|
200
|
+
if (!answer.ok)
|
|
201
|
+
return fail(door, answer.reason, answer.detail);
|
|
202
|
+
const body = answer.body;
|
|
203
|
+
writeLine(door.io.stderr, `${TAG} place answered ${JSON.stringify({
|
|
204
|
+
note_id: command.noteId ?? null,
|
|
205
|
+
ok: body.ok === true,
|
|
206
|
+
decided_by: body.decidedBy ?? null,
|
|
207
|
+
applied: body.applied === true,
|
|
208
|
+
reason: body.reason ?? null,
|
|
209
|
+
})}`);
|
|
210
|
+
if (door.json)
|
|
211
|
+
return emit(door, body, body.ok === true ? 0 : 1);
|
|
212
|
+
writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
|
|
213
|
+
for (const line of body.lines ?? [])
|
|
214
|
+
writeLine(door.io.stdout, line);
|
|
215
|
+
if (body.ok === true && !applying) {
|
|
216
|
+
writeLine(door.io.stdout, "Nothing moved. Add --apply to file it there.");
|
|
217
|
+
}
|
|
218
|
+
return body.ok === true ? 0 : 1;
|
|
219
|
+
}
|
|
183
220
|
export async function moveNote(command, door) {
|
|
184
221
|
const answer = await ask(door, {
|
|
185
222
|
path: "/api/notes/move",
|
package/dist/commands/notes.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* table of contents). Every responsibility lives in a `notes-*.ts` sibling:
|
|
28
28
|
*
|
|
29
29
|
* - `notes-reads.ts` — the library, shelves list, one note, your own shelf.
|
|
30
|
-
* - `notes-writes.ts` — upload, paste, share/unshare, move.
|
|
30
|
+
* - `notes-writes.ts` — upload, paste, share/unshare, move, place.
|
|
31
31
|
* - `notes-door.ts` — the one HTTP request, and the shared answer / refusal /
|
|
32
32
|
* emit / say-* rendering both halves above call.
|
|
33
33
|
*
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { loadPairedSession } from "../tower-client.js";
|
|
37
37
|
import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
38
|
-
import { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
|
|
38
|
+
import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
|
|
39
39
|
export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
40
|
-
export { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
|
|
40
|
+
export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
|
|
41
41
|
export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
|
|
42
42
|
export async function runNotes(command, io) {
|
|
43
43
|
const session = await loadPairedSession("notes", command.homeDir);
|
|
@@ -65,5 +65,7 @@ export async function runNotes(command, io) {
|
|
|
65
65
|
return shareNote(command, door);
|
|
66
66
|
case "move":
|
|
67
67
|
return moveNote(command, door);
|
|
68
|
+
case "place":
|
|
69
|
+
return placeNote(command, door);
|
|
68
70
|
}
|
|
69
71
|
}
|
|
@@ -101,7 +101,11 @@ options = {}) {
|
|
|
101
101
|
// `STALE collector-fleet 11h` was already misread once (BLI-3699) as "the
|
|
102
102
|
// fleet", not "one machine in it", and the name is otherwise buried in
|
|
103
103
|
// `detail` below.
|
|
104
|
-
|
|
104
|
+
// BLI-4057 generalised it from `stale` alone: `memory-hooks` names the
|
|
105
|
+
// machine whose hooks stopped and reads `failing`, because nothing about
|
|
106
|
+
// it is late. A row that took the trouble to name a device is naming it
|
|
107
|
+
// for the reader, whatever word it printed beside it.
|
|
108
|
+
const staleName = row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
|
|
105
109
|
lines.push(`${verdict} ${id} ${age}${staleName} ${dim(intervalWord(row))}`);
|
|
106
110
|
if (row.verdict !== "healthy") {
|
|
107
111
|
// The absence of an age is explained BEFORE the detail, because it is
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.99");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { writeLine } from "./cli-io.js";
|
|
3
|
+
export async function runUsage(command, io) {
|
|
4
|
+
const door = await openAgentDoor("usage", command, io);
|
|
5
|
+
const query = new URLSearchParams({ since: command.since });
|
|
6
|
+
if (command.until)
|
|
7
|
+
query.set("until", command.until);
|
|
8
|
+
if (command.includeAutomated)
|
|
9
|
+
query.set("include_automated", "1");
|
|
10
|
+
const answer = await askAgentDoor(door, { path: `/api/usage/people?${query}`, method: "GET", label: "usage people", timeoutMs: 30_000 });
|
|
11
|
+
if (!answer.ok)
|
|
12
|
+
return failAgentDoor(door, "[usage]", answer.reason, answer.detail);
|
|
13
|
+
const body = answer.body;
|
|
14
|
+
if (door.json)
|
|
15
|
+
return emitAgentDoor(door, body);
|
|
16
|
+
writeLine(io.stdout, "PERSON TOKENS OUTPUT LIST EQUIVALENT COVERAGE");
|
|
17
|
+
for (const row of body.people ?? [])
|
|
18
|
+
writeLine(io.stdout, `${(row.display_name ?? row.email ?? "Unknown").slice(0, 20).padEnd(20)} ${String(row.tokens_total).padStart(11)} ${String(row.output_tokens).padStart(11)} ${`$${row.api_list_price_equivalent_usd.toFixed(2)}`.padStart(15)} ${row.sessions_extracted}/${row.sessions_observed}`);
|
|
19
|
+
writeLine(io.stdout, "");
|
|
20
|
+
writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
|
|
21
|
+
writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run that dies must not be able to say it worked (BLI-4110).
|
|
3
|
+
*
|
|
4
|
+
* On 2026-09-09 `cockpit do-everything` on the founder's Windows box reached
|
|
5
|
+
* "windows task repair converged", printed `Error: read ENOTCONN` from
|
|
6
|
+
* `child_process.spawn`, and **exited 0**. That is the worse half of that
|
|
7
|
+
* incident. A repair that fails loudly gets looked at; a repair that dies
|
|
8
|
+
* while claiming success stops anybody looking at all, and that machine had
|
|
9
|
+
* not delivered a session in 29 days.
|
|
10
|
+
*
|
|
11
|
+
* **What was measured** (Node 22.20, win32), because the fix depends entirely
|
|
12
|
+
* on which shapes can produce a zero:
|
|
13
|
+
*
|
|
14
|
+
* | shape | exit code |
|
|
15
|
+
* | -- | -- |
|
|
16
|
+
* | `throw` from a `setImmediate` after `process.exitCode = 0` | 1 |
|
|
17
|
+
* | an unhandled promise rejection | 1 |
|
|
18
|
+
* | an `error` event on an inherited stdio stream | 1 |
|
|
19
|
+
* | a `throw` inside a `process.on("exit")` handler | **0** |
|
|
20
|
+
*
|
|
21
|
+
* Node's own defaults are honest about every asynchronous crash. The zero can
|
|
22
|
+
* only come from the window where OUR code is the last thing holding the exit
|
|
23
|
+
* code — and `cli.ts` held it in the most optimistic way available:
|
|
24
|
+
*
|
|
25
|
+
* const exitCode = await runCockpitCli(argv);
|
|
26
|
+
* process.exitCode = exitCode;
|
|
27
|
+
*
|
|
28
|
+
* `process.exitCode` starts at 0. So *anything* that prevents that assignment
|
|
29
|
+
* from being reached, or that runs after it, reports success by default.
|
|
30
|
+
* Success was the resting state and had to be disproved.
|
|
31
|
+
*
|
|
32
|
+
* **The fix inverts that: success must be earned.** The entry point sets a
|
|
33
|
+
* non-zero code BEFORE doing any work, and only a command that actually
|
|
34
|
+
* returned is allowed to lower it. Nothing here has to enumerate the ways a
|
|
35
|
+
* run can die, which matters because the specific `read ENOTCONN` path has not
|
|
36
|
+
* been reproduced off that machine — see `docs/runbooks/` and the ticket. A
|
|
37
|
+
* guard that depends on correctly predicting the crash is a guard that works
|
|
38
|
+
* on the crashes you already knew about.
|
|
39
|
+
*
|
|
40
|
+
* The guard only ever RAISES a zero. It can never turn a real failure into a
|
|
41
|
+
* success, and it never overwrites a code a command chose.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Distinct from 1 on purpose. `1` is "this ran and the answer is no"; 70 is
|
|
45
|
+
* "this never finished, so there is no answer". A scheduled task's log can
|
|
46
|
+
* then tell a failed repair from an abandoned one without parsing prose.
|
|
47
|
+
*/
|
|
48
|
+
export const EXIT_DIED_MID_RUN = 70;
|
|
49
|
+
/** Stages nest — the subcommand, then the invariant running inside it. */
|
|
50
|
+
const openStages = [];
|
|
51
|
+
let commandReturned = false;
|
|
52
|
+
let installed = false;
|
|
53
|
+
export function beginStage(stage) {
|
|
54
|
+
openStages.push(stage);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Closes the innermost stage. Takes the name so an unbalanced pair is visible:
|
|
58
|
+
* closing a stage that is not the open one would make a later crash blame the
|
|
59
|
+
* wrong stage, which is worse than naming none.
|
|
60
|
+
*/
|
|
61
|
+
export function endStage(stage) {
|
|
62
|
+
const top = openStages[openStages.length - 1];
|
|
63
|
+
if (top !== stage) {
|
|
64
|
+
console.error("[cockpit-cli] stage mismatch", JSON.stringify({ reason: "stage_mismatch", closing: stage, open: top ?? null }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
openStages.pop();
|
|
68
|
+
}
|
|
69
|
+
/** The innermost stage still running, or null. Naming only — never a verdict. */
|
|
70
|
+
export function openStage() {
|
|
71
|
+
return openStages[openStages.length - 1] ?? null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Called by the entry point when the command function actually returned a
|
|
75
|
+
* code. This is the ONE fact the guard trusts: not "did a stage close", not
|
|
76
|
+
* "were there failures", but "did control come back".
|
|
77
|
+
*/
|
|
78
|
+
export function markCommandReturned() {
|
|
79
|
+
commandReturned = true;
|
|
80
|
+
}
|
|
81
|
+
/** Test seam — the guard is process-global, so a suite must reset it. */
|
|
82
|
+
export function resetCrashGuardForTest() {
|
|
83
|
+
openStages.length = 0;
|
|
84
|
+
commandReturned = false;
|
|
85
|
+
installed = false;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Error facts that are safe to log: names and codes, never a message. An error
|
|
89
|
+
* message routinely carries a path under somebody's home directory, and this
|
|
90
|
+
* line is read off a fleet-visible log.
|
|
91
|
+
*/
|
|
92
|
+
function describeCrash(error) {
|
|
93
|
+
if (!(error instanceof Error)) {
|
|
94
|
+
return { error_name: typeof error, error_code: null, error_syscall: null };
|
|
95
|
+
}
|
|
96
|
+
const record = error;
|
|
97
|
+
return {
|
|
98
|
+
error_name: error.name,
|
|
99
|
+
error_code: record.code ?? null,
|
|
100
|
+
error_syscall: record.syscall ?? null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Installs the guard. Idempotent — the entry point is also imported by tests.
|
|
105
|
+
*
|
|
106
|
+
* `uncaughtExceptionMonitor`, not `uncaughtException`, and the difference is
|
|
107
|
+
* the whole safety argument: the monitor OBSERVES and lets Node's default
|
|
108
|
+
* handling proceed, so the stack trace still prints and the process still
|
|
109
|
+
* dies. Registering `uncaughtException` would SUPPRESS the default, leaving
|
|
110
|
+
* this guard to re-implement crashing correctly — which is precisely how a
|
|
111
|
+
* safety net becomes the thing that swallows the error. Since Node 15 an
|
|
112
|
+
* unhandled rejection is raised as an uncaught exception by default, so the
|
|
113
|
+
* monitor names those too without a second listener.
|
|
114
|
+
*/
|
|
115
|
+
export function installCrashGuard(options = {}) {
|
|
116
|
+
const target = options.process ?? process;
|
|
117
|
+
if (installed)
|
|
118
|
+
return;
|
|
119
|
+
installed = true;
|
|
120
|
+
target.on("uncaughtExceptionMonitor", (error) => {
|
|
121
|
+
console.error("[cockpit-cli] crashed", JSON.stringify({
|
|
122
|
+
reason: "uncaught_exception",
|
|
123
|
+
stage: openStage(),
|
|
124
|
+
...describeCrash(error),
|
|
125
|
+
}));
|
|
126
|
+
});
|
|
127
|
+
target.on("exit", () => {
|
|
128
|
+
if (commandReturned)
|
|
129
|
+
return;
|
|
130
|
+
// `EXIT_DIED_MID_RUN` has to be in this set, and leaving it out made this
|
|
131
|
+
// whole branch dead code in the shipped binary: `cli.ts` presets 70 BEFORE
|
|
132
|
+
// any work, so by the time `exit` fires the code is 70 (quiet death) or 1
|
|
133
|
+
// (Node overwrote it on an uncaught exception) and never 0. The exit CODE
|
|
134
|
+
// was right; the line that names the stage never printed once. Caught in
|
|
135
|
+
// review by transcribing this file plus `cli.ts` and running every death
|
|
136
|
+
// shape — not by the unit test, which passed only because it hand-set 0,
|
|
137
|
+
// a state the entry point cannot reach.
|
|
138
|
+
if (target.exitCode !== 0 &&
|
|
139
|
+
target.exitCode !== undefined &&
|
|
140
|
+
target.exitCode !== EXIT_DIED_MID_RUN) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.error("[cockpit-cli] died mid-run", JSON.stringify({
|
|
144
|
+
reason: "died_mid_run",
|
|
145
|
+
stage: openStage(),
|
|
146
|
+
corrected_exit_code: EXIT_DIED_MID_RUN,
|
|
147
|
+
}));
|
|
148
|
+
target.exitCode = EXIT_DIED_MID_RUN;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Runs `body` as a named stage so a crash inside it can say where it was.
|
|
153
|
+
*
|
|
154
|
+
* The `finally` closes the stage on both paths on purpose: a stage that THREW
|
|
155
|
+
* is finished, and its exception is already travelling to somebody who will
|
|
156
|
+
* set a real exit code. Naming is all this does — the exit-code decision rests
|
|
157
|
+
* on `markCommandReturned`, never on whether a stage happens to be open.
|
|
158
|
+
*/
|
|
159
|
+
export async function withStage(stage, body) {
|
|
160
|
+
beginStage(stage);
|
|
161
|
+
try {
|
|
162
|
+
return await body();
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
endStage(stage);
|
|
166
|
+
}
|
|
167
|
+
}
|
package/dist/process-runner.js
CHANGED
|
@@ -49,6 +49,34 @@ export function createCapturedExecRunner(options = {}) {
|
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Which stdio a re-exec may inherit (BLI-4110).
|
|
54
|
+
*
|
|
55
|
+
* `stdio: "inherit"` hands the child all three of the parent's handles. Under
|
|
56
|
+
* the Windows Task Scheduler — and any other non-interactive host — the
|
|
57
|
+
* parent's stdin is not a console: it can be a pipe nobody is writing to, or a
|
|
58
|
+
* socket that was never connected, and reading it raises `read ENOTCONN` from
|
|
59
|
+
* inside `child_process.spawn`. That is the error `cockpit do-everything`
|
|
60
|
+
* printed on the founder's box on 2026-09-09, from `reexecDoctor`, in a
|
|
61
|
+
* non-interactive shell.
|
|
62
|
+
*
|
|
63
|
+
* **stdout and stderr are still inherited, always.** They are what makes a
|
|
64
|
+
* scheduled run's output land in the log a person later reads, and dropping
|
|
65
|
+
* them to fix stdin would trade a crash for a silence — which is the failure
|
|
66
|
+
* this whole ticket is about.
|
|
67
|
+
*
|
|
68
|
+
* **stdin is inherited only when there is a console to read from.** A
|
|
69
|
+
* re-execed `do-everything` under a scheduler has nothing to type at it; the
|
|
70
|
+
* handle was pure liability. `"ignore"` gives the child a real, closed stdin
|
|
71
|
+
* rather than a broken one, so a child that does read it gets EOF instead of
|
|
72
|
+
* an error.
|
|
73
|
+
*
|
|
74
|
+
* Pure and exported so both host families are testable without a real TTY,
|
|
75
|
+
* per the supported-fleet contract in AGENTS.md.
|
|
76
|
+
*/
|
|
77
|
+
export function interactiveStdio(input) {
|
|
78
|
+
return [input.stdinIsTty === true ? "inherit" : "ignore", "inherit", "inherit"];
|
|
79
|
+
}
|
|
52
80
|
export function createInteractiveExecRunner(options = {}) {
|
|
53
81
|
return (command, args, runOptions) => new Promise((resolve) => {
|
|
54
82
|
const env = runOptions?.env ?? options.env ?? process.env;
|
|
@@ -56,8 +84,9 @@ export function createInteractiveExecRunner(options = {}) {
|
|
|
56
84
|
platform: options.platform,
|
|
57
85
|
env,
|
|
58
86
|
});
|
|
87
|
+
const stdio = interactiveStdio({ stdinIsTty: process.stdin.isTTY });
|
|
59
88
|
const child = spawn(invocation.command, invocation.args, {
|
|
60
|
-
stdio
|
|
89
|
+
stdio,
|
|
61
90
|
env,
|
|
62
91
|
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
63
92
|
});
|
|
@@ -66,6 +95,15 @@ export function createInteractiveExecRunner(options = {}) {
|
|
|
66
95
|
if (settled)
|
|
67
96
|
return;
|
|
68
97
|
settled = true;
|
|
98
|
+
// Named, because a spawn that never started and a child that ran and
|
|
99
|
+
// failed both used to arrive here as a bare code 1. Metadata only.
|
|
100
|
+
const record = error;
|
|
101
|
+
console.error("[process-runner] spawn failed", JSON.stringify({
|
|
102
|
+
reason: "spawn_failed",
|
|
103
|
+
error_code: record.code ?? null,
|
|
104
|
+
error_syscall: record.syscall ?? null,
|
|
105
|
+
stdin: stdio[0],
|
|
106
|
+
}));
|
|
69
107
|
resolve({ code: 1, stdout: "", stderr: error.message });
|
|
70
108
|
});
|
|
71
109
|
child.on("close", (code) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.99",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"pretypecheck": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
|
|
25
25
|
"typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
|
|
26
26
|
"pretest": "node ../../scripts/build-workspace-dep.mjs @bli-cockpit/cli",
|
|
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-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
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-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.26",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.30",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.43"
|
|
33
33
|
}
|
|
34
34
|
}
|