@bli-cockpit/cli 0.2.30 → 0.2.32
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/agent-image-evidence.js +4 -0
- package/dist/adapters/attribution-core.js +12 -0
- package/dist/adapters/car-state.js +12 -1
- package/dist/adapters/claude-attribution.js +81 -7
- package/dist/adapters/codex-attribution.js +37 -3
- package/dist/adapters/raw-evidence-manifest.js +12 -1
- package/dist/adapters/raw-evidence-pack-store.js +28 -3
- package/dist/adapters/raw-evidence-sanitize.js +21 -3
- package/dist/adapters/raw-evidence.js +51 -6
- package/dist/agent-rules.js +34 -3
- package/dist/autostart.js +3 -0
- package/dist/backfill-lock.js +22 -1
- package/dist/commands/backfill.js +41 -7
- package/dist/commands/cli-io.js +3 -0
- package/dist/commands/doctor.js +35 -4
- package/dist/commands/install-receipts.js +43 -6
- package/dist/commands/install-update.js +3 -1
- package/dist/commands/jarvis.js +136 -0
- package/dist/commands/local-args.js +29 -0
- package/dist/commands/local-auth.js +14 -0
- package/dist/commands/local-help.js +15 -0
- package/dist/commands/local.js +24 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +35 -6
- package/dist/commands/status.js +17 -1
- package/dist/cursors/backfill-cursor.js +23 -2
- package/dist/cursors/raw-evidence-cursor.js +14 -1
- package/dist/discovery-limits.js +12 -1
- package/dist/evidence-upload-client.js +41 -4
- package/dist/health-detail.js +111 -2
- package/dist/local-state.js +98 -11
- package/dist/onboarding-roots.js +3 -0
- package/dist/raw-evidence-attribution-policy.js +7 -0
- package/dist/raw-evidence-gc.js +5 -0
- package/dist/raw-evidence-staging.js +12 -1
- package/dist/repo-identity.js +15 -1
- package/dist/scheduled-self-update.js +14 -1
- package/dist/spool/install-event-outbox.js +11 -1
- package/dist/spool/local-spool.js +3 -0
- package/dist/sync-lock.js +24 -1
- package/dist/upload-agent-artifacts.js +11 -1
- package/dist/upload-envelope.js +30 -3
- package/dist/upload-http.js +10 -0
- package/dist/upload-session-reports.js +36 -3
- package/dist/upload.js +16 -5
- package/package.json +2 -2
package/dist/autostart.js
CHANGED
|
@@ -131,6 +131,9 @@ export async function uninstallAutostartAgent(options) {
|
|
|
131
131
|
if (!(await fileExists(plistPath))) {
|
|
132
132
|
return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
133
133
|
}
|
|
134
|
+
// Same reasoning as the install path above: an agent that was never loaded
|
|
135
|
+
// makes unload fail harmlessly, and the plist is removed either way, so the
|
|
136
|
+
// error is deliberately ignored (BLI-3238).
|
|
134
137
|
await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
135
138
|
await rm(plistPath, { force: true });
|
|
136
139
|
return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
|
package/dist/backfill-lock.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { describeError } from "./health-detail.js";
|
|
4
5
|
const BACKFILL_LOCK_FILENAME = "backfill.lock";
|
|
5
6
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
6
7
|
export const BACKFILL_LOCK_STALE_TAKEOVER_MS = 5 * 60_000;
|
|
@@ -65,10 +66,26 @@ async function tryExclusiveCreate(lockPath, token, now) {
|
|
|
65
66
|
await handle.close();
|
|
66
67
|
return true;
|
|
67
68
|
}
|
|
68
|
-
catch {
|
|
69
|
+
catch (error) {
|
|
70
|
+
// Same trap as sync-lock: EEXIST means another backfill holds it, and
|
|
71
|
+
// anything else (permissions, full disk, read-only volume) is reported as
|
|
72
|
+
// the identical "already running" and would keep the historical sweep from
|
|
73
|
+
// ever starting, permanently and silently (BLI-3238).
|
|
74
|
+
if (!isBackfillLockHeldError(error)) {
|
|
75
|
+
console.error("[backfill-lock] could not create the lock file; reporting the lock as held", JSON.stringify({
|
|
76
|
+
reason: "backfill_lock_create_failed",
|
|
77
|
+
...describeError(error),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
69
80
|
return false;
|
|
70
81
|
}
|
|
71
82
|
}
|
|
83
|
+
/** `wx` refusing because the lock exists — the ordinary contended case. */
|
|
84
|
+
function isBackfillLockHeldError(error) {
|
|
85
|
+
return (typeof error === "object" &&
|
|
86
|
+
error !== null &&
|
|
87
|
+
error.code === "EEXIST");
|
|
88
|
+
}
|
|
72
89
|
async function writeBackfillLock(lockPath, token, now) {
|
|
73
90
|
await fs.writeFile(lockPath, serializeBackfillLock(token, now), {
|
|
74
91
|
mode: 0o600,
|
|
@@ -103,6 +120,10 @@ async function readBackfillLockRecord(lockPath) {
|
|
|
103
120
|
};
|
|
104
121
|
}
|
|
105
122
|
catch {
|
|
123
|
+
// Deliberately silent (BLI-3238), same reasoning as sync-lock's reader:
|
|
124
|
+
// this is the "is anyone holding it?" probe, and every way it can fail
|
|
125
|
+
// means "not held", which is the answer the caller acts on. The failing
|
|
126
|
+
// ACQUIRE above owns the reporting.
|
|
106
127
|
return null;
|
|
107
128
|
}
|
|
108
129
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
1
|
+
import { NO_UPLOAD_ATTEMPT_RECORDED, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, notUploadableAttributionStateReason } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { describeError } from "../health-detail.js";
|
|
6
7
|
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
7
8
|
import { defaultCodexSessionDirs, scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
|
|
8
9
|
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
|
|
@@ -984,6 +985,10 @@ function isMissingFsError(error) {
|
|
|
984
985
|
async function countReadOnlyGuards(candidates) {
|
|
985
986
|
const counts = new Map();
|
|
986
987
|
const retryableCandidateKeys = new Set();
|
|
988
|
+
// Aggregated: this runs over the whole archived history, so a per-candidate
|
|
989
|
+
// line could be thousands. The count already travels; the reason did not
|
|
990
|
+
// (BLI-3238).
|
|
991
|
+
let firstReadFailure = null;
|
|
987
992
|
for (const candidate of candidates) {
|
|
988
993
|
if (candidate.reason === "repo_not_on_disk") {
|
|
989
994
|
increment(counts, "repo_not_on_disk");
|
|
@@ -1004,11 +1009,21 @@ async function countReadOnlyGuards(candidates) {
|
|
|
1004
1009
|
try {
|
|
1005
1010
|
await fs.readFile(candidate.file_path);
|
|
1006
1011
|
}
|
|
1007
|
-
catch {
|
|
1012
|
+
catch (error) {
|
|
1008
1013
|
increment(counts, "file_read_failed");
|
|
1014
|
+
firstReadFailure ??= describeError(error);
|
|
1009
1015
|
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
1010
1016
|
}
|
|
1011
1017
|
}
|
|
1018
|
+
const readFailedCount = counts.get("file_read_failed") ?? 0;
|
|
1019
|
+
if (readFailedCount > 0) {
|
|
1020
|
+
console.error("[cockpit-backfill] archived sessions could not be read", JSON.stringify({
|
|
1021
|
+
reason: "file_read_failed",
|
|
1022
|
+
read_failed_count: readFailedCount,
|
|
1023
|
+
candidate_count: candidates.length,
|
|
1024
|
+
...firstReadFailure,
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
1012
1027
|
return {
|
|
1013
1028
|
counts,
|
|
1014
1029
|
retryable_candidate_keys: retryableCandidateKeys,
|
|
@@ -1128,13 +1143,23 @@ async function ensureBackfillReportContext(options) {
|
|
|
1128
1143
|
await readLocalWorkContextForRepo(options.paths, repoRoot);
|
|
1129
1144
|
}
|
|
1130
1145
|
catch {
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1146
|
+
// Reading it can legitimately fail — there is no context yet, which is
|
|
1147
|
+
// precisely why the next line creates one. That read is a probe and stays
|
|
1148
|
+
// silent; the CREATE is the branch that has to speak (BLI-3238).
|
|
1133
1149
|
await startLocalWorkContext({
|
|
1134
1150
|
homeDir: options.homeDir,
|
|
1135
1151
|
repoRoot,
|
|
1136
1152
|
branch: representative?.branch,
|
|
1137
|
-
}).catch(() =>
|
|
1153
|
+
}).catch((error) => {
|
|
1154
|
+
// Context creation is best-effort here: postCodexSessionReport converts
|
|
1155
|
+
// a remaining local-context failure into a retryable report reason. But
|
|
1156
|
+
// that reason is `collector_not_ready`, which points the operator at
|
|
1157
|
+
// setup rather than at whatever actually failed here.
|
|
1158
|
+
console.error("[cockpit-backfill] could not start a local work context for the batch", JSON.stringify({
|
|
1159
|
+
reason: "work_context_start_failed",
|
|
1160
|
+
...describeError(error),
|
|
1161
|
+
}));
|
|
1162
|
+
});
|
|
1138
1163
|
}
|
|
1139
1164
|
return { repoRoot };
|
|
1140
1165
|
}
|
|
@@ -1188,7 +1213,8 @@ async function syncBackfillBatch(options) {
|
|
|
1188
1213
|
throw error;
|
|
1189
1214
|
}
|
|
1190
1215
|
}
|
|
1191
|
-
|
|
1216
|
+
/** Exported for the BLI-3272 regression test; not part of the CLI surface. */
|
|
1217
|
+
export function buildBackfillSessionReport(options) {
|
|
1192
1218
|
const uploadByKey = new Map();
|
|
1193
1219
|
// BLI-2107: an outcome that names a failure but has no pointer used to be
|
|
1194
1220
|
// dropped on the floor here, taking its reason with it.
|
|
@@ -1275,7 +1301,15 @@ function buildBackfillSessionReport(options) {
|
|
|
1275
1301
|
upload_reason: noUploadReasonBySessionId.get(candidate.session_id) ??
|
|
1276
1302
|
NO_UPLOAD_ATTEMPT_RECORDED,
|
|
1277
1303
|
}
|
|
1278
|
-
: {
|
|
1304
|
+
: {
|
|
1305
|
+
// BLI-3272: and the refused-attribution branch says why too. Same
|
|
1306
|
+
// NULL/NULL hole as live sync, same fix — backfill is the path
|
|
1307
|
+
// that revisits old sessions, so leaving it silent would keep
|
|
1308
|
+
// rewriting the very rows this ticket found.
|
|
1309
|
+
upload_state: "not_uploaded",
|
|
1310
|
+
upload_reason: noUploadReasonBySessionId.get(candidate.session_id) ??
|
|
1311
|
+
notUploadableAttributionStateReason(candidate.reason),
|
|
1312
|
+
}),
|
|
1279
1313
|
};
|
|
1280
1314
|
});
|
|
1281
1315
|
}
|
package/dist/commands/cli-io.js
CHANGED
|
@@ -67,6 +67,9 @@ export function parseCapturedJson(chunks) {
|
|
|
67
67
|
return JSON.parse(text);
|
|
68
68
|
}
|
|
69
69
|
catch {
|
|
70
|
+
// Deliberately silent (BLI-3238). The parse IS the question this function
|
|
71
|
+
// asks — "did the nested command print JSON or human text?" — and both
|
|
72
|
+
// answers are valid; the caller replays the raw text when it is not JSON.
|
|
70
73
|
return text;
|
|
71
74
|
}
|
|
72
75
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { autostartStatus, installAutostartAgent } from "../autostart.js";
|
|
4
4
|
import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
|
|
5
|
-
import { redactedHealthDetail } from "../health-detail.js";
|
|
5
|
+
import { describeError, redactedHealthDetail } from "../health-detail.js";
|
|
6
6
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
7
7
|
import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
8
8
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
@@ -146,14 +146,32 @@ async function fixCliLatest(context, state) {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
async function fixAuthState(context, state) {
|
|
149
|
-
const code = await context.deps.runLogin(context).catch(() =>
|
|
149
|
+
const code = await context.deps.runLogin(context).catch((error) => {
|
|
150
|
+
// Exit code 1 with no reason at all is what an operator saw when doctor
|
|
151
|
+
// tried and failed to repair their auth — the same output as a login that
|
|
152
|
+
// ran and was declined (BLI-3238).
|
|
153
|
+
console.error("[cockpit-doctor] login repair threw", JSON.stringify({
|
|
154
|
+
reason: "auth_repair_failed",
|
|
155
|
+
...describeError(error),
|
|
156
|
+
}));
|
|
157
|
+
return 1;
|
|
158
|
+
});
|
|
150
159
|
if (code !== 0)
|
|
151
160
|
return state;
|
|
152
161
|
const checked = await context.deps.readAuth(context);
|
|
153
162
|
return checked.status === "ok" ? checked : state;
|
|
154
163
|
}
|
|
155
164
|
async function fixRootState(context, state) {
|
|
156
|
-
await context.deps.resolveAndSaveRoots(context).catch(() =>
|
|
165
|
+
await context.deps.resolveAndSaveRoots(context).catch((error) => {
|
|
166
|
+
// Roots ARE the collection boundary. If saving them throws and nothing
|
|
167
|
+
// says so, the machine converges to "no approved root" and collects
|
|
168
|
+
// nothing — a green-looking doctor run over an empty boundary, which is
|
|
169
|
+
// the failure mode this whole ticket exists for.
|
|
170
|
+
console.error("[cockpit-doctor] collection roots could not be resolved or saved", JSON.stringify({
|
|
171
|
+
reason: "roots_repair_failed",
|
|
172
|
+
...describeError(error),
|
|
173
|
+
}));
|
|
174
|
+
});
|
|
157
175
|
const checked = await context.deps.readRoots(context);
|
|
158
176
|
return checked.status === "ok" ? checked : state;
|
|
159
177
|
}
|
|
@@ -350,7 +368,16 @@ function parseDoctorSyncJson(stdout) {
|
|
|
350
368
|
const parsed = JSON.parse(stdout.trim());
|
|
351
369
|
return parsed && typeof parsed === "object" ? parsed : null;
|
|
352
370
|
}
|
|
353
|
-
catch {
|
|
371
|
+
catch (error) {
|
|
372
|
+
// `null` sends doctor back to its regex field-scrape, quietly losing the
|
|
373
|
+
// BLI-2728 disambiguation. Something wrote to stdout that was not the one
|
|
374
|
+
// JSON document the contract promises — a stray console.log in the
|
|
375
|
+
// collector would do exactly this and look like nothing at all.
|
|
376
|
+
console.error("[cockpit-doctor] sync --json stdout was not one JSON document", JSON.stringify({
|
|
377
|
+
reason: "sync_json_unparseable",
|
|
378
|
+
byte_size: stdout.length,
|
|
379
|
+
...describeError(error),
|
|
380
|
+
}));
|
|
354
381
|
return null;
|
|
355
382
|
}
|
|
356
383
|
}
|
|
@@ -547,6 +574,10 @@ function parseNpmVersion(stdout) {
|
|
|
547
574
|
return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
|
|
548
575
|
}
|
|
549
576
|
catch {
|
|
577
|
+
// Deliberately silent (BLI-3238). `npm view ... version` prints a bare
|
|
578
|
+
// `1.2.3` without `--json` and a quoted `"1.2.3"` with it; the parse is
|
|
579
|
+
// the test for which one this npm produced, and the unquoted fallback is
|
|
580
|
+
// the intended handling of the other form, not a failure.
|
|
550
581
|
return trimmed.replace(/^"|"$/gu, "") || null;
|
|
551
582
|
}
|
|
552
583
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import { errorMessage, writeLine } from "./cli-io.js";
|
|
14
|
-
import { maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
|
|
14
|
+
import { describeError, maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
|
|
15
15
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
16
16
|
import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
|
|
17
17
|
import { COLLECTION_ROOT_REQUIRED } from "../onboarding-roots.js";
|
|
@@ -74,7 +74,17 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
74
74
|
})),
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
|
-
catch {
|
|
77
|
+
catch (error) {
|
|
78
|
+
// The existing operator line survives, and is `--json`-only. This one is
|
|
79
|
+
// unconditional and carries the reason: the machine has just dropped the
|
|
80
|
+
// health receipts for a whole command, and a `--json` gate meant the
|
|
81
|
+
// normal interactive run said nothing at all (BLI-3238).
|
|
82
|
+
console.error("[install-receipts] could not queue install events; the receipts are lost", JSON.stringify({
|
|
83
|
+
reason: "local_write_failed",
|
|
84
|
+
command: options.command,
|
|
85
|
+
event_count: options.events.length,
|
|
86
|
+
...describeError(error),
|
|
87
|
+
}));
|
|
78
88
|
if (options.json) {
|
|
79
89
|
writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
|
|
80
90
|
}
|
|
@@ -112,9 +122,18 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
112
122
|
if (!response.ok) {
|
|
113
123
|
throw new Error(`http_${response.status}`);
|
|
114
124
|
}
|
|
115
|
-
const receipt = (await response
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
const receipt = (await response.json().catch((error) => {
|
|
126
|
+
// This reply carries the server-published `min_cli_version` floor
|
|
127
|
+
// (BLI-2678). A body that will not parse means the floor is not
|
|
128
|
+
// observed on this tick and the forced-update path silently does
|
|
129
|
+
// nothing — while the 2xx above says the receipt landed fine.
|
|
130
|
+
console.error("[install-receipts] receipt body unreadable; no min_cli_version observed", JSON.stringify({
|
|
131
|
+
reason: "receipt_body_unreadable",
|
|
132
|
+
http_status: response.status,
|
|
133
|
+
...describeError(error),
|
|
134
|
+
}));
|
|
135
|
+
return null;
|
|
136
|
+
}));
|
|
118
137
|
if (typeof receipt?.min_cli_version === "string" &&
|
|
119
138
|
receipt.min_cli_version.trim()) {
|
|
120
139
|
observedMinCliVersion = receipt.min_cli_version.trim();
|
|
@@ -124,10 +143,28 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
124
143
|
catch (error) {
|
|
125
144
|
const failureReason = classifyInstallTelemetryError(error);
|
|
126
145
|
failures.push(failureReason);
|
|
146
|
+
// The classified reason is the coarse bucket the outbox row keeps;
|
|
147
|
+
// beside it, what actually happened. `network_error` covers DNS,
|
|
148
|
+
// TLS, timeout and abort, and only one of those is worth waking up
|
|
149
|
+
// for (BLI-3238).
|
|
150
|
+
console.error("[install-receipts] install event delivery failed, entry kept for retry", JSON.stringify({
|
|
151
|
+
reason: failureReason,
|
|
152
|
+
outbox_id: entry.outbox_id,
|
|
153
|
+
...describeError(error),
|
|
154
|
+
}));
|
|
127
155
|
await recordInstallEventAttemptFailure(paths, entry, {
|
|
128
156
|
attemptedAt: new Date().toISOString(),
|
|
129
157
|
failureReason,
|
|
130
|
-
}).catch(() =>
|
|
158
|
+
}).catch((writeError) => {
|
|
159
|
+
// Double failure: delivery failed AND the retry bookkeeping did.
|
|
160
|
+
// The entry stays queued, so nothing is lost, but the attempt
|
|
161
|
+
// count stops advancing and the outbox looks stuck for no reason.
|
|
162
|
+
console.error("[install-receipts] could not record the delivery failure against the entry", JSON.stringify({
|
|
163
|
+
reason: "attempt_bookkeeping_failed",
|
|
164
|
+
outbox_id: entry.outbox_id,
|
|
165
|
+
...describeError(writeError),
|
|
166
|
+
}));
|
|
167
|
+
});
|
|
131
168
|
}
|
|
132
169
|
finally {
|
|
133
170
|
clearTimeout(timeout);
|
|
@@ -295,7 +295,9 @@ async function findPublicReleaseRoot(startDir) {
|
|
|
295
295
|
}
|
|
296
296
|
catch {
|
|
297
297
|
// Keep walking: nested packages may be missing package.json or have one
|
|
298
|
-
// without the release script.
|
|
298
|
+
// without the release script. Deliberately silent (BLI-3238) — this is
|
|
299
|
+
// a search, every level that is not the answer fails here, and the
|
|
300
|
+
// caller reports `null` when the walk finds nothing.
|
|
299
301
|
}
|
|
300
302
|
const parent = path.dirname(current);
|
|
301
303
|
if (parent === current)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal adapter for the shared JARVIS conversation gateway.
|
|
3
|
+
*
|
|
4
|
+
* This file owns terminal input and output only. Identity comes from the
|
|
5
|
+
* existing paired device session, and every turn is executed by the dashboard
|
|
6
|
+
* through the same JARVIS runtime used by web chat and Slack.
|
|
7
|
+
*/
|
|
8
|
+
import { errorMessage, isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
|
|
9
|
+
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
10
|
+
export async function runJarvis(command, io) {
|
|
11
|
+
const session = await loadPairedSession(command.homeDir);
|
|
12
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
13
|
+
const oneShotPrompt = await resolveOneShotPrompt(command, io);
|
|
14
|
+
if (oneShotPrompt !== null) {
|
|
15
|
+
return sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, oneShotPrompt, io);
|
|
16
|
+
}
|
|
17
|
+
if (command.json) {
|
|
18
|
+
throw new Error("cockpit jarvis --json needs --prompt, positional text, or piped stdin.");
|
|
19
|
+
}
|
|
20
|
+
writeLine(io.stdout, "JARVIS terminal chat. Type /exit to leave.");
|
|
21
|
+
while (true) {
|
|
22
|
+
const prompt = (await readLine(io, "you> ")).trim();
|
|
23
|
+
if (!prompt)
|
|
24
|
+
continue;
|
|
25
|
+
if (prompt === "/exit" || prompt === "/quit")
|
|
26
|
+
return 0;
|
|
27
|
+
const exitCode = await sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, prompt, io);
|
|
28
|
+
if (exitCode !== 0)
|
|
29
|
+
return exitCode;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function loadPairedSession(homeDir) {
|
|
33
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
34
|
+
try {
|
|
35
|
+
const session = await readLocalCollectorSessionFile(paths);
|
|
36
|
+
if (session.session_state !== "valid") {
|
|
37
|
+
throw new Error(`session_${session.session_state}`);
|
|
38
|
+
}
|
|
39
|
+
return session;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(`JARVIS needs a valid paired Cockpit session. Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function resolveOneShotPrompt(command, io) {
|
|
46
|
+
if (command.prompt)
|
|
47
|
+
return validatePrompt(command.prompt);
|
|
48
|
+
if (isInteractiveStdin(io))
|
|
49
|
+
return null;
|
|
50
|
+
const piped = await readAll(io.stdin);
|
|
51
|
+
return validatePrompt(piped);
|
|
52
|
+
}
|
|
53
|
+
async function sendOneTurn(context, prompt, io) {
|
|
54
|
+
const startedAt = Date.now();
|
|
55
|
+
let response;
|
|
56
|
+
try {
|
|
57
|
+
response = await io.fetch(`${context.dashboardUrl}/api/jarvis/cli`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: {
|
|
60
|
+
authorization: `Bearer ${context.deviceToken}`,
|
|
61
|
+
"content-type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({ question: prompt, thread: context.command.thread }),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
writeFailure(context.command, io, "gateway_unreachable", errorMessage(error));
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
const body = await readReply(response);
|
|
71
|
+
if (!response.ok || !body.ok || !body.reply) {
|
|
72
|
+
const reason = body.error ?? body.reply ?? `http_${response.status}`;
|
|
73
|
+
writeFailure(context.command, io, "turn_failed", reason);
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
if (context.command.json) {
|
|
77
|
+
writeLine(io.stdout, JSON.stringify({
|
|
78
|
+
ok: true,
|
|
79
|
+
reply: body.reply,
|
|
80
|
+
thread: body.thread ?? context.command.thread,
|
|
81
|
+
model: body.model ?? null,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
writeLine(io.stdout, `jarvis> ${body.reply}`);
|
|
86
|
+
}
|
|
87
|
+
writeLine(io.stderr, `[jarvis cli] answered ${JSON.stringify({
|
|
88
|
+
prompt_length: prompt.length,
|
|
89
|
+
reply_length: body.reply.length,
|
|
90
|
+
elapsed_ms: Date.now() - startedAt,
|
|
91
|
+
thread: context.command.thread === "main" ? "default" : "named",
|
|
92
|
+
})}`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
async function readReply(response) {
|
|
96
|
+
const text = await response.text();
|
|
97
|
+
if (!text)
|
|
98
|
+
return { ok: false, error: "empty_response" };
|
|
99
|
+
try {
|
|
100
|
+
const value = JSON.parse(text);
|
|
101
|
+
if (!value || typeof value !== "object") {
|
|
102
|
+
return { ok: false, error: "response_body_not_object" };
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { ok: false, error: "response_body_not_json" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function writeFailure(command, io, reason, detail) {
|
|
111
|
+
if (command.json) {
|
|
112
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
writeLine(io.stderr, `JARVIS could not answer: ${detail}`);
|
|
116
|
+
}
|
|
117
|
+
function validatePrompt(raw) {
|
|
118
|
+
const prompt = raw.trim();
|
|
119
|
+
if (!prompt)
|
|
120
|
+
throw new Error("JARVIS needs a non-empty question.");
|
|
121
|
+
if (prompt.length > 4000) {
|
|
122
|
+
throw new Error("JARVIS questions are limited to 4000 characters.");
|
|
123
|
+
}
|
|
124
|
+
return prompt;
|
|
125
|
+
}
|
|
126
|
+
async function readAll(stream) {
|
|
127
|
+
stream.setEncoding("utf8");
|
|
128
|
+
let text = "";
|
|
129
|
+
for await (const chunk of stream) {
|
|
130
|
+
text += chunk;
|
|
131
|
+
if (text.length > 4000) {
|
|
132
|
+
throw new Error("JARVIS questions are limited to 4000 characters.");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return text;
|
|
136
|
+
}
|
|
@@ -47,6 +47,8 @@ export function parseLocalArgs(argv) {
|
|
|
47
47
|
return parseSyncArgs(argv.slice(1));
|
|
48
48
|
case "analyze":
|
|
49
49
|
return parseAnalyzeArgs(argv.slice(1));
|
|
50
|
+
case "jarvis":
|
|
51
|
+
return parseJarvisArgs(argv.slice(1));
|
|
50
52
|
case "backfill":
|
|
51
53
|
return parseBackfillArgs(argv.slice(1));
|
|
52
54
|
case "status":
|
|
@@ -607,6 +609,29 @@ function parseAgentRulesHost(value) {
|
|
|
607
609
|
return host;
|
|
608
610
|
throw new Error("agent-rules --host must be codex, claude, or all.");
|
|
609
611
|
}
|
|
612
|
+
function parseJarvisArgs(args) {
|
|
613
|
+
const values = parseNamedArgs(args, {
|
|
614
|
+
allowedFlags: ["--home", "--dashboard-url", "--prompt", "--thread", "--json"],
|
|
615
|
+
valueFlags: ["--home", "--dashboard-url", "--prompt", "--thread"],
|
|
616
|
+
});
|
|
617
|
+
const flaggedPrompt = optionalNonEmpty(values.flags.get("--prompt"));
|
|
618
|
+
const positionalPrompt = optionalNonEmpty(values.positionals.join(" "));
|
|
619
|
+
if (flaggedPrompt && positionalPrompt) {
|
|
620
|
+
throw new Error("jarvis accepts either --prompt or positional text, not both.");
|
|
621
|
+
}
|
|
622
|
+
const thread = optionalNonEmpty(values.flags.get("--thread")) ?? "main";
|
|
623
|
+
if (!/^[A-Za-z0-9_-]{1,40}$/.test(thread)) {
|
|
624
|
+
throw new Error("jarvis --thread must use 1 to 40 letters, numbers, underscores, or hyphens.");
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
kind: "jarvis",
|
|
628
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
629
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
630
|
+
prompt: flaggedPrompt ?? positionalPrompt,
|
|
631
|
+
thread,
|
|
632
|
+
json: values.booleans.has("--json"),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
610
635
|
function parseNamedArgs(args, options) {
|
|
611
636
|
const allowed = new Set(options.allowedFlags);
|
|
612
637
|
const valueFlags = new Set(options.valueFlags);
|
|
@@ -739,6 +764,10 @@ function looksLikeServiceRoleSecret(value) {
|
|
|
739
764
|
return serviceCredentialPayloadPattern().test(payload);
|
|
740
765
|
}
|
|
741
766
|
catch {
|
|
767
|
+
// Deliberately silent (BLI-3238), and it must stay silent: this is the
|
|
768
|
+
// "is this argument a service-role JWT?" test, so a value that will not
|
|
769
|
+
// decode is simply not one. Anything logged here would be a fragment of a
|
|
770
|
+
// credential.
|
|
742
771
|
return false;
|
|
743
772
|
}
|
|
744
773
|
}
|
|
@@ -185,6 +185,15 @@ async function readJsonResponse(response) {
|
|
|
185
185
|
return JSON.parse(text);
|
|
186
186
|
}
|
|
187
187
|
catch {
|
|
188
|
+
// OTP path. The text is handed to the caller for the human-facing message
|
|
189
|
+
// but never logged — it is an unbounded page from whatever answered. The
|
|
190
|
+
// shape is what says "a proxy replied, not the dashboard" (BLI-3238).
|
|
191
|
+
console.error("[local-auth] auth reply was not JSON", JSON.stringify({
|
|
192
|
+
reason: "response_body_not_json",
|
|
193
|
+
http_status: response.status,
|
|
194
|
+
byte_size: text.length,
|
|
195
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
196
|
+
}));
|
|
188
197
|
return text;
|
|
189
198
|
}
|
|
190
199
|
}
|
|
@@ -260,6 +269,11 @@ export async function readOnboardSessionReuseCandidate(homeDir) {
|
|
|
260
269
|
return await readLocalCollectorSessionFile(paths);
|
|
261
270
|
}
|
|
262
271
|
catch {
|
|
272
|
+
// Deliberately silent (BLI-3238). This is the "can we reuse an existing
|
|
273
|
+
// login?" probe and the fallback below reads the same file through the
|
|
274
|
+
// looser schema — which reports its own reason when it also fails
|
|
275
|
+
// (`session_file_unusable` in local-state). Logging here would double
|
|
276
|
+
// every line for one read.
|
|
263
277
|
return readLocalSessionReference(paths);
|
|
264
278
|
}
|
|
265
279
|
}
|
|
@@ -21,6 +21,7 @@ export const rootCommandNames = new Set([
|
|
|
21
21
|
"start",
|
|
22
22
|
"sync",
|
|
23
23
|
"analyze",
|
|
24
|
+
"jarvis",
|
|
24
25
|
"backfill",
|
|
25
26
|
"status",
|
|
26
27
|
"sessions",
|
|
@@ -45,6 +46,7 @@ export function localCommandHelp(command) {
|
|
|
45
46
|
" cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
46
47
|
" cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
47
48
|
" cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
49
|
+
" cockpit jarvis [question] [--prompt <question>] [--thread <name>] [--dashboard-url <url>] [--json]",
|
|
48
50
|
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
49
51
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
50
52
|
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
@@ -185,6 +187,19 @@ function localSubcommandHelp(command) {
|
|
|
185
187
|
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
186
188
|
],
|
|
187
189
|
],
|
|
190
|
+
[
|
|
191
|
+
"jarvis",
|
|
192
|
+
[
|
|
193
|
+
"Usage: cockpit jarvis [question] [--prompt <question>] [--thread <name>] [--dashboard-url <url>] [--json]",
|
|
194
|
+
"",
|
|
195
|
+
"Chats with the same JARVIS used by Cockpit web chat and the BLI Slack DM.",
|
|
196
|
+
"Run with no question for an interactive terminal conversation.",
|
|
197
|
+
"Agents can pass --prompt, positional text, or pipe one question on stdin.",
|
|
198
|
+
"--json writes one machine-readable response to stdout; operational metadata stays on stderr.",
|
|
199
|
+
"The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
|
|
200
|
+
"Run `cockpit login` first if this machine is not paired.",
|
|
201
|
+
],
|
|
202
|
+
],
|
|
188
203
|
[
|
|
189
204
|
"backfill",
|
|
190
205
|
[
|
package/dist/commands/local.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* install-update.ts install, update, self-update, release
|
|
17
17
|
* status.ts `cockpit status`
|
|
18
18
|
* sessions.ts `cockpit sessions`
|
|
19
|
+
* jarvis.ts terminal adapter for the shared JARVIS gateway
|
|
19
20
|
*
|
|
20
21
|
* What stays here is orchestration: onboard, login/logout/start, the sync tick,
|
|
21
22
|
* analyze, serve, autostart and agent-rules.
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
import path from "node:path";
|
|
24
25
|
import { bufferedWritable, defaultExec, defaultIo, errorMessage, parseCapturedJson, replayCaptured, writeLine, } from "./cli-io.js";
|
|
25
26
|
import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
|
|
27
|
+
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
26
28
|
import { addInstallEvent, classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
27
29
|
import { canReuseOnboardSession, pairLocalCollectorWithAuthFallback, readOnboardSessionReuseCandidate, requestPairingAccessToken, requestPairingAccessTokenDetailed, resolveInteractiveLoginEmail, resolveOnboardEmail, } from "./local-auth.js";
|
|
28
30
|
import { collectionRootConsentAliases, persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
|
|
@@ -31,6 +33,7 @@ import { attributedSyncRunStatus, cursorStatusLine, displayTicketId, rawEvidence
|
|
|
31
33
|
import { runInstall, runRelease, runSelfUpdate, runUpdate, SelfUpdateError, } from "./install-update.js";
|
|
32
34
|
import { runStatus } from "./status.js";
|
|
33
35
|
import { runSessions } from "./sessions.js";
|
|
36
|
+
import { runJarvis } from "./jarvis.js";
|
|
34
37
|
import { createCollectorServer } from "../server.js";
|
|
35
38
|
import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
|
|
36
39
|
import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
|
|
@@ -97,6 +100,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
97
100
|
return await runSync(command, io);
|
|
98
101
|
case "analyze":
|
|
99
102
|
return await runAnalyze(command, io);
|
|
103
|
+
case "jarvis":
|
|
104
|
+
return await runJarvis(command, io);
|
|
100
105
|
case "backfill":
|
|
101
106
|
return await runBackfillCommand(command, io);
|
|
102
107
|
case "status":
|
|
@@ -1372,7 +1377,16 @@ async function runAnalyze(command, io) {
|
|
|
1372
1377
|
return syncExitCode === 0 ? 1 : syncExitCode;
|
|
1373
1378
|
}
|
|
1374
1379
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
1375
|
-
const session = await readLocalCollectorSessionFile(paths).catch(() => {
|
|
1380
|
+
const session = await readLocalCollectorSessionFile(paths).catch((error) => {
|
|
1381
|
+
// "Not signed in" is the right sentence for an absent session file and the
|
|
1382
|
+
// wrong one for a corrupt one, which `cockpit login` will not repair
|
|
1383
|
+
// (BLI-3238).
|
|
1384
|
+
if (!isMissingFileFailure(error)) {
|
|
1385
|
+
console.error("[cockpit-analyze] session file present but unreadable, reporting as not signed in", JSON.stringify({
|
|
1386
|
+
reason: "session_file_unusable",
|
|
1387
|
+
...describeError(error),
|
|
1388
|
+
}));
|
|
1389
|
+
}
|
|
1376
1390
|
throw new Error("Cockpit is not signed in. Run `cockpit onboard` or `cockpit login` first.");
|
|
1377
1391
|
});
|
|
1378
1392
|
const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
|
|
@@ -1436,6 +1450,15 @@ async function readAnalyzeApiResponse(response) {
|
|
|
1436
1450
|
return value && typeof value === "object" ? value : {};
|
|
1437
1451
|
}
|
|
1438
1452
|
catch {
|
|
1453
|
+
// `{}` erases whatever the analyze endpoint said, including its error, and
|
|
1454
|
+
// the operator is left with a job id of `undefined` and no reason. The
|
|
1455
|
+
// body is never logged; its shape is what identifies a proxy reply.
|
|
1456
|
+
console.error("[cockpit-analyze] reply was not JSON", JSON.stringify({
|
|
1457
|
+
reason: "response_body_not_json",
|
|
1458
|
+
http_status: response.status,
|
|
1459
|
+
byte_size: text.length,
|
|
1460
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
1461
|
+
}));
|
|
1439
1462
|
return {};
|
|
1440
1463
|
}
|
|
1441
1464
|
}
|
|
@@ -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.32");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|