@bli-cockpit/cli 0.2.40 → 0.2.41
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/adapters/local-sources.js +51 -5
- package/dist/autostart.js +105 -5
- package/dist/commands/brief.js +11 -10
- package/dist/commands/cli-io.js +29 -0
- package/dist/commands/correct.js +4 -5
- package/dist/commands/jarvis.js +7 -8
- package/dist/commands/local-discovery.js +20 -0
- package/dist/commands/local.js +34 -1
- package/dist/commands/notes.js +9 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout-render.js +20 -18
- package/dist/commands/scout.js +4 -4
- package/dist/commands/sessions.js +45 -1
- package/dist/commands/settings-render.js +9 -4
- package/dist/commands/settings.js +6 -0
- package/dist/commands/text-width.js +108 -0
- package/dist/commands/workbook-render.js +34 -17
- package/dist/commands/workbook.js +6 -4
- package/dist/evidence-upload-client.js +42 -3
- package/dist/local-state.js +27 -2
- package/dist/upload-agent-artifacts.js +27 -2
- package/dist/upload-failure-reason.js +121 -0
- package/dist/upload-http.js +28 -0
- package/dist/upload.js +122 -21
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { collectCarState } from "./car-state.js";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import { makeSourceScan, makeUnavailableScan, } from "./common.js";
|
|
4
4
|
import { collectGitState } from "./git-state.js";
|
|
5
|
+
import { describeError } from "../health-detail.js";
|
|
5
6
|
import { collectRawEvidencePack, } from "./raw-evidence.js";
|
|
6
7
|
import { generateDumbRiskFlags } from "./risk-flags.js";
|
|
7
8
|
import { resolveTicketBinding, } from "./ticket-binding.js";
|
|
@@ -50,9 +51,11 @@ export async function runLocalSourceCollectors(options) {
|
|
|
50
51
|
makeUnavailableScan(context, "claude_hooks", "claude-hooks", "claude_hooks_not_present"),
|
|
51
52
|
];
|
|
52
53
|
// claude_jsonl availability (B.4): "ok" when ~/.claude/projects exists, else
|
|
53
|
-
// skipped with
|
|
54
|
-
//
|
|
55
|
-
//
|
|
54
|
+
// skipped with the reason it could not be read — absent, permission-denied,
|
|
55
|
+
// not a directory, or unreadable (BLI-3483) — so an operator sees whether
|
|
56
|
+
// the empty claude funnel is normal or a wall they can take down. Collection
|
|
57
|
+
// itself is gated separately by collect_claude_jsonl in the sync
|
|
58
|
+
// orchestrator.
|
|
56
59
|
const claudeJsonlScan = await makeClaudeJsonlScan(context, options.claudeProjectsDir);
|
|
57
60
|
const candidates = collectBindingCandidates([
|
|
58
61
|
git.scan,
|
|
@@ -93,9 +96,52 @@ async function makeClaudeJsonlScan(context, projectsDir) {
|
|
|
93
96
|
if (!projectsDir) {
|
|
94
97
|
return makeUnavailableScan(context, "claude_jsonl", "claude-jsonl", "claude_projects_dir_not_configured");
|
|
95
98
|
}
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
// Until BLI-3483 every failure of this stat became `claude_projects_dir_
|
|
100
|
+
// not_found`, so a machine that HAS the folder and cannot open it — macOS
|
|
101
|
+
// TCC withholding Full Disk Access, a Windows ACL, a corporate MDM profile —
|
|
102
|
+
// reported the same word as a machine that has never run Claude Code. One
|
|
103
|
+
// repair is "nothing to do"; the other is "grant this app access, then every
|
|
104
|
+
// Claude session on this laptop starts collecting". Opposite meanings, one
|
|
105
|
+
// label, and the empty funnel looked normal either way.
|
|
106
|
+
const inspected = await fs.stat(projectsDir).then((stat) => ({ ok: true, isDirectory: stat.isDirectory() }), (error) => ({ ok: false, error }));
|
|
107
|
+
if (inspected.ok && inspected.isDirectory) {
|
|
108
|
+
return makeSourceScan(context, "claude_jsonl", "claude-jsonl", "ok", "claude_projects_dir_present");
|
|
109
|
+
}
|
|
110
|
+
const reason = inspected.ok
|
|
111
|
+
? "claude_projects_dir_not_a_directory"
|
|
112
|
+
: claudeProjectsDirFailureReason(inspected.error);
|
|
113
|
+
// Never the path: it is under the operator's home directory.
|
|
114
|
+
console.error("[local-sources] claude projects directory unavailable", JSON.stringify({
|
|
115
|
+
reason,
|
|
116
|
+
fix_hint: CLAUDE_PROJECTS_FIX_HINTS[reason],
|
|
117
|
+
...(inspected.ok ? {} : describeError(inspected.error)),
|
|
118
|
+
}));
|
|
119
|
+
return makeSourceScan(context, "claude_jsonl", "claude-jsonl", "skipped", reason);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* `ENOENT`/`ENOTDIR` is the ordinary "this machine does not run Claude Code".
|
|
123
|
+
* `EACCES`/`EPERM` is a permission wall a person can take down. Anything else
|
|
124
|
+
* (`ELOOP`, `ENAMETOOLONG`, an I/O error) is named as unreadable rather than
|
|
125
|
+
* folded into either, because guessing between them is what created this bug.
|
|
126
|
+
*/
|
|
127
|
+
function claudeProjectsDirFailureReason(error) {
|
|
128
|
+
const code = error && typeof error === "object"
|
|
129
|
+
? error.code
|
|
130
|
+
: undefined;
|
|
131
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
132
|
+
return "claude_projects_dir_not_found";
|
|
133
|
+
}
|
|
134
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
135
|
+
return "claude_projects_dir_permission_denied";
|
|
136
|
+
}
|
|
137
|
+
return "claude_projects_dir_unreadable";
|
|
98
138
|
}
|
|
139
|
+
const CLAUDE_PROJECTS_FIX_HINTS = {
|
|
140
|
+
claude_projects_dir_not_found: "nothing to repair unless this machine runs Claude Code",
|
|
141
|
+
claude_projects_dir_permission_denied: "macOS: grant the terminal/CLI Full Disk Access in System Settings > Privacy & Security; Windows: check the folder ACL on the user profile",
|
|
142
|
+
claude_projects_dir_not_a_directory: "a file sits where the projects directory should be; move or remove it",
|
|
143
|
+
claude_projects_dir_unreadable: "read the error_code beside this line; the folder exists but the stat failed",
|
|
144
|
+
};
|
|
99
145
|
function isGitStateFacts(value) {
|
|
100
146
|
return Boolean(value && typeof value === "object" && "repo_root" in value);
|
|
101
147
|
}
|
package/dist/autostart.js
CHANGED
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
|
|
5
5
|
import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
|
|
6
|
+
import { redactedHealthDetail } from "./health-detail.js";
|
|
6
7
|
/** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
|
|
7
8
|
export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
|
|
8
9
|
export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
|
|
@@ -100,6 +101,28 @@ export async function installAutostartAgent(options) {
|
|
|
100
101
|
await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
101
102
|
const load = await options.exec("launchctl", ["load", plistPath]);
|
|
102
103
|
const loaded = load.code === 0;
|
|
104
|
+
// Both branches log, for the same reason the Windows path does: a line that
|
|
105
|
+
// only fires on failure cannot answer "did background collection get
|
|
106
|
+
// installed at all today?". Until BLI-3483 the macOS failure existed only in
|
|
107
|
+
// the returned `message`, which nothing on the sync path reads — a Mac could
|
|
108
|
+
// finish `cockpit onboard` with no scheduler and nothing in sync.err.log.
|
|
109
|
+
// Metadata only: launchctl's stderr can carry the state directory, so the
|
|
110
|
+
// exit code and the redacted first line travel, never the raw text.
|
|
111
|
+
if (loaded) {
|
|
112
|
+
console.error("[autostart] launchd agent loaded", JSON.stringify({
|
|
113
|
+
label: AUTOSTART_LABEL,
|
|
114
|
+
interval_seconds: intervalSeconds,
|
|
115
|
+
watch_path_count: watchPaths.length,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.error("[autostart] launchctl load failed; this Mac has no background collection", JSON.stringify({
|
|
120
|
+
reason: "autostart_load_failed",
|
|
121
|
+
label: AUTOSTART_LABEL,
|
|
122
|
+
exit_code: load.code,
|
|
123
|
+
detail: launchctlDetail(load),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
103
126
|
return {
|
|
104
127
|
status: "installed",
|
|
105
128
|
label: AUTOSTART_LABEL,
|
|
@@ -112,10 +135,27 @@ export async function installAutostartAgent(options) {
|
|
|
112
135
|
...(loaded
|
|
113
136
|
? {}
|
|
114
137
|
: {
|
|
115
|
-
message: `launchctl load exited ${load.code}: ${load
|
|
138
|
+
message: `launchctl load exited ${load.code}: ${launchctlDetail(load)}`,
|
|
116
139
|
}),
|
|
117
140
|
};
|
|
118
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* What `launchctl` actually said, or an honest account of it saying nothing.
|
|
144
|
+
*
|
|
145
|
+
* "unknown error" was the old fallback, and it is the least useful string in
|
|
146
|
+
* the file: `launchctl load` exits nonzero with EMPTY stderr for the ordinary
|
|
147
|
+
* cases — the agent is already loaded, or the plist was rejected — so the
|
|
148
|
+
* message an operator saw named neither the exit code's meaning nor the
|
|
149
|
+
* silence itself (BLI-3483). Exit 1 with no output is a fact; say so, and say
|
|
150
|
+
* what to do next. stdout is consulted too because launchctl is inconsistent
|
|
151
|
+
* about which stream it uses across macOS versions.
|
|
152
|
+
*/
|
|
153
|
+
function launchctlDetail(result) {
|
|
154
|
+
const spoken = result.stderr.trim() || result.stdout.trim();
|
|
155
|
+
if (spoken)
|
|
156
|
+
return redactedHealthDetail(spoken);
|
|
157
|
+
return `no output on either stream (exit ${result.code}); the agent may already be loaded, or launchd rejected the plist — check \`launchctl print gui/$(id -u)/${AUTOSTART_LABEL}\``;
|
|
158
|
+
}
|
|
119
159
|
/**
|
|
120
160
|
* Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
|
|
121
161
|
* the command is safe to run repeatedly.
|
|
@@ -379,8 +419,9 @@ async function windowsTaskStatus(options) {
|
|
|
379
419
|
syncLogPath: windowsSyncLogPath(options.homeDir),
|
|
380
420
|
})}`;
|
|
381
421
|
const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
|
|
382
|
-
|
|
383
|
-
|
|
422
|
+
const scriptDifference = renderedFileDifference(currentScript, expectedScript);
|
|
423
|
+
if (scriptDifference) {
|
|
424
|
+
registrationProblems.push(`sync script does not match the current roots or Tower runtime (${describeDifference(scriptDifference)})`);
|
|
384
425
|
}
|
|
385
426
|
}
|
|
386
427
|
// Same shape as the sync-script checks: existence always, content only when
|
|
@@ -398,8 +439,9 @@ async function windowsTaskStatus(options) {
|
|
|
398
439
|
scriptPath,
|
|
399
440
|
});
|
|
400
441
|
const currentLauncher = await readFile(launcherPath, "utf8").catch(() => null);
|
|
401
|
-
|
|
402
|
-
|
|
442
|
+
const launcherDifference = renderedFileDifference(currentLauncher, expectedLauncher);
|
|
443
|
+
if (launcherDifference) {
|
|
444
|
+
registrationProblems.push(`sync launcher does not match the current Tower runtime (${describeDifference(launcherDifference)})`);
|
|
403
445
|
}
|
|
404
446
|
}
|
|
405
447
|
// Both branches log. A line that only fires on failure cannot answer "did
|
|
@@ -431,6 +473,64 @@ async function windowsTaskStatus(options) {
|
|
|
431
473
|
: {}),
|
|
432
474
|
};
|
|
433
475
|
}
|
|
476
|
+
function renderedFileDifference(current, expected) {
|
|
477
|
+
const expectedLines = normalizedScriptLines(expected);
|
|
478
|
+
if (current === null) {
|
|
479
|
+
return {
|
|
480
|
+
reason: "file_unreadable",
|
|
481
|
+
first_differing_line: null,
|
|
482
|
+
current_line_count: null,
|
|
483
|
+
expected_line_count: expectedLines.length,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (hasByteOrderMark(current) !== hasByteOrderMark(expected)) {
|
|
487
|
+
return {
|
|
488
|
+
reason: "byte_order_mark",
|
|
489
|
+
first_differing_line: 0,
|
|
490
|
+
current_line_count: normalizedScriptLines(current).length,
|
|
491
|
+
expected_line_count: expectedLines.length,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const currentLines = normalizedScriptLines(current);
|
|
495
|
+
const shared = Math.min(currentLines.length, expectedLines.length);
|
|
496
|
+
for (let index = 0; index < shared; index += 1) {
|
|
497
|
+
if (currentLines[index] !== expectedLines[index]) {
|
|
498
|
+
return {
|
|
499
|
+
reason: "line_content",
|
|
500
|
+
first_differing_line: index,
|
|
501
|
+
current_line_count: currentLines.length,
|
|
502
|
+
expected_line_count: expectedLines.length,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (currentLines.length !== expectedLines.length) {
|
|
507
|
+
return {
|
|
508
|
+
reason: "line_count",
|
|
509
|
+
first_differing_line: shared,
|
|
510
|
+
current_line_count: currentLines.length,
|
|
511
|
+
expected_line_count: expectedLines.length,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
/** Safe to print: a reason label, a line index, two counts. No content. */
|
|
517
|
+
function describeDifference(difference) {
|
|
518
|
+
if (difference.reason === "file_unreadable")
|
|
519
|
+
return "file could not be read";
|
|
520
|
+
if (difference.reason === "byte_order_mark")
|
|
521
|
+
return "byte-order mark differs";
|
|
522
|
+
return `${difference.reason} at line ${difference.first_differing_line}, ${difference.current_line_count} lines on disk vs ${difference.expected_line_count} expected`;
|
|
523
|
+
}
|
|
524
|
+
function normalizedScriptLines(value) {
|
|
525
|
+
const withoutBom = hasByteOrderMark(value) ? value.slice(UTF8_BOM.length) : value;
|
|
526
|
+
return withoutBom
|
|
527
|
+
.replace(/\r\n/gu, "\n")
|
|
528
|
+
.replace(/\s+$/u, "")
|
|
529
|
+
.split("\n");
|
|
530
|
+
}
|
|
531
|
+
function hasByteOrderMark(value) {
|
|
532
|
+
return value.startsWith(UTF8_BOM);
|
|
533
|
+
}
|
|
434
534
|
function windowsAutostartScriptPath(homeDir) {
|
|
435
535
|
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
|
|
436
536
|
}
|
package/dist/commands/brief.js
CHANGED
|
@@ -12,10 +12,8 @@
|
|
|
12
12
|
* Identity is the existing paired device session. `--for` changes WHOSE page is
|
|
13
13
|
* asked for, never who is asking — the same rule `cockpit jarvis --as` follows.
|
|
14
14
|
*/
|
|
15
|
-
import { writeLine } from "./cli-io.js";
|
|
15
|
+
import { colorEnabled, dim, writeLine } from "./cli-io.js";
|
|
16
16
|
import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
|
|
17
|
-
const DIM = "\x1b[2m";
|
|
18
|
-
const RESET = "\x1b[0m";
|
|
19
17
|
const REQUEST_DEADLINE_MS = 30_000;
|
|
20
18
|
export async function runBrief(command, io) {
|
|
21
19
|
const session = await loadPairedSession("brief", command.homeDir);
|
|
@@ -81,19 +79,22 @@ function queryFor(command) {
|
|
|
81
79
|
return query ? `?${query}` : "";
|
|
82
80
|
}
|
|
83
81
|
function writeHuman(io, command, body) {
|
|
82
|
+
// Decided once per page: colour only for a real terminal that has not said
|
|
83
|
+
// NO_COLOR, so `cockpit brief > page.txt` is text and nothing else (BLI-3482).
|
|
84
|
+
const styled = colorEnabled(io);
|
|
84
85
|
const page = body.page ?? {};
|
|
85
86
|
// Whose page, and — when it is not the newest — that it is a record of a
|
|
86
87
|
// moment rather than something that failed to update.
|
|
87
88
|
const whose = page.displayName ? `${page.displayName}'s page` : "This page";
|
|
88
89
|
const pinned = page.olderVersionLabel ? ` · version from ${page.olderVersionLabel}` : "";
|
|
89
|
-
writeLine(io.stdout, `${
|
|
90
|
+
writeLine(io.stdout, dim(`${whose}${pinned}`, styled));
|
|
90
91
|
writeLine(io.stdout, "");
|
|
91
92
|
writeLine(io.stdout, body.text ?? "");
|
|
92
93
|
if (body.claims && body.claims.length > 0) {
|
|
93
94
|
writeLine(io.stdout, "");
|
|
94
|
-
writeLine(io.stdout,
|
|
95
|
+
writeLine(io.stdout, dim("Claim ids — pass one to `cockpit correct --claim`:", styled));
|
|
95
96
|
for (const claim of body.claims) {
|
|
96
|
-
writeLine(io.stdout, `${
|
|
97
|
+
writeLine(io.stdout, `${dim(` [${claim.claimId}]`, styled)} ${claim.text ?? ""}`);
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
if (body.versions) {
|
|
@@ -101,13 +102,13 @@ function writeHuman(io, command, body) {
|
|
|
101
102
|
if (body.versions.length === 0) {
|
|
102
103
|
// Never "there is one version". An empty list with a reason is an
|
|
103
104
|
// answer; an empty list without one is a wrong answer.
|
|
104
|
-
writeLine(io.stdout,
|
|
105
|
+
writeLine(io.stdout, dim(`Earlier versions could not be listed (${body.versionsReason ?? "no reason given"}).`, styled));
|
|
105
106
|
}
|
|
106
107
|
else {
|
|
107
|
-
writeLine(io.stdout,
|
|
108
|
+
writeLine(io.stdout, dim("Versions — pass one to `cockpit brief --version`:", styled));
|
|
108
109
|
for (const version of body.versions) {
|
|
109
110
|
const headline = version.headline ? ` — ${version.headline}` : "";
|
|
110
|
-
writeLine(io.stdout,
|
|
111
|
+
writeLine(io.stdout, dim(` ${version.version}/${version.of} ${version.pageId} ${version.asOf}${headline}`, styled));
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -118,7 +119,7 @@ function writeHuman(io, command, body) {
|
|
|
118
119
|
.filter(Boolean);
|
|
119
120
|
if (others.length > 0) {
|
|
120
121
|
writeLine(io.stdout, "");
|
|
121
|
-
writeLine(io.stdout,
|
|
122
|
+
writeLine(io.stdout, dim(`You can also open: ${others.join(", ")} (\`--for <name>\`)`, styled));
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
}
|
package/dist/commands/cli-io.js
CHANGED
|
@@ -13,6 +13,35 @@ export function writeRaw(stream, text) {
|
|
|
13
13
|
export function errorMessage(error) {
|
|
14
14
|
return error instanceof Error ? error.message : String(error);
|
|
15
15
|
}
|
|
16
|
+
// ------------------------------------------------------------------- styling
|
|
17
|
+
const SGR_DIM = "\x1b[2m";
|
|
18
|
+
const SGR_RESET = "\x1b[0m";
|
|
19
|
+
/**
|
|
20
|
+
* Whether this run may put colour escapes on stdout (BLI-3482). Five commands
|
|
21
|
+
* had their own copy of the same two escape constants and none of them asked
|
|
22
|
+
* this question, so `cockpit brief > page.txt` wrote `ESC[2m` into the file and
|
|
23
|
+
* `cockpit scout | grep` matched against bytes nobody typed.
|
|
24
|
+
*
|
|
25
|
+
* Two conditions, both from `io` rather than the process so a test can state
|
|
26
|
+
* them: stdout is a real terminal, and NO_COLOR is absent or empty
|
|
27
|
+
* (no-color.org — any non-empty value, whatever it says, means no colour).
|
|
28
|
+
* `defaultIo()` passes `process.env`, so in production this IS `process.env`.
|
|
29
|
+
*/
|
|
30
|
+
export function colorEnabled(io) {
|
|
31
|
+
if ((io.env["NO_COLOR"] ?? "") !== "")
|
|
32
|
+
return false;
|
|
33
|
+
return Boolean(io.stdout.isTTY);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The one dim-text helper. `enabled` is an explicit argument, never read from
|
|
37
|
+
* the process here, so the pure render modules stay deterministic and a caller
|
|
38
|
+
* cannot forget the gate without the compiler saying so.
|
|
39
|
+
*/
|
|
40
|
+
export function dim(text, enabled) {
|
|
41
|
+
if (!enabled || text === "")
|
|
42
|
+
return text;
|
|
43
|
+
return `${SGR_DIM}${text}${SGR_RESET}`;
|
|
44
|
+
}
|
|
16
45
|
export function writeExecOutput(io, result, options) {
|
|
17
46
|
if (options.stdout)
|
|
18
47
|
writeRaw(io.stdout, result.stdout);
|
package/dist/commands/correct.js
CHANGED
|
@@ -23,10 +23,8 @@
|
|
|
23
23
|
* is printed on stdout with an exit code of 0 — the correction WAS filed, with
|
|
24
24
|
* its outcome recorded. Only a failure to file at all is a non-zero exit.
|
|
25
25
|
*/
|
|
26
|
-
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
26
|
+
import { colorEnabled, dim, isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
27
27
|
import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
|
|
28
|
-
const DIM = "\x1b[2m";
|
|
29
|
-
const RESET = "\x1b[0m";
|
|
30
28
|
const REQUEST_DEADLINE_MS = 60_000;
|
|
31
29
|
const MAX_CORRECTION_LENGTH = 4000;
|
|
32
30
|
export async function runCorrect(command, io) {
|
|
@@ -101,11 +99,12 @@ export async function runCorrect(command, io) {
|
|
|
101
99
|
writeLine(io.stdout, JSON.stringify({ ok: true, ...filed }));
|
|
102
100
|
}
|
|
103
101
|
else {
|
|
102
|
+
const styled = colorEnabled(io);
|
|
104
103
|
writeLine(io.stdout, filed.reply ?? "Filed.");
|
|
105
104
|
if (filed.finding)
|
|
106
|
-
writeLine(io.stdout,
|
|
105
|
+
writeLine(io.stdout, dim(`The record says: ${filed.finding}`, styled));
|
|
107
106
|
if (filed.link)
|
|
108
|
-
writeLine(io.stdout,
|
|
107
|
+
writeLine(io.stdout, dim(filed.link, styled));
|
|
109
108
|
}
|
|
110
109
|
writeLine(io.stderr, `[correct cli] filed ${JSON.stringify({
|
|
111
110
|
tier: filed.tier ?? null,
|
package/dist/commands/jarvis.js
CHANGED
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
* existing paired device session, and every turn is executed by the dashboard
|
|
6
6
|
* through the same JARVIS runtime used by web chat and Slack.
|
|
7
7
|
*/
|
|
8
|
-
import { isInteractiveStdin, readLine, readPipedText, writeLine } from "./cli-io.js";
|
|
8
|
+
import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeLine, } from "./cli-io.js";
|
|
9
9
|
import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
|
|
10
10
|
import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
|
|
11
11
|
import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
|
|
12
|
-
const TRACE_DIM = "\x1b[2m";
|
|
13
|
-
const TRACE_RESET = "\x1b[0m";
|
|
14
12
|
/**
|
|
15
13
|
* The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
|
|
16
14
|
* waits slightly longer so the server's own named failure wins the race
|
|
@@ -108,14 +106,15 @@ function writeThreadList(io, threads) {
|
|
|
108
106
|
writeLine(io.stdout, "No terminal conversations yet. Ask something with `cockpit jarvis`.");
|
|
109
107
|
return;
|
|
110
108
|
}
|
|
109
|
+
const styled = colorEnabled(io);
|
|
111
110
|
for (const thread of threads) {
|
|
112
111
|
const turns = thread.turnCount === 1 ? "1 turn" : `${thread.turnCount ?? 0} turns`;
|
|
113
|
-
writeLine(io.stdout, `${thread.name} ${
|
|
112
|
+
writeLine(io.stdout, `${thread.name} ${dim(`${turns} · ${thread.lastAt ?? "unknown"}`, styled)}`);
|
|
114
113
|
if (thread.preview)
|
|
115
|
-
writeLine(io.stdout,
|
|
114
|
+
writeLine(io.stdout, dim(` ${thread.preview}`, styled));
|
|
116
115
|
}
|
|
117
116
|
writeLine(io.stdout, "");
|
|
118
|
-
writeLine(io.stdout,
|
|
117
|
+
writeLine(io.stdout, dim("Replay one with `cockpit jarvis --thread <name> --history`.", styled));
|
|
119
118
|
}
|
|
120
119
|
function writeThreadHistory(io, body) {
|
|
121
120
|
const messages = body.messages ?? [];
|
|
@@ -124,7 +123,7 @@ function writeThreadHistory(io, body) {
|
|
|
124
123
|
return;
|
|
125
124
|
}
|
|
126
125
|
if (body.truncated) {
|
|
127
|
-
writeLine(io.stdout,
|
|
126
|
+
writeLine(io.stdout, dim(`Showing the most recent ${messages.length}; there is more before this (\`--limit\`).`, colorEnabled(io)));
|
|
128
127
|
}
|
|
129
128
|
for (const message of messages) {
|
|
130
129
|
const speaker = message.role === "you" ? "you" : "jarvis";
|
|
@@ -294,7 +293,7 @@ function writeTraceBlock(io, trace) {
|
|
|
294
293
|
function writeTraceLine(io, step) {
|
|
295
294
|
const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
|
|
296
295
|
const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
|
|
297
|
-
writeLine(io.stdout,
|
|
296
|
+
writeLine(io.stdout, dim(` ⏺ ${step.label}${elapsed}${failure}`, colorEnabled(io)));
|
|
298
297
|
}
|
|
299
298
|
/**
|
|
300
299
|
* Draws one streamed tool call, and says whether it drew anything.
|
|
@@ -38,6 +38,26 @@ export async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
|
38
38
|
throw new Error(message);
|
|
39
39
|
}
|
|
40
40
|
if (worktrees.length === 0 && !discovery.allowEmpty) {
|
|
41
|
+
// "No git repos found" is only true when the scan could actually SEE
|
|
42
|
+
// everywhere it looked. With unreadable folders in hand, the sentence sent
|
|
43
|
+
// the operator to the wrong repair — "run from a git repo", when the repos
|
|
44
|
+
// may be sitting right there behind a permission wall (BLI-3483). The
|
|
45
|
+
// warning above prints the folders; this refusal has to carry the fact,
|
|
46
|
+
// because it is the line that ends the command.
|
|
47
|
+
if (result.unreadable_dirs.length > 0) {
|
|
48
|
+
console.error("[local-discovery] scan found no repos while folders were unopenable", JSON.stringify({
|
|
49
|
+
reason: "empty_scan_with_unreadable_dirs",
|
|
50
|
+
unreadable_dir_count: result.unreadable_dirs.length,
|
|
51
|
+
codes: [...new Set(result.unreadable_dirs.map((dir) => dir.code))],
|
|
52
|
+
}));
|
|
53
|
+
throw new Error([
|
|
54
|
+
`No git repos found, and ${result.unreadable_dirs.length} folder(s) could not be opened — so this is not proof there are none.`,
|
|
55
|
+
...(io
|
|
56
|
+
? ["The folders are listed in the warning above."]
|
|
57
|
+
: ["Run `cockpit doctor` to see which folders."]),
|
|
58
|
+
"Fix the permissions on those folders and run the command again, or point --workspace at the project folders you can read.",
|
|
59
|
+
].join("\n"));
|
|
60
|
+
}
|
|
41
61
|
throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
|
|
42
62
|
}
|
|
43
63
|
return worktrees;
|
package/dist/commands/local.js
CHANGED
|
@@ -155,6 +155,17 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
catch (error) {
|
|
158
|
+
// The outermost net under every subcommand. It printed `error.message` and
|
|
159
|
+
// dropped the name, the code, the syscall and the cause — and never said
|
|
160
|
+
// WHICH subcommand died, so a scheduled `cockpit sync` and a hand-typed
|
|
161
|
+
// `cockpit doctor` failing the same way were indistinguishable in
|
|
162
|
+
// sync.err.log (BLI-3483). The human line is unchanged; the structured one
|
|
163
|
+
// beside it carries names and codes only, scrubbed by `describeError`.
|
|
164
|
+
console.error("[cockpit-cli] command failed", JSON.stringify({
|
|
165
|
+
reason: "command_failed",
|
|
166
|
+
command: command.kind,
|
|
167
|
+
...describeError(error),
|
|
168
|
+
}));
|
|
158
169
|
writeLine(io.stderr, errorMessage(error));
|
|
159
170
|
return 1;
|
|
160
171
|
}
|
|
@@ -1115,8 +1126,26 @@ async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
|
|
|
1115
1126
|
let result;
|
|
1116
1127
|
try {
|
|
1117
1128
|
const rawExec = io.exec;
|
|
1118
|
-
if (!rawExec)
|
|
1129
|
+
if (!rawExec) {
|
|
1130
|
+
// BLI-3483: this was a bare `return`. On Windows the self-heal is the
|
|
1131
|
+
// only thing that puts a broken scheduler back, so abandoning it here
|
|
1132
|
+
// meant a machine could stop collecting forever and leave no receipt
|
|
1133
|
+
// anywhere — the exact shape the fleet contract forbids. The packed CLI
|
|
1134
|
+
// always supplies a runner (`commands/cli-io.ts`), so this fires only for
|
|
1135
|
+
// an embedder that built its own `io`; it costs one line either way.
|
|
1136
|
+
console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
|
|
1137
|
+
reason: "runner_unavailable",
|
|
1138
|
+
platform: process.platform,
|
|
1139
|
+
next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
|
|
1140
|
+
}));
|
|
1141
|
+
result = {
|
|
1142
|
+
status: "skipped",
|
|
1143
|
+
reason: "runner_unavailable",
|
|
1144
|
+
detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
|
|
1145
|
+
};
|
|
1146
|
+
await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
|
|
1119
1147
|
return;
|
|
1148
|
+
}
|
|
1120
1149
|
const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
1121
1150
|
const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
|
|
1122
1151
|
result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
|
|
@@ -1137,6 +1166,10 @@ async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
|
|
|
1137
1166
|
// throttle are silent; an actual repair attempt reports either way.
|
|
1138
1167
|
if (!result || result.reason === "repair_throttled_recent_attempt")
|
|
1139
1168
|
return;
|
|
1169
|
+
await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
|
|
1170
|
+
}
|
|
1171
|
+
/** One receipt for the repair, whichever branch above produced the outcome. */
|
|
1172
|
+
async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
|
|
1140
1173
|
await reportInstallEventsBestEffort({
|
|
1141
1174
|
homeDir: command.homeDir,
|
|
1142
1175
|
dashboardUrl,
|
package/dist/commands/notes.js
CHANGED
|
@@ -293,8 +293,14 @@ async function pasteNote(command, door) {
|
|
|
293
293
|
form.set("name", command.name);
|
|
294
294
|
if (command.exclude)
|
|
295
295
|
form.set("exclusions", command.exclude);
|
|
296
|
-
|
|
297
|
-
|
|
296
|
+
// Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
|
|
297
|
+
// threshold, and the file path above already compares `bytes.byteLength`
|
|
298
|
+
// against it; `text.length` counts UTF-16 units, so a note in any non-Latin
|
|
299
|
+
// script was measured at a third to a half of the size actually being sent
|
|
300
|
+
// and the "this will take a while" line stayed silent through the wait.
|
|
301
|
+
const pastedBytes = Buffer.byteLength(text, "utf8");
|
|
302
|
+
if (pastedBytes >= NOTE_SLOW_UPLOAD_BYTES) {
|
|
303
|
+
writeLine(door.io.stderr, `Reading ${Math.round(pastedBytes / 1024)} KB of pasted text. This can take a couple of minutes.`);
|
|
298
304
|
}
|
|
299
305
|
const answer = await ask(door, {
|
|
300
306
|
path: "/api/notes/upload",
|
|
@@ -311,6 +317,7 @@ async function pasteNote(command, door) {
|
|
|
311
317
|
note_id: body.noteId ?? null,
|
|
312
318
|
scope: body.scope ?? null,
|
|
313
319
|
chars: text.length,
|
|
320
|
+
byte_size: pastedBytes,
|
|
314
321
|
named_by_caller: Boolean(command.name),
|
|
315
322
|
})}`);
|
|
316
323
|
if (door.json)
|
|
@@ -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.41");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
* **A bounded read says so.** The server returns at most 8 cards and 12
|
|
16
16
|
* signals; when there are more, `truncationLines` prints how many. A quiet
|
|
17
17
|
* board and a truncated board must never look alike.
|
|
18
|
+
*
|
|
19
|
+
* **Styling arrives as an argument, never as a global.** `styled` says whether
|
|
20
|
+
* this run's stdout is a terminal that asked for colour (BLI-3482); `scout.ts`
|
|
21
|
+
* decides it once with `colorEnabled(io)` and the layout stays deterministic.
|
|
18
22
|
*/
|
|
19
|
-
|
|
20
|
-
const RESET = "\x1b[0m";
|
|
23
|
+
import { dim } from "./cli-io.js";
|
|
21
24
|
// ------------------------------------------------------------------ rendering
|
|
22
25
|
/** The whole board as terminal lines, in the page's three sections and order. */
|
|
23
|
-
export function renderScoutBoard(payload) {
|
|
26
|
+
export function renderScoutBoard(payload, styled) {
|
|
24
27
|
const board = payload.board ?? {};
|
|
25
28
|
const lines = payload.lines ?? {};
|
|
26
29
|
const out = [];
|
|
@@ -35,7 +38,7 @@ export function renderScoutBoard(payload) {
|
|
|
35
38
|
}
|
|
36
39
|
else {
|
|
37
40
|
for (const card of experiments)
|
|
38
|
-
out.push(...renderExperiment(card));
|
|
41
|
+
out.push(...renderExperiment(card, styled));
|
|
39
42
|
}
|
|
40
43
|
const settled = board.settled ?? [];
|
|
41
44
|
if (settled.length > 0) {
|
|
@@ -51,14 +54,14 @@ export function renderScoutBoard(payload) {
|
|
|
51
54
|
}
|
|
52
55
|
else {
|
|
53
56
|
for (const signal of signals)
|
|
54
|
-
out.push(...renderSignal(signal));
|
|
57
|
+
out.push(...renderSignal(signal, styled));
|
|
55
58
|
}
|
|
56
59
|
const truncation = truncationLines(board);
|
|
57
60
|
if (truncation.length > 0)
|
|
58
61
|
out.push("", ...truncation);
|
|
59
62
|
return out;
|
|
60
63
|
}
|
|
61
|
-
function renderExperiment(card) {
|
|
64
|
+
function renderExperiment(card, styled) {
|
|
62
65
|
const sourceCount = card.sourceCount ?? 0;
|
|
63
66
|
const head = [
|
|
64
67
|
shortId(card.id ?? ""),
|
|
@@ -74,23 +77,25 @@ function renderExperiment(card) {
|
|
|
74
77
|
if (card.claimSummary)
|
|
75
78
|
out.push(` ${card.claimSummary}`);
|
|
76
79
|
if (card.title)
|
|
77
|
-
out.push(dim(` ${card.title}
|
|
80
|
+
out.push(dim(` ${card.title}`, styled));
|
|
78
81
|
for (const row of card.crossref ?? []) {
|
|
79
|
-
if (row.person)
|
|
80
|
-
out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}
|
|
82
|
+
if (row.person) {
|
|
83
|
+
out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`, styled));
|
|
84
|
+
}
|
|
81
85
|
}
|
|
82
86
|
if (card.expectedPayoff)
|
|
83
|
-
out.push(dim(` Worth trying · ${card.expectedPayoff}
|
|
87
|
+
out.push(dim(` Worth trying · ${card.expectedPayoff}`, styled));
|
|
84
88
|
for (const source of card.sources ?? []) {
|
|
85
|
-
if (source.title)
|
|
86
|
-
out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}
|
|
89
|
+
if (source.title) {
|
|
90
|
+
out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}`, styled));
|
|
91
|
+
}
|
|
87
92
|
}
|
|
88
93
|
return out;
|
|
89
94
|
}
|
|
90
|
-
function renderSignal(signal) {
|
|
95
|
+
function renderSignal(signal, styled) {
|
|
91
96
|
const out = [` ⏺ ${signal.title ?? "untitled"} · ${dayStamp(signal.gatheredAt)}`];
|
|
92
97
|
if (signal.synopsis)
|
|
93
|
-
out.push(dim(` ${signal.synopsis}
|
|
98
|
+
out.push(dim(` ${signal.synopsis}`, styled));
|
|
94
99
|
const foot = [
|
|
95
100
|
signal.source,
|
|
96
101
|
signal.evidenceStrength ?? undefined,
|
|
@@ -99,7 +104,7 @@ function renderSignal(signal) {
|
|
|
99
104
|
signal.url,
|
|
100
105
|
].filter((part) => Boolean(part));
|
|
101
106
|
if (foot.length > 0)
|
|
102
|
-
out.push(dim(` ${foot.join(" · ")}
|
|
107
|
+
out.push(dim(` ${foot.join(" · ")}`, styled));
|
|
103
108
|
return out;
|
|
104
109
|
}
|
|
105
110
|
/**
|
|
@@ -164,9 +169,6 @@ export function shortId(id) {
|
|
|
164
169
|
function dayStamp(iso) {
|
|
165
170
|
return iso ? iso.slice(0, 10) : "";
|
|
166
171
|
}
|
|
167
|
-
function dim(text) {
|
|
168
|
-
return `${DIM}${text}${RESET}`;
|
|
169
|
-
}
|
|
170
172
|
function indent(text) {
|
|
171
173
|
return ` ${text}`;
|
|
172
174
|
}
|