@bli-cockpit/cli 0.2.50 → 0.2.52

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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `cockpit clean` — what this machine is holding, why, and what goes.
3
+ *
4
+ * BLI-3619. The tick prunes on its own once a day; this is the door a person
5
+ * opens when a laptop is full now. It prints the same buckets the retention
6
+ * rule reasons in, one line each with its reason, and only then deletes:
7
+ *
8
+ * committed, past the 48 h retry window goes
9
+ * committed, still inside the window kept — unless the cap needs it
10
+ * uncommitted kept, always, and named
11
+ * the ledger cannot say kept, always, and named apart
12
+ *
13
+ * `--dry-run` stops after the printing. `--all-committed` drops the retry
14
+ * window — every committed spare goes now — and is also the only thing that
15
+ * removes a `manual-study-evidence-vault-*` directory, and then only when every
16
+ * object inside it is committed. Nothing here can delete evidence the upload
17
+ * ledger did not vouch for; that line is in `disk-retention.ts` and this
18
+ * command has no way to cross it.
19
+ */
20
+ import crypto from "node:crypto";
21
+ import fs from "node:fs/promises";
22
+ import path from "node:path";
23
+ import { writeLine } from "./cli-io.js";
24
+ import { planFromDisk, runStagingPrune } from "../disk-prune.js";
25
+ import { MANUAL_VAULT_PREFIX, readDiskFootprint, } from "../disk-usage.js";
26
+ import { readRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
27
+ import { getCollectorRuntimePaths, } from "../local-state.js";
28
+ import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
29
+ export async function runClean(command, io) {
30
+ const paths = getCollectorRuntimePaths(command.homeDir);
31
+ const now = new Date();
32
+ const env = io.env ?? process.env;
33
+ const footprint = await readDiskFootprint(paths, now);
34
+ const plan = await planFromDisk(paths, env, { allCommitted: command.allCommitted }, now);
35
+ const vaults = command.allCommitted
36
+ ? await vaultsSafeToRemove(paths, footprint.vaults)
37
+ : [];
38
+ if (!command.json) {
39
+ for (const line of cleanLines(footprint, plan, vaults, command)) {
40
+ writeLine(io.stdout, line);
41
+ }
42
+ }
43
+ if (command.dryRun) {
44
+ if (command.json)
45
+ writeLine(io.stdout, cleanJson(footprint, plan, vaults, null));
46
+ return 0;
47
+ }
48
+ const pruned = await runStagingPrune(paths, {
49
+ env,
50
+ now,
51
+ force: true,
52
+ allCommitted: command.allCommitted,
53
+ });
54
+ // The two smaller footprints go with it: a rotation pass sweeps the archives
55
+ // outside the keep window, and each removed vault is named on its own line.
56
+ await rotateCollectorLogsBestEffort(paths);
57
+ const removedVaults = await removeVaults(paths.state_dir, vaults, io);
58
+ if (command.json) {
59
+ writeLine(io.stdout, cleanJson(footprint, plan, vaults, pruned));
60
+ return pruned.status === "fail" ? 1 : 0;
61
+ }
62
+ writeLine(io.stdout, `Freed ${mb(pruned.deleted_bytes)} MB across ${pruned.deleted_files} file(s) and ${pruned.removed_packs} pack(s)${removedVaults > 0 ? `, plus ${removedVaults} evidence vault(s)` : ""}.`);
63
+ if (pruned.cap_blocked_by_uncommitted) {
64
+ writeLine(io.stdout, `Still ${mb(pruned.bytes_after)} MB over a ${mb(pruned.cap_bytes)} MB cap, held by ${pruned.cap_blocked_count} object(s) the upload ledger cannot vouch for. Run \`cockpit sync\` to try delivering them; nothing here will delete them.`);
65
+ }
66
+ return pruned.status === "fail" ? 1 : 0;
67
+ }
68
+ /** The bucket table a person reads before anything is deleted. */
69
+ export function cleanLines(footprint, plan, vaults, command) {
70
+ const staging = footprint.staging;
71
+ const lines = [
72
+ command.dryRun ? "Tower clean (dry run)" : "Tower clean",
73
+ ` staging ${mb(staging.total_bytes)} MB across ${staging.pack_count} pack(s), ${staging.object_count} file(s)`,
74
+ ` going ${mb(plan.deleted_bytes + plan.empty_pack_manifest_bytes)} MB, ${plan.delete.length} file(s) — ${command.allCommitted
75
+ ? "every committed copy (--all-committed)"
76
+ : `committed and past the ${Math.round(plan.retention_ms / 3_600_000)} h retry window`}`,
77
+ ` kept in window ${mb(plan.kept_in_window.bytes)} MB, ${plan.kept_in_window.count} file(s) — committed, still inside the retry window`,
78
+ ` kept uncommitted ${mb(plan.kept_uncommitted.bytes)} MB, ${plan.kept_uncommitted.count} file(s) — never delivered; retention will never delete these`,
79
+ ` kept unknown ${mb(plan.kept_unknown.bytes)} MB, ${plan.kept_unknown.count} file(s) — the upload ledger no longer reaches back this far and cannot say`,
80
+ ` rotated logs ${mb(footprint.logs.total_bytes)} MB (${footprint.logs.archive_count} archive(s))`,
81
+ ` spool ${mb(footprint.spool_bytes)} MB`,
82
+ ];
83
+ for (const vault of footprint.vaults) {
84
+ const removable = vaults.some((entry) => entry.name === vault.name);
85
+ lines.push(` ${vault.name} ${mb(vault.byte_size)} MB, ${vault.file_count} file(s) — ${removable
86
+ ? "every object inside is committed; --all-committed removes it"
87
+ : "a one-off you made by hand; nothing here removes it"}`);
88
+ }
89
+ if (plan.cap_blocked_by_uncommitted) {
90
+ lines.push(` over cap ${mb(plan.bytes_after)} MB against a ${mb(plan.cap_bytes)} MB cap, held by ${plan.cap_blocked_count} object(s) no ledger vouches for (staging_cap_blocked_by_uncommitted)`);
91
+ }
92
+ return lines;
93
+ }
94
+ function cleanJson(footprint, plan, vaults, pruned) {
95
+ return JSON.stringify({
96
+ staging: {
97
+ pack_count: footprint.staging.pack_count,
98
+ object_count: footprint.staging.object_count,
99
+ total_bytes: footprint.staging.total_bytes,
100
+ committed_bytes: footprint.staging.committed_bytes,
101
+ uncommitted_bytes: footprint.staging.uncommitted_bytes,
102
+ unknown_bytes: footprint.staging.unknown_bytes,
103
+ oldest_uncommitted_at: footprint.staging.oldest_uncommitted_at,
104
+ },
105
+ rotated_log_bytes: footprint.logs.total_bytes,
106
+ spool_bytes: footprint.spool_bytes,
107
+ vaults: footprint.vaults,
108
+ plan: {
109
+ delete_count: plan.delete.length,
110
+ delete_bytes: plan.deleted_bytes + plan.empty_pack_manifest_bytes,
111
+ kept_in_window: plan.kept_in_window,
112
+ kept_uncommitted: plan.kept_uncommitted,
113
+ kept_unknown: plan.kept_unknown,
114
+ cap_bytes: plan.cap_bytes,
115
+ cap_blocked_by_uncommitted: plan.cap_blocked_by_uncommitted,
116
+ },
117
+ removable_vaults: vaults.map((vault) => vault.name),
118
+ pruned,
119
+ }, null, 2);
120
+ }
121
+ /**
122
+ * A hand-made evidence vault may only go when EVERY object inside it is
123
+ * committed. The vault is not a pack — it has no manifest — so each file is
124
+ * hashed and looked up. That is why it is on the explicit `--all-committed`
125
+ * door and never on the tick.
126
+ */
127
+ async function vaultsSafeToRemove(paths, vaults) {
128
+ if (vaults.length === 0)
129
+ return [];
130
+ const cursor = await readRawEvidenceCursor(paths);
131
+ const committed = new Set(Object.keys(cursor.objects));
132
+ const safe = [];
133
+ for (const vault of vaults) {
134
+ const dir = path.join(paths.state_dir, vault.name);
135
+ if (await everyFileCommitted(dir, committed))
136
+ safe.push(vault);
137
+ }
138
+ return safe;
139
+ }
140
+ async function everyFileCommitted(dir, committed) {
141
+ const stack = [dir];
142
+ let sawFile = false;
143
+ while (stack.length > 0) {
144
+ const current = stack.pop();
145
+ if (!current)
146
+ continue;
147
+ const entries = await fs
148
+ .readdir(current, { withFileTypes: true })
149
+ .catch(() => []);
150
+ for (const entry of entries) {
151
+ const full = path.join(current, entry.name);
152
+ if (entry.isDirectory()) {
153
+ stack.push(full);
154
+ continue;
155
+ }
156
+ if (!entry.isFile())
157
+ continue;
158
+ sawFile = true;
159
+ const bytes = await fs.readFile(full).catch(() => null);
160
+ if (!bytes)
161
+ return false;
162
+ const digest = crypto.createHash("sha256").update(bytes).digest("hex");
163
+ if (!committed.has(digest))
164
+ return false;
165
+ }
166
+ }
167
+ return sawFile;
168
+ }
169
+ async function removeVaults(stateDir, vaults, io) {
170
+ let removed = 0;
171
+ for (const vault of vaults) {
172
+ if (!vault.name.startsWith(MANUAL_VAULT_PREFIX))
173
+ continue;
174
+ const done = await fs
175
+ .rm(path.join(stateDir, vault.name), { recursive: true, force: true })
176
+ .then(() => true, () => false);
177
+ if (!done)
178
+ continue;
179
+ removed += 1;
180
+ writeLine(io.stderr, `[collector prune] removed an evidence vault ${JSON.stringify({
181
+ reason: "vault_all_committed",
182
+ vault: vault.name,
183
+ byte_size: vault.byte_size,
184
+ file_count: vault.file_count,
185
+ })}`);
186
+ }
187
+ return removed;
188
+ }
189
+ function mb(bytes) {
190
+ return (bytes / (1024 * 1024)).toFixed(1);
191
+ }
@@ -9,6 +9,9 @@ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSIO
9
9
  import { normalizeCollectionRoots } from "../root-normalization.js";
10
10
  import { detectSecondCockpitInstall } from "../second-install.js";
11
11
  import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
12
+ import { runStagingPrune } from "../disk-prune.js";
13
+ import { retentionOptionsFromEnv } from "../disk-retention.js";
14
+ import { readDiskFootprint } from "../disk-usage.js";
12
15
  import { runBackfillCommand } from "./backfill.js";
13
16
  import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
14
17
  import { createInteractiveExecRunner } from "../process-runner.js";
@@ -100,6 +103,15 @@ function doctorInvariants() {
100
103
  check: (context) => context.deps.checkGc(context),
101
104
  fix: (context, _state) => context.deps.fixGc(context),
102
105
  },
106
+ // BLI-3619. Runs after the GC because it answers the question the GC
107
+ // cannot: the GC only removes a whole pack whose every file is committed,
108
+ // so one undelivered file kept 20 GB alive on the reference Mac. This row
109
+ // reads the disk per FILE against the upload ledger and says what is on it.
110
+ {
111
+ id: "disk-bounded",
112
+ check: (context) => context.deps.checkDisk(context),
113
+ fix: (context, _state) => context.deps.fixDisk(context),
114
+ },
103
115
  {
104
116
  id: "sync-fresh",
105
117
  check: (context) => context.deps.checkSync(context),
@@ -126,6 +138,8 @@ function defaultDoctorDeps(hooks) {
126
138
  fixBackfill: fixBackfillState,
127
139
  checkGc: checkGcState,
128
140
  fixGc: fixGcState,
141
+ checkDisk: checkDiskState,
142
+ fixDisk: fixDiskState,
129
143
  checkSync: checkSyncState,
130
144
  fixSync: fixSyncState,
131
145
  };
@@ -449,6 +463,60 @@ async function fixGcState(context) {
449
463
  }
450
464
  return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
451
465
  }
466
+ /**
467
+ * BLI-3619: what Cockpit is holding on this machine, in one line.
468
+ *
469
+ * Green means the staging tree fits under the cap. Over the cap is `needs_fix`
470
+ * and the fix is the same prune the tick runs, forced past its daily throttle;
471
+ * if it is still over afterwards, the message names the one command that goes
472
+ * further. Every number here is metadata — byte counts and file counts, never
473
+ * a path.
474
+ */
475
+ async function checkDiskState(context) {
476
+ const footprint = await readDiskFootprint(getCollectorRuntimePaths(context.command.homeDir));
477
+ const { capBytes } = retentionOptionsFromEnv(context.io.env);
478
+ const message = diskRowMessage(footprint, capBytes);
479
+ return footprint.staging.total_bytes > capBytes
480
+ ? needsFix("disk-bounded", "over_staging_cap", message)
481
+ : ok("disk-bounded", "within_staging_cap", message);
482
+ }
483
+ async function fixDiskState(context) {
484
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
485
+ const pruned = await runStagingPrune(paths, {
486
+ env: context.io.env,
487
+ force: true,
488
+ });
489
+ if (pruned.status === "fail") {
490
+ return fail("disk-bounded", pruned.reason, "the prune could not run; nothing was deleted");
491
+ }
492
+ if (pruned.status === "skipped") {
493
+ return skipped("disk-bounded", pruned.reason, "cleanup is switched off");
494
+ }
495
+ const footprint = await readDiskFootprint(paths);
496
+ const message = diskRowMessage(footprint, pruned.cap_bytes);
497
+ if (!pruned.cap_blocked_by_uncommitted) {
498
+ return ok("disk-bounded", `freed_${pruned.deleted_files}`, `freed ${mib(pruned.deleted_bytes)} MB; ${message}`);
499
+ }
500
+ // Deliberately still `ok`: staging over the cap because evidence has not been
501
+ // accepted yet is the collector working, not a machine to repair. The row
502
+ // names the blockage and the one command that goes further.
503
+ return ok("disk-bounded", "staging_cap_blocked_by_uncommitted", `freed ${mib(pruned.deleted_bytes)} MB; ${message}; ${pruned.cap_blocked_count} object(s) the upload ledger cannot vouch for are holding the rest — run \`cockpit sync\` to deliver them, or \`cockpit clean --all-committed\` to drop every accepted copy now`);
504
+ }
505
+ function diskRowMessage(footprint, capBytes) {
506
+ const staging = footprint.staging;
507
+ const parts = [
508
+ `staging ${mib(staging.total_bytes)} MB (${mib(staging.committed_bytes)} committed / ${mib(staging.uncommitted_bytes)} uncommitted / ${mib(staging.unknown_bytes)} unknown) against a ${mib(capBytes)} MB cap`,
509
+ `logs ${mib(footprint.logs.total_bytes)} MB`,
510
+ `spool ${mib(footprint.spool_bytes)} MB`,
511
+ ];
512
+ for (const vault of footprint.vaults) {
513
+ parts.push(`${vault.name} ${mib(vault.byte_size)} MB (a one-off; \`cockpit clean --all-committed\` removes it only if every file in it is accepted)`);
514
+ }
515
+ return parts.join("; ");
516
+ }
517
+ function mib(bytes) {
518
+ return (bytes / (1024 * 1024)).toFixed(1);
519
+ }
452
520
  async function checkSyncState(context) {
453
521
  const roots = await doctorRoots(context);
454
522
  if (roots.length === 0) {
@@ -102,7 +102,10 @@ export async function runJarvis(command, io) {
102
102
  const promptStartedAt = Date.now();
103
103
  const oneShotPrompt = await resolveOneShotPrompt(command, io);
104
104
  if (oneShotPrompt !== null) {
105
- return sendOneTurn({
105
+ // A one-shot turn has nothing above it — this process printed no earlier
106
+ // answer — so it sends no previous answer and nothing is ever revised. The
107
+ // `sent_previous_answer: false` field on the answered line says so.
108
+ const turn = await sendOneTurn({
106
109
  command,
107
110
  dashboardUrl,
108
111
  deviceToken: session.device_token,
@@ -114,27 +117,38 @@ export async function runJarvis(command, io) {
114
117
  promptMs: command.prompt ? null : Date.now() - promptStartedAt,
115
118
  },
116
119
  }, oneShotPrompt, io);
120
+ return turn.exitCode;
117
121
  }
118
122
  if (command.json) {
119
123
  throw new Error("cockpit jarvis --json needs --prompt, positional text, or piped stdin.");
120
124
  }
121
125
  writeLine(io.stdout, "JARVIS terminal chat. Type /exit to leave.");
126
+ // BLI-3567: the answer this session last printed, and the question it
127
+ // answered. It is what a correction typed on the next line can rewrite —
128
+ // kept here rather than read back off the ledger, for the same reason the
129
+ // browsers send their own bubble: what has to be revised is what is on the
130
+ // person's screen. A failed turn deliberately does not replace it; a "that
131
+ // did not go through" line is not an answer.
132
+ let previous = null;
122
133
  while (true) {
123
134
  const prompt = (await readLine(io, "you> ")).trim();
124
135
  if (!prompt)
125
136
  continue;
126
137
  if (prompt === "/exit" || prompt === "/quit")
127
138
  return 0;
128
- const exitCode = await sendOneTurn({
139
+ const turn = await sendOneTurn({
129
140
  command,
130
141
  dashboardUrl,
131
142
  deviceToken: session.device_token,
132
143
  // The typing wait belongs to the person, not to the turn, so no prompt
133
144
  // span is reported for an interactive turn.
134
145
  boot: { bootMs, sessionMs, promptMs: null },
146
+ previous,
135
147
  }, prompt, io);
136
- if (exitCode !== 0)
137
- return exitCode;
148
+ if (turn.exitCode !== 0)
149
+ return turn.exitCode;
150
+ if (turn.reply)
151
+ previous = { answer: turn.reply, question: prompt };
138
152
  }
139
153
  }
140
154
  /**
@@ -242,7 +256,7 @@ async function sendOneTurn(context, prompt, io) {
242
256
  const read = await readAttachedImage(context.command.imagePath);
243
257
  if (!read.ok) {
244
258
  writeAttachmentRefusal(context.command, io, read.refusal, context.command.imagePath);
245
- return 1;
259
+ return { exitCode: 1, reply: null };
246
260
  }
247
261
  attachment = read;
248
262
  }
@@ -277,12 +291,18 @@ async function sendOneTurn(context, prompt, io) {
277
291
  // BLI-3484: which day's page this turn is about. Sent verbatim — the
278
292
  // dashboard decides what counts as a date and whose day it is.
279
293
  date: context.command.date,
294
+ // BLI-3567: the answer this session last printed, so a correction can
295
+ // rewrite the paragraph it contradicts. Absent on a one-shot turn and
296
+ // on the first turn of a session, which is how the dashboard knows
297
+ // there is nothing above to revise.
298
+ previousAnswer: context.previous?.answer,
299
+ previousQuestion: context.previous?.question,
280
300
  },
281
301
  log,
282
302
  });
283
303
  if (!requested.ok) {
284
304
  writeFailure(context.command, io, requested.reason, towerFailureDetail(requested.reason, requested.detail));
285
- return 1;
305
+ return { exitCode: 1, reply: null };
286
306
  }
287
307
  // Live trace lines go to a person as they land, never to a `--json`
288
308
  // consumer: that contract is exactly one object on stdout, so the events are
@@ -302,6 +322,17 @@ async function sendOneTurn(context, prompt, io) {
302
322
  liveTraceLines += 1;
303
323
  },
304
324
  onToken: (event) => live.token(event),
325
+ // BLI-3567: the paragraph being rewritten says so while it happens. The
326
+ // terminal cannot mark the paragraph itself — it printed it turns ago — so
327
+ // the pending state is one dim line, in the same words the browsers use.
328
+ onRevision: (event) => {
329
+ if (context.command.json)
330
+ return;
331
+ if (event.revision?.status !== "pending")
332
+ return;
333
+ live.interrupt();
334
+ writeLine(io.stdout, dim(` ${ANSWER_UPDATING_LINE}`, colorEnabled(io)));
335
+ },
305
336
  onNote: (reason, detail) => {
306
337
  log(`[jarvis cli] stream note ${JSON.stringify({
307
338
  reason,
@@ -312,7 +343,7 @@ async function sendOneTurn(context, prompt, io) {
312
343
  if (!turn.ok) {
313
344
  live.abandon();
314
345
  writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
315
- return 1;
346
+ return { exitCode: 1, reply: null };
316
347
  }
317
348
  const body = turn.final;
318
349
  const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
@@ -320,7 +351,7 @@ async function sendOneTurn(context, prompt, io) {
320
351
  live.abandon();
321
352
  const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
322
353
  writeFailure(context.command, io, "turn_failed", reason);
323
- return 1;
354
+ return { exitCode: 1, reply: null };
324
355
  }
325
356
  // A streaming server may leave the settled trace out of the final event
326
357
  // because it already sent every step live; the activity we collected is that
@@ -366,6 +397,10 @@ async function sendOneTurn(context, prompt, io) {
366
397
  // through `writeReply`, which is byte-for-byte what this command printed
367
398
  // before, BLI-3570's dim link line included.
368
399
  live.settle(body.reply, subject, body.revised === true);
400
+ // BLI-3567: what the correction did to the answer above, printed under
401
+ // this turn's reply because that is the only place a terminal has.
402
+ if (body.revision)
403
+ writeRevision(context.command, io, body.revision);
369
404
  // Only when nothing was drawn live — otherwise every tool would print twice.
370
405
  if (liveTraceLines === 0)
371
406
  writeTraceBlock(io, trace);
@@ -389,6 +424,13 @@ async function sendOneTurn(context, prompt, io) {
389
424
  // dashboard means the answer landed all at once.
390
425
  streamed_chars: turn.draft.length,
391
426
  revised: body.revised === true,
427
+ // BLI-3567: whether this turn was told there was an answer above it, and
428
+ // what the correction did to it. `sent_previous_answer: false` is a
429
+ // one-shot turn; a status with no revision at all is a dashboard that
430
+ // predates the step.
431
+ sent_previous_answer: Boolean(context.previous),
432
+ revision: body.revision?.status ?? null,
433
+ revision_reason: body.revision?.reason ?? null,
392
434
  // BLI-3582: `elapsed_ms` above is the whole wait as this side felt it;
393
435
  // these three say which part of it was the dashboard's prep, which was
394
436
  // the model's first token, and how long the socket stayed silent before
@@ -406,7 +448,7 @@ async function sendOneTurn(context, prompt, io) {
406
448
  timing: turn.timing,
407
449
  }),
408
450
  })}`);
409
- return 0;
451
+ return { exitCode: 0, reply: body.reply };
410
452
  }
411
453
  /**
412
454
  * The terminal's own spans as log fields (BLI-3591).
@@ -528,6 +570,56 @@ function createLiveAnswer(command, io) {
528
570
  * not exist.
529
571
  */
530
572
  const ANSWER_REVISED_LINE = "revised: I checked that against its sources and changed what they did not back up";
573
+ /**
574
+ * The three lines a correction's rewrite prints (BLI-3567).
575
+ *
576
+ * The browsers replace the paragraph where it stands. A terminal cannot —
577
+ * stdout is a river — so it says the same three things in sequence instead:
578
+ * that a rewrite is under way, what the paragraph now says, and that the
579
+ * printed one above it did not change. The last of those is the honest half:
580
+ * without it a person would have two versions on screen and no idea which one
581
+ * JARVIS believes.
582
+ *
583
+ * `ANSWER_UPDATING_LINE` is the terminal's copy of the browsers'
584
+ * `ANSWER_UPDATING_LABEL` (`apps/dashboard/src/lib/webchat/tool-trace.ts`),
585
+ * word for word. This package cannot import from the dashboard, so it is
586
+ * written out here rather than shared through a dependency that does not exist
587
+ * — the same arrangement `ANSWER_REVISED_LINE` above already has.
588
+ */
589
+ const ANSWER_UPDATING_LINE = "(updating...)";
590
+ const ANSWER_CORRECTED_LINE = "revised after your correction";
591
+ const ANSWER_CORRECTED_NOTE_LINE = "The paragraph printed above is unchanged — a terminal cannot rewrite what it already " +
592
+ "printed. This is what it says now.";
593
+ const ANSWER_REWRITE_UNVERIFIED_LINE = "Your correction is recorded. I could not verify a rewrite of that paragraph against its " +
594
+ "sources, so it stays as it was.";
595
+ /**
596
+ * Print what a correction did to the answer above, once the turn has settled.
597
+ *
598
+ * `--json` prints nothing: that contract is exactly one object on stdout, and
599
+ * the revision rides it as a field. Every branch either prints or is a
600
+ * deliberate no-op with a stderr line behind it in `sendOneTurn`.
601
+ */
602
+ function writeRevision(command, io, revision) {
603
+ if (command.json)
604
+ return;
605
+ const styled = colorEnabled(io);
606
+ if (revision.status === "unverified") {
607
+ writeLine(io.stdout, "");
608
+ writeLine(io.stdout, dim(` — ${ANSWER_REWRITE_UNVERIFIED_LINE}`, styled));
609
+ return;
610
+ }
611
+ if (revision.status !== "revised")
612
+ return;
613
+ const paragraphs = Array.isArray(revision.paragraphs) ? revision.paragraphs : [];
614
+ const written = paragraphs.filter((one) => typeof one === "object" && one !== null && typeof one.text === "string");
615
+ if (written.length === 0)
616
+ return;
617
+ writeLine(io.stdout, "");
618
+ writeLine(io.stdout, dim(` — ${ANSWER_CORRECTED_LINE} —`, styled));
619
+ for (const paragraph of written)
620
+ writeLine(io.stdout, paragraph.text);
621
+ writeLine(io.stdout, dim(` ${ANSWER_CORRECTED_NOTE_LINE}`, styled));
622
+ }
531
623
  /**
532
624
  * The multipart body `/api/jarvis/cli` reads when a file is attached
533
625
  * (BLI-3414) — same field names the request handler parses, mirroring the
@@ -596,6 +596,27 @@ export function parseMemoryArgs(args) {
596
596
  json: values.booleans.has("--json"),
597
597
  };
598
598
  }
599
+ /**
600
+ * BLI-3619. Deliberately takes no positional: `clean` is one verb, and the two
601
+ * flags say how far it goes. A path is never accepted — the only directory this
602
+ * command may touch is the collector's own state directory.
603
+ */
604
+ export function parseCleanArgs(args) {
605
+ const values = parseNamedArgs(args, {
606
+ allowedFlags: ["--home", "--dry-run", "--all-committed", "--json"],
607
+ valueFlags: ["--home"],
608
+ });
609
+ if (values.positionals.length > 0) {
610
+ throw new Error("clean takes no arguments; use --dry-run or --all-committed.");
611
+ }
612
+ return {
613
+ kind: "clean",
614
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
615
+ dryRun: values.booleans.has("--dry-run"),
616
+ allCommitted: values.booleans.has("--all-committed"),
617
+ json: values.booleans.has("--json"),
618
+ };
619
+ }
599
620
  function parseAgentRulesHost(value) {
600
621
  const host = value?.trim().toLowerCase() || "all";
601
622
  if (host === "codex" || host === "claude" || host === "all")
@@ -1,4 +1,4 @@
1
- import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
1
+ import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
2
2
  import { parseBriefArgs, parseCorrectArgs, parseJarvisArgs, parseModelArgs, parseNotesArgs, parseOpsArgs, parseScoutArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
3
3
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
4
4
  // `local-auth.ts` import it from here — so it stays exported from this address
@@ -82,6 +82,8 @@ export function parseLocalArgs(argv) {
82
82
  return parseAgentRulesArgs(argv.slice(1));
83
83
  case "memory":
84
84
  return parseMemoryArgs(argv.slice(1));
85
+ case "clean":
86
+ return parseCleanArgs(argv.slice(1));
85
87
  case "release":
86
88
  return parseReleaseArgs(argv.slice(1));
87
89
  default:
@@ -39,6 +39,7 @@ export const rootCommandNames = new Set([
39
39
  "autostart",
40
40
  "agent-rules",
41
41
  "memory",
42
+ "clean",
42
43
  "release",
43
44
  ]);
44
45
  export function localCommandHelp(command) {
@@ -76,6 +77,7 @@ export function localCommandHelp(command) {
76
77
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
77
78
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
78
79
  " cockpit memory [install|status] [--dashboard-url <url>] [--dry-run] [--json]",
80
+ " cockpit clean [--dry-run] [--all-committed] [--json]",
79
81
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
80
82
  "",
81
83
  `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.`,
@@ -546,6 +548,22 @@ function localSubcommandHelp(command) {
546
548
  "Action defaults to `install`. See docs/runbooks/bli-memory-install.md.",
547
549
  ],
548
550
  ],
551
+ [
552
+ "clean",
553
+ [
554
+ "Usage: cockpit clean [--dry-run] [--all-committed] [--json]",
555
+ "",
556
+ "Reclaims this machine's disk. Prints what it would delete and why, per bucket,",
557
+ "then deletes: staged evidence Tower has already accepted goes after a 48-hour",
558
+ "retry window, and anything the upload ledger has NOT vouched for is kept,",
559
+ "counted and named — retention never deletes undelivered evidence.",
560
+ "--dry-run stops after the printing. --all-committed drops the retry window,",
561
+ "so every already-accepted copy goes now, and is the only thing that removes a",
562
+ "manual-study-evidence-vault folder (and only when every file in it is accepted).",
563
+ "The scheduled sync does the same prune once a day; this is the door for a",
564
+ "laptop that is full right now.",
565
+ ],
566
+ ],
549
567
  [
550
568
  "release",
551
569
  [
@@ -31,6 +31,7 @@ import { runServe } from "./serve.js";
31
31
  import { runAutostart } from "./autostart-command.js";
32
32
  import { runAgentRules } from "./agent-rules-command.js";
33
33
  import { runMemoryInstall } from "./memory-install.js";
34
+ import { runClean } from "./clean.js";
34
35
  import { parseLocalArgs } from "./local-args.js";
35
36
  // `./local.js` is the published entry point for this command surface: the
36
37
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
@@ -128,6 +129,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
128
129
  return await runAgentRules(command, io);
129
130
  case "memory":
130
131
  return await runMemoryInstall(command, io);
132
+ case "clean":
133
+ return await runClean(command, io);
131
134
  case "release":
132
135
  return await runRelease(command, io);
133
136
  }
@@ -42,6 +42,13 @@
42
42
  * env = { COCKPIT_DASHBOARD_URL = "https://…" }
43
43
  * ```
44
44
  *
45
+ * **The printed `command` is a bare name and is never written as-is.** The bin
46
+ * cannot know where it was installed, and it lives nested inside
47
+ * `@bli-cockpit/cli`'s `node_modules` rather than on PATH — so
48
+ * `withResolvedBinPath` re-points every command at the absolute path this
49
+ * machine resolved before anything is written. Skipping that step registered
50
+ * three hooks that answered `command not found` on every turn.
51
+ *
45
52
  * Nothing here touches the filesystem. The halves that do are
46
53
  * `memory-install-claude.ts` and `memory-install-codex.ts`.
47
54
  */
@@ -207,6 +214,54 @@ export function parsePrintedMemoryInstallConfig(stdout) {
207
214
  permissions_allow: allow ?? [...MEMORY_AUTO_APPROVE_TOOLS],
208
215
  };
209
216
  }
217
+ /**
218
+ * Put THIS machine's resolved bin path into a config the bin printed.
219
+ *
220
+ * The printed contract says `command: "bli-memory-mcp"` — a bare name, because
221
+ * the bin has no idea where it was installed. `bli-memory-mcp` is a DEPENDENCY
222
+ * of `@bli-cockpit/cli`, nested inside its `node_modules`, and therefore **not
223
+ * on anybody's PATH**: a host handed the bare name answers `command not found`
224
+ * on every SessionStart, every prompt and every Stop. The installer is the only
225
+ * thing that knows the absolute path, so path qualification happens here and
226
+ * the printed strings are never written verbatim (BLI-3580; found by running
227
+ * the registered hook command in a simulated global install, not by reading
228
+ * the code).
229
+ *
230
+ * What the printed config still owns: which hook EVENTS exist, their
231
+ * subcommands, their timeouts, the server id and the allow-list. What this
232
+ * function owns: where the program is, and the per-platform launch shape.
233
+ *
234
+ * Returns null when a printed hook command cannot be understood well enough to
235
+ * re-point — the caller then falls back to the built-in template, which is
236
+ * always path-qualified. Guessing at a command we cannot parse would write a
237
+ * hook that runs something else.
238
+ */
239
+ export function withResolvedBinPath(config, options) {
240
+ const quoted = shellQuoteBinPath(options.binPath);
241
+ const hooks = [];
242
+ for (const hook of config.hooks) {
243
+ const tail = hookSubcommandTail(hook.command);
244
+ if (!tail)
245
+ return null;
246
+ hooks.push({ ...hook, command: `${quoted} ${tail}` });
247
+ }
248
+ const entry = memoryMcpServerEntry(options);
249
+ return {
250
+ ...config,
251
+ // The bin's own env wins nothing and loses nothing: it prints `{}`, and the
252
+ // dashboard URL is a machine fact the installer holds.
253
+ mcp_server: { ...entry, env: { ...config.mcp_server.env, ...entry.env } },
254
+ hooks,
255
+ };
256
+ }
257
+ /**
258
+ * `…/bli-memory-mcp hook prompt` → `hook prompt`. The subcommand is the part we
259
+ * keep; everything before it is a path that may be wrong for this machine.
260
+ */
261
+ function hookSubcommandTail(command) {
262
+ const match = /(^|\s)(hook\s+\S+.*)$/u.exec(command.trim());
263
+ return match?.[2]?.trim() ?? null;
264
+ }
210
265
  function normalizeStringArray(value) {
211
266
  if (value === undefined)
212
267
  return [];