@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 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;
@@ -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
- const fixed = await invariant.fix(context, checked);
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 start|dismiss|undo
18
- * <id>` moves one card. Positional shape modelled on `autostart`: an optional
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 move.`);
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
- 'Usage: cockpit search "<words>" [--kind doc,msg,issue,note,memory] [--limit <n>] [--json]',
159
+ `Usage: cockpit search "<words>" [--kind ${SEARCH_KIND_LIST}] [--limit <n>] [--json]`,
137
160
  "",
138
- "One bar over five corpora: documents, messages, issues, meeting notes and memory.",
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
- "--kind narrows to one or more corpora, comma separated. Omit it and all five are searched.",
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
- "Every result is scoped by what YOU may read: four of the five corpora are searched on your",
152
- "own database session, so a document or a channel you cannot open is not in the list.",
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
- " cockpit search \"<words>\" [--kind doc,msg,issue,note,memory] [--limit <n>] [--dashboard-url <url>] [--json]",
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");
@@ -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 only one
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 with their sentence typed,
17
- * the one whose budget QA tick 18 caught it losing, and the one that runs on
18
- * every turn — so it is the only one whose miss rate means anything as a
19
- * daily number. The file holds all three; a future gauge that wants
20
- * SessionStart or Stop reads the same rows with the same function.
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 window = summariseMemoryHookWindow(parsed.file, "prompt", {
41
- now: options.now ?? new Date(),
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",
@@ -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
- const staleName = row.verdict === "stale" && row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
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.98");
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
+ }
@@ -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: "inherit",
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.98",
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.25",
31
- "@bli-cockpit/mcp": "0.1.29",
32
- "@bli-cockpit/telemetry-core": "0.1.42"
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
  }