@bli-cockpit/cli 0.2.36 → 0.2.38

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,82 @@
1
+ /**
2
+ * Reads a local file for `cockpit jarvis --image <path>` (BLI-3414), before
3
+ * anything is uploaded.
4
+ *
5
+ * The dashboard's `/api/jarvis/cli` route is the ONE authority on whether an
6
+ * attached image is acceptable — it runs the exact same
7
+ * `checkAttachedImage`/`attachedImageRefusalSentence` gate the JARVIS panel
8
+ * uses (`apps/dashboard/src/lib/jarvis/chat-v2/attached-image.ts`), and this
9
+ * package cannot import from `apps/dashboard` (separate workspace, no
10
+ * dependency edge). So this module only catches what is cheap and purely
11
+ * local to check BEFORE spending a network round trip: does the path exist
12
+ * and is it readable, and does its extension even look like an image. Byte
13
+ * size and decoded-dimension refusals still come from the server and are
14
+ * relayed to the terminal verbatim (`readReply`/`writeFailure` in
15
+ * `jarvis.ts`) — this module's `ATTACHED_IMAGE_MAX_BYTES` constant is a local
16
+ * courtesy check to avoid uploading something the server is certain to
17
+ * refuse, not a second source of truth; if the two ever drift, the server's
18
+ * check still wins.
19
+ */
20
+ import { readFile, stat } from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { errorMessage } from "./cli-io.js";
23
+ /** Mirrors `ATTACHED_IMAGE_MAX_BYTES` in `apps/dashboard/src/lib/jarvis/chat-v2/attached-image.ts`. */
24
+ export const ATTACHED_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
25
+ const EXTENSION_MIME_TYPES = {
26
+ ".png": "image/png",
27
+ ".jpg": "image/jpeg",
28
+ ".jpeg": "image/jpeg",
29
+ ".webp": "image/webp",
30
+ };
31
+ /** The sentence the terminal prints for a refusal caught locally, before any request goes out. */
32
+ export function attachedFileRefusalSentence(refusal, filePath) {
33
+ switch (refusal) {
34
+ case "file_not_found":
35
+ return `I could not find that file: ${filePath}`;
36
+ case "file_unreadable":
37
+ return `I could not read that file: ${filePath}`;
38
+ case "unsupported_file_type":
39
+ // Same wording as the panel's own refusal for this case (BLI-3170).
40
+ return "That is not an image I can read — attach a PNG, JPEG, or WEBP.";
41
+ case "file_too_big":
42
+ // Same wording as the panel's own refusal for this case (BLI-3170).
43
+ return "That image is too big to attach — keep it under 10 MB.";
44
+ }
45
+ }
46
+ /**
47
+ * Reads and locally screens one attached file. Never throws — every failure
48
+ * mode comes back as a named refusal so the caller can print a plain
49
+ * sentence instead of a stack trace.
50
+ */
51
+ export async function readAttachedImage(filePath) {
52
+ const extension = path.extname(filePath).toLowerCase();
53
+ const mimeType = EXTENSION_MIME_TYPES[extension];
54
+ if (!mimeType) {
55
+ return { ok: false, refusal: "unsupported_file_type", detail: extension || "no_extension" };
56
+ }
57
+ let size;
58
+ try {
59
+ const info = await stat(filePath);
60
+ if (!info.isFile()) {
61
+ return { ok: false, refusal: "file_unreadable", detail: "not_a_regular_file" };
62
+ }
63
+ size = info.size;
64
+ }
65
+ catch (error) {
66
+ const code = error.code;
67
+ if (code === "ENOENT")
68
+ return { ok: false, refusal: "file_not_found", detail: "enoent" };
69
+ return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
70
+ }
71
+ if (size > ATTACHED_IMAGE_MAX_BYTES) {
72
+ return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
73
+ }
74
+ let bytes;
75
+ try {
76
+ bytes = await readFile(filePath);
77
+ }
78
+ catch (error) {
79
+ return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
80
+ }
81
+ return { ok: true, bytes, mimeType, fileName: path.basename(filePath) };
82
+ }
@@ -5,12 +5,21 @@
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 { errorMessage, isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
9
- import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
8
+ import { isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
9
+ import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
10
+ import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
11
+ import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
10
12
  const TRACE_DIM = "\x1b[2m";
11
13
  const TRACE_RESET = "\x1b[0m";
14
+ /**
15
+ * The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
16
+ * waits slightly longer so the server's own named failure wins the race
17
+ * whenever it manages to send one; past that, the terminal names the timeout
18
+ * itself rather than sitting there.
19
+ */
20
+ const TURN_DEADLINE_MS = 125_000;
12
21
  export async function runJarvis(command, io) {
13
- const session = await loadPairedSession(command.homeDir);
22
+ const session = await loadPairedSession("jarvis", command.homeDir);
14
23
  const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
15
24
  const oneShotPrompt = await resolveOneShotPrompt(command, io);
16
25
  if (oneShotPrompt !== null) {
@@ -31,19 +40,6 @@ export async function runJarvis(command, io) {
31
40
  return exitCode;
32
41
  }
33
42
  }
34
- async function loadPairedSession(homeDir) {
35
- const paths = getCollectorRuntimePaths(homeDir);
36
- try {
37
- const session = await readLocalCollectorSessionFile(paths);
38
- if (session.session_state !== "valid") {
39
- throw new Error(`session_${session.session_state}`);
40
- }
41
- return session;
42
- }
43
- catch (error) {
44
- throw new Error(`JARVIS needs a valid paired Tower session. Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
45
- }
46
- }
47
43
  async function resolveOneShotPrompt(command, io) {
48
44
  if (command.prompt)
49
45
  return validatePrompt(command.prompt);
@@ -54,60 +50,143 @@ async function resolveOneShotPrompt(command, io) {
54
50
  }
55
51
  async function sendOneTurn(context, prompt, io) {
56
52
  const startedAt = Date.now();
57
- let response;
58
- try {
59
- response = await io.fetch(`${context.dashboardUrl}/api/jarvis/cli`, {
60
- method: "POST",
61
- headers: {
62
- authorization: `Bearer ${context.deviceToken}`,
63
- "content-type": "application/json",
64
- },
65
- body: JSON.stringify({
53
+ // BLI-3414: an attached file is read and locally screened (exists,
54
+ // readable, a supported extension, under the byte ceiling) BEFORE any
55
+ // network call a refusal here never reaches the dashboard and is
56
+ // terminal, same discipline as the panel's own attached-image gate.
57
+ let attachment = null;
58
+ if (context.command.imagePath) {
59
+ const read = await readAttachedImage(context.command.imagePath);
60
+ if (!read.ok) {
61
+ writeAttachmentRefusal(context.command, io, read.refusal, context.command.imagePath);
62
+ return 1;
63
+ }
64
+ attachment = read;
65
+ }
66
+ // BLI-3457: streaming is the default. A dashboard that has not shipped the
67
+ // NDJSON half yet answers `application/json`, which the reader takes as a
68
+ // single final event and names `stream_not_available` — so the terminal
69
+ // works against both server versions with no flag.
70
+ const wantsStream = context.command.stream !== false;
71
+ const log = (line) => writeLine(io.stderr, line);
72
+ const requested = await towerRequest({
73
+ dashboardUrl: context.dashboardUrl,
74
+ path: "/api/jarvis/cli",
75
+ deviceToken: context.deviceToken,
76
+ fetch: io.fetch,
77
+ label: "jarvis",
78
+ timeoutMs: TURN_DEADLINE_MS,
79
+ headers: wantsStream ? { accept: "application/x-ndjson" } : {},
80
+ body: attachment
81
+ ? buildAttachmentForm(context.command, prompt, attachment)
82
+ : {
66
83
  question: prompt,
67
84
  thread: context.command.thread,
68
85
  subject: context.command.subject,
69
86
  model: context.command.model,
70
- }),
71
- });
87
+ },
88
+ log,
89
+ });
90
+ if (!requested.ok) {
91
+ writeFailure(context.command, io, requested.reason, towerFailureDetail(requested.reason, requested.detail));
92
+ return 1;
72
93
  }
73
- catch (error) {
74
- writeFailure(context.command, io, "gateway_unreachable", errorMessage(error));
94
+ // Live trace lines go to a person as they land, never to a `--json`
95
+ // consumer: that contract is exactly one object on stdout, so the events are
96
+ // buffered and folded into the final payload instead.
97
+ let liveTraceLines = 0;
98
+ const turn = await readTowerTurn(requested.response, {
99
+ startedAt,
100
+ log,
101
+ onActivity: (event) => {
102
+ if (context.command.json)
103
+ return;
104
+ if (writeActivityLine(io, event))
105
+ liveTraceLines += 1;
106
+ },
107
+ onNote: (reason, detail) => {
108
+ log(`[jarvis cli] stream note ${JSON.stringify({
109
+ reason,
110
+ ...(detail && reason !== "ndjson_line_unparseable" ? { detail } : {}),
111
+ })}`);
112
+ },
113
+ });
114
+ if (!turn.ok) {
115
+ writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
75
116
  return 1;
76
117
  }
77
- const body = await readReply(response);
78
- if (!response.ok || !body.ok || !body.reply) {
79
- const reason = body.error ?? body.reply ?? `http_${response.status}`;
118
+ const body = turn.final;
119
+ const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
120
+ if (httpStatus >= 400 || !body.ok || !body.reply) {
121
+ const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
80
122
  writeFailure(context.command, io, "turn_failed", reason);
81
123
  return 1;
82
124
  }
125
+ // A streaming server may leave the settled trace out of the final event
126
+ // because it already sent every step live; the activity we collected is that
127
+ // same trace, so `--json` still gets one.
128
+ const trace = body.trace ?? activityToTrace(turn.activity);
83
129
  if (context.command.json) {
84
130
  writeLine(io.stdout, JSON.stringify({
85
131
  ok: true,
86
132
  reply: body.reply,
87
133
  thread: body.thread ?? context.command.thread,
88
134
  model: body.model ?? null,
89
- trace: body.trace ?? [],
135
+ trace,
90
136
  subject: body.subject ?? null,
91
137
  }));
92
138
  }
93
139
  else {
94
140
  const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
95
141
  writeLine(io.stdout, `jarvis${subject}> ${body.reply}`);
96
- writeTraceBlock(io, body.trace);
142
+ // Only when nothing was drawn live — otherwise every tool would print twice.
143
+ if (liveTraceLines === 0)
144
+ writeTraceBlock(io, trace);
97
145
  writeModelReceipt(io, body.model);
98
146
  }
99
147
  writeLine(io.stderr, `[jarvis cli] answered ${JSON.stringify({
100
148
  prompt_length: prompt.length,
101
149
  reply_length: body.reply.length,
102
- trace_steps: body.trace?.length ?? 0,
103
- trace_failed: body.trace?.filter((step) => step.status === "failed").length ?? 0,
150
+ trace_steps: trace?.length ?? 0,
151
+ trace_failed: trace?.filter((step) => step.status === "failed").length ?? 0,
104
152
  model_requested: context.command.model ?? null,
105
153
  elapsed_ms: Date.now() - startedAt,
106
154
  thread: context.command.thread === "main" ? "default" : "named",
107
155
  subject: context.command.subject ? "selected" : "caller",
156
+ image_attached: attachment !== null,
157
+ image_byte_size: attachment?.bytes.byteLength ?? null,
158
+ streamed: turn.streamed,
159
+ live_trace_lines: liveTraceLines,
108
160
  })}`);
109
161
  return 0;
110
162
  }
163
+ /**
164
+ * The multipart body `/api/jarvis/cli` reads when a file is attached
165
+ * (BLI-3414) — same field names the request handler parses, mirroring the
166
+ * JSON body's fields plus one `image` file field.
167
+ */
168
+ function buildAttachmentForm(command, prompt, attachment) {
169
+ const form = new FormData();
170
+ form.set("question", prompt);
171
+ form.set("thread", command.thread);
172
+ if (command.subject)
173
+ form.set("subject", command.subject);
174
+ if (command.model)
175
+ form.set("model", command.model);
176
+ form.set("image", new File([attachment.bytes], attachment.fileName, { type: attachment.mimeType }));
177
+ return form;
178
+ }
179
+ /** A refusal caught locally, before any request went out — never a stack trace. */
180
+ function writeAttachmentRefusal(command, io, refusal, filePath) {
181
+ const message = attachedFileRefusalSentence(refusal, filePath);
182
+ if (command.json) {
183
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: refusal, detail: message }));
184
+ }
185
+ else {
186
+ writeLine(io.stderr, `JARVIS could not attach that file: ${message}`);
187
+ }
188
+ writeLine(io.stderr, `[jarvis cli] attachment refused ${JSON.stringify({ reason: refusal })}`);
189
+ }
111
190
  /**
112
191
  * One dim line per tool the turn called (BLI-3381): what it was, how long it
113
192
  * took, and — for a failure — the reason, matching what the web thinking
@@ -117,11 +196,53 @@ async function sendOneTurn(context, prompt, io) {
117
196
  function writeTraceBlock(io, trace) {
118
197
  if (!trace || trace.length === 0)
119
198
  return;
120
- for (const step of trace) {
121
- const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
122
- const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
123
- writeLine(io.stdout, `${TRACE_DIM} ⏺ ${step.label}${elapsed}${failure}${TRACE_RESET}`);
199
+ for (const step of trace)
200
+ writeTraceLine(io, step);
201
+ }
202
+ /** The one dim trace line, drawn identically live (BLI-3457) and after the fact. */
203
+ function writeTraceLine(io, step) {
204
+ const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
205
+ const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
206
+ writeLine(io.stdout, `${TRACE_DIM} ⏺ ${step.label}${elapsed}${failure}${TRACE_RESET}`);
207
+ }
208
+ /**
209
+ * Draws one streamed tool call, and says whether it drew anything.
210
+ *
211
+ * Only settled steps print: a `running` event is the same tool arriving a
212
+ * second time, and printing both would double every line in a terminal that
213
+ * cannot rewrite the one above it.
214
+ */
215
+ function writeActivityLine(io, event) {
216
+ if (event.status !== "done" && event.status !== "failed")
217
+ return false;
218
+ const step = activityToStep(event);
219
+ if (!step)
220
+ return false;
221
+ writeTraceLine(io, step);
222
+ return true;
223
+ }
224
+ /** The settled steps of a streamed turn, in the shape `--json` already promises. */
225
+ function activityToTrace(activity) {
226
+ const steps = [];
227
+ for (const event of activity) {
228
+ if (event.status !== "done" && event.status !== "failed")
229
+ continue;
230
+ const step = activityToStep(event);
231
+ if (step)
232
+ steps.push(step);
124
233
  }
234
+ return steps;
235
+ }
236
+ function activityToStep(event) {
237
+ if (event.status !== "done" && event.status !== "failed")
238
+ return null;
239
+ return {
240
+ tool: event.tool ?? "unknown_tool",
241
+ label: event.label ?? event.tool ?? "A tool ran",
242
+ status: event.status,
243
+ ...(typeof event.elapsedMs === "number" ? { elapsedMs: event.elapsedMs } : {}),
244
+ ...(event.detail ? { detail: event.detail } : {}),
245
+ };
125
246
  }
126
247
  /**
127
248
  * One line, only when the answer did not come from what was requested — a
@@ -138,21 +259,6 @@ function writeModelReceipt(io, model) {
138
259
  return;
139
260
  writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered (${requested ?? "the requested model"} unavailable)`);
140
261
  }
141
- async function readReply(response) {
142
- const text = await response.text();
143
- if (!text)
144
- return { ok: false, error: "empty_response" };
145
- try {
146
- const value = JSON.parse(text);
147
- if (!value || typeof value !== "object") {
148
- return { ok: false, error: "response_body_not_object" };
149
- }
150
- return value;
151
- }
152
- catch {
153
- return { ok: false, error: "response_body_not_json" };
154
- }
155
- }
156
262
  function writeFailure(command, io, reason, detail) {
157
263
  if (command.json) {
158
264
  writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
@@ -611,8 +611,19 @@ function parseAgentRulesHost(value) {
611
611
  }
612
612
  function parseJarvisArgs(args) {
613
613
  const values = parseNamedArgs(args, {
614
- allowedFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--model", "--json"],
615
- valueFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--model"],
614
+ allowedFlags: [
615
+ "--home",
616
+ "--dashboard-url",
617
+ "--prompt",
618
+ "--as",
619
+ "--thread",
620
+ "--model",
621
+ "--image",
622
+ "--file",
623
+ "--no-stream",
624
+ "--json",
625
+ ],
626
+ valueFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--model", "--image", "--file"],
616
627
  });
617
628
  const flaggedPrompt = optionalNonEmpty(values.flags.get("--prompt"));
618
629
  const positionalPrompt = optionalNonEmpty(values.positionals.join(" "));
@@ -623,6 +634,13 @@ function parseJarvisArgs(args) {
623
634
  if (!/^[A-Za-z0-9_-]{1,40}$/.test(thread)) {
624
635
  throw new Error("jarvis --thread must use 1 to 40 letters, numbers, underscores, or hyphens.");
625
636
  }
637
+ // BLI-3414: `--file` is a plain alias for `--image` — same flag, whichever
638
+ // word a person reaches for first.
639
+ const image = optionalNonEmpty(values.flags.get("--image"));
640
+ const file = optionalNonEmpty(values.flags.get("--file"));
641
+ if (image && file) {
642
+ throw new Error("jarvis accepts either --image or --file, not both — they are the same flag.");
643
+ }
626
644
  return {
627
645
  kind: "jarvis",
628
646
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -633,6 +651,11 @@ function parseJarvisArgs(args) {
633
651
  // BLI-3381: no client-side allowlist — the dashboard forwards this key
634
652
  // to the inference server's own allowlist and relays its refusal.
635
653
  model: optionalNonEmpty(values.flags.get("--model")),
654
+ imagePath: image ?? file,
655
+ // BLI-3457: streaming is on unless a caller opts out. A dashboard that
656
+ // does not stream yet still answers plain JSON, so this flag is for
657
+ // callers that want the single-body shape on purpose, not a compat knob.
658
+ stream: !values.booleans.has("--no-stream"),
636
659
  json: values.booleans.has("--json"),
637
660
  };
638
661
  }
@@ -46,7 +46,7 @@ export function localCommandHelp(command) {
46
46
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
47
47
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
48
48
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
49
- " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--dashboard-url <url>] [--json]",
49
+ " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--dashboard-url <url>] [--json]",
50
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]",
51
51
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
52
52
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
@@ -190,15 +190,18 @@ function localSubcommandHelp(command) {
190
190
  [
191
191
  "jarvis",
192
192
  [
193
- "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--dashboard-url <url>] [--json]",
193
+ "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--dashboard-url <url>] [--json]",
194
194
  "",
195
195
  "Chats with the same JARVIS used by Tower web chat and the BLI Slack DM.",
196
196
  "--as selects the existing website person space; it changes who the chat is about, never who is authenticated.",
197
197
  "--model <key> requests one provider:model pair (e.g. openai:gpt-5.6-terra); an unrecognised key is refused by the dashboard, not this command.",
198
+ "--image <path> (alias --file) attaches one local PNG, JPEG, or WEBP under 10 MB with the question, the same one-image gate the JARVIS panel uses. A bad path, an unsupported type, or an oversized file is refused with its own plain sentence — never a stack trace.",
198
199
  "Run with no question for an interactive terminal conversation.",
199
200
  "Agents can pass --prompt, positional text, or pipe one question on stdin.",
200
201
  "Each answer prints a per-tool trace line and, only on a fallback or model mismatch, one model receipt line.",
201
- "--json writes one machine-readable response (including trace and model) to stdout; operational metadata stays on stderr.",
202
+ "Trace lines arrive live while JARVIS works; pass --no-stream to wait for the whole answer in one piece instead.",
203
+ "--json writes one machine-readable response (including trace and model) to stdout; operational metadata stays on stderr. A --json run never streams fragments, whichever mode it is in.",
204
+ "A turn that outlives the dashboard's own ceiling is reported as turn_timed_out — an incomplete answer is never printed as a finished one.",
202
205
  "The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
203
206
  "Run `cockpit login` first if this machine is not paired.",
204
207
  ],
@@ -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.36");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.38");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The one shared "call the dashboard as me" client (BLI-3457).
3
+ *
4
+ * Every command that speaks to Tower on the paired person's behalf — `jarvis`
5
+ * today, the rest of the CLI-parity surface next — needs the same three
6
+ * things: the paired device session, a request that carries its device token,
7
+ * and a failure that names itself instead of throwing a transport stack trace
8
+ * at a person. Before this module each command grew its own copy; the copies
9
+ * drifted (one normalised the dashboard URL, one did not) and only one of them
10
+ * ever named a reason.
11
+ *
12
+ * Contract:
13
+ * - Nothing here throws for a transport error. `towerRequest` returns
14
+ * `{ ok: false, reason }` for every network-shaped failure, and the reason
15
+ * label is the thing that travels to the operator.
16
+ * - `loadPairedSession` still throws, because "this machine is not paired" is
17
+ * not a transport failure — it is a precondition the caller cannot proceed
18
+ * past, and the command's top level turns it into one plain sentence.
19
+ * - The device token is attached to the request and never returned, logged, or
20
+ * put in a reason. Log lines here carry metadata only.
21
+ *
22
+ * The HTTP vocabulary (`normalizeDashboardUrl`, `readResponseJson`,
23
+ * `responseErrorMessage`) is reused from `upload-http.ts` rather than copied —
24
+ * the upload path and the conversation path answer to the same rules.
25
+ */
26
+ import { errorMessage } from "./commands/cli-io.js";
27
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "./local-state.js";
28
+ import { normalizeDashboardUrl, readResponseJson, responseErrorMessage, } from "./upload-http.js";
29
+ const defaultLog = (line) => {
30
+ console.error(line);
31
+ };
32
+ /**
33
+ * Loads the paired device session, or explains in one sentence why it cannot.
34
+ *
35
+ * `commandName` names the caller so the sentence reads as the command a person
36
+ * actually typed ("`cockpit jarvis` needs a valid paired Tower session"),
37
+ * rather than the JARVIS-specific wording this used to be hard-coded to.
38
+ */
39
+ export async function loadPairedSession(commandName, homeDir) {
40
+ const paths = getCollectorRuntimePaths(homeDir);
41
+ try {
42
+ const session = await readLocalCollectorSessionFile(paths);
43
+ if (session.session_state !== "valid") {
44
+ throw new Error(`session_${session.session_state}`);
45
+ }
46
+ return session;
47
+ }
48
+ catch (error) {
49
+ throw new Error(`\`cockpit ${commandName}\` needs a valid paired Tower session. ` +
50
+ `Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
51
+ }
52
+ }
53
+ /**
54
+ * Makes one authenticated request to the dashboard. Never throws.
55
+ *
56
+ * A 4xx/5xx still comes back as `{ ok: true, response }` — the status is the
57
+ * caller's to interpret, because several routes put a readable refusal in a
58
+ * non-2xx body. Only a request that produced no response at all is a failure
59
+ * here, and it always carries a reason label.
60
+ */
61
+ export async function towerRequest(options) {
62
+ const log = options.log ?? defaultLog;
63
+ const startedAt = Date.now();
64
+ const url = `${normalizeDashboardUrl(options.dashboardUrl)}${options.path}`;
65
+ const isForm = typeof FormData !== "undefined" && options.body instanceof FormData;
66
+ const headers = {
67
+ ...(options.headers ?? {}),
68
+ authorization: `Bearer ${options.deviceToken}`,
69
+ };
70
+ // fetch derives the multipart boundary from a FormData body; declaring the
71
+ // content-type by hand would strip it and the server would parse nothing.
72
+ if (options.body !== undefined && !isForm)
73
+ headers["content-type"] = "application/json";
74
+ const init = {
75
+ method: options.method ?? "POST",
76
+ headers,
77
+ ...(options.body === undefined
78
+ ? {}
79
+ : { body: (isForm ? options.body : JSON.stringify(options.body)) }),
80
+ ...(timeoutSignal(options.timeoutMs) ?? {}),
81
+ };
82
+ try {
83
+ const response = await options.fetch(url, init);
84
+ log(`[tower client] responded ${JSON.stringify({
85
+ request: options.label,
86
+ http_status: response.status,
87
+ content_type: response.headers.get("content-type") ?? "none",
88
+ streamed: (response.headers.get("content-type") ?? "").includes("application/x-ndjson"),
89
+ elapsed_ms: Date.now() - startedAt,
90
+ })}`);
91
+ return { ok: true, response };
92
+ }
93
+ catch (error) {
94
+ const reason = isAbortError(error)
95
+ ? "turn_timed_out"
96
+ : "gateway_unreachable";
97
+ log(`[tower client] request failed ${JSON.stringify({
98
+ request: options.label,
99
+ reason,
100
+ elapsed_ms: Date.now() - startedAt,
101
+ })}`);
102
+ return { ok: false, reason, detail: towerFailureDetail(reason, errorMessage(error)) };
103
+ }
104
+ }
105
+ /**
106
+ * `towerRequest` plus reading the body as JSON and turning a non-2xx into a
107
+ * named failure carrying the server's own words when it supplied any.
108
+ */
109
+ export async function towerJsonRequest(options) {
110
+ const result = await towerRequest(options);
111
+ if (!result.ok)
112
+ return result;
113
+ const body = await readResponseJson(result.response);
114
+ if (!result.response.ok) {
115
+ return {
116
+ ok: false,
117
+ reason: "http_error",
118
+ httpStatus: result.response.status,
119
+ detail: responseErrorMessage(body, `http_${result.response.status}`),
120
+ };
121
+ }
122
+ return { ok: true, httpStatus: result.response.status, body };
123
+ }
124
+ /** One plain sentence per reason, so no caller has to invent its own wording. */
125
+ export function towerFailureDetail(reason, detail) {
126
+ switch (reason) {
127
+ case "gateway_unreachable":
128
+ return `Tower could not be reached (${detail}).`;
129
+ case "turn_timed_out":
130
+ return "Tower did not finish in time, so the answer is incomplete. Ask again.";
131
+ case "http_error":
132
+ return detail;
133
+ }
134
+ }
135
+ export function isAbortError(error) {
136
+ if (!error || typeof error !== "object")
137
+ return false;
138
+ const name = error.name;
139
+ return name === "AbortError" || name === "TimeoutError";
140
+ }
141
+ /**
142
+ * A deadline when the runtime has one. `AbortSignal.timeout` exists on Node 18+
143
+ * on both supported host families; the guard is here so a stripped runtime
144
+ * loses the ceiling rather than the whole request.
145
+ */
146
+ function timeoutSignal(timeoutMs) {
147
+ if (!timeoutMs || typeof AbortSignal?.timeout !== "function")
148
+ return null;
149
+ return { signal: AbortSignal.timeout(timeoutMs) };
150
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Reading a Tower turn as it happens (BLI-3457).
3
+ *
4
+ * The dashboard's conversation routes speak one JSON object per line
5
+ * (`application/x-ndjson`): `activity` events as tools start and settle, then
6
+ * exactly one `final` event carrying what the plain JSON response used to be.
7
+ * That is the same wire format the browser already reads
8
+ * (`apps/dashboard/src/lib/webchat/tool-trace.ts`); this is the terminal's
9
+ * reader for it.
10
+ *
11
+ * Three things it refuses to do:
12
+ * - Throw. A stream that dies mid-turn comes back as a named reason, never an
13
+ * exception in the middle of a person's answer.
14
+ * - Present a truncated turn as a complete one. No `final` event means the
15
+ * turn failed, and it says which way it failed
16
+ * (`turn_timed_out` when the dashboard's own 120s ceiling is the likely
17
+ * cause, `stream_ended_without_final` otherwise).
18
+ * - Require the stream. A dashboard that has not shipped streaming yet answers
19
+ * `application/json`; that single object is read as the final event and the
20
+ * fallback is named `stream_not_available` rather than passed off as a
21
+ * stream that happened to be quiet.
22
+ *
23
+ * A malformed line is skipped, named `ndjson_line_unparseable`, and the lines
24
+ * after it still count — one bad byte cannot swallow an answer.
25
+ */
26
+ import { errorMessage } from "./commands/cli-io.js";
27
+ import { isAbortError } from "./tower-client.js";
28
+ /**
29
+ * The dashboard caps a turn at 120s (`maxDuration = 120`). A stream that stops
30
+ * without a final event anywhere near that mark was almost certainly cut off by
31
+ * the platform, and saying `turn_timed_out` is the honest label; earlier than
32
+ * that, the stream broke for some other reason and gets its own.
33
+ */
34
+ const CEILING_HINT_MS = 110_000;
35
+ /**
36
+ * Yields every event on the response, then stops at the first `final`.
37
+ *
38
+ * Handles both wire shapes: real NDJSON, and a plain `application/json` body
39
+ * from a dashboard that does not stream yet (yielded as the final event, after
40
+ * a `stream_not_available` note).
41
+ */
42
+ export async function* readTowerStream(response) {
43
+ const contentType = response.headers.get("content-type") ?? "";
44
+ if (!contentType.includes("application/x-ndjson")) {
45
+ yield* readPlainJsonBody(response);
46
+ return;
47
+ }
48
+ let buffer = "";
49
+ try {
50
+ for await (const chunk of iterateBody(response)) {
51
+ buffer += chunk;
52
+ const lines = buffer.split("\n");
53
+ buffer = lines.pop() ?? "";
54
+ for (const line of lines) {
55
+ const item = parseLine(line);
56
+ if (!item)
57
+ continue;
58
+ yield item;
59
+ if (item.kind === "event" && item.event.type === "final")
60
+ return;
61
+ }
62
+ }
63
+ }
64
+ catch (error) {
65
+ yield {
66
+ kind: "note",
67
+ reason: isAbortError(error) ? "turn_timed_out" : "stream_read_failed",
68
+ detail: errorMessage(error),
69
+ };
70
+ return;
71
+ }
72
+ // Whatever was still in flight when the body ended.
73
+ const tail = parseLine(buffer);
74
+ if (tail)
75
+ yield tail;
76
+ }
77
+ /**
78
+ * The whole turn in one call: renders activity as it arrives, buffers the
79
+ * final, and names any way the turn failed to produce one.
80
+ *
81
+ * `onActivity` is where a caller draws its live trace. `onNote` sees every
82
+ * named oddity (a skipped line, a non-streaming dashboard) so nothing is
83
+ * silently absorbed — the caller decides whether that is a log line or a
84
+ * sentence.
85
+ */
86
+ export async function readTowerTurn(response, options = {}) {
87
+ const startedAt = options.startedAt ?? Date.now();
88
+ const activity = [];
89
+ const notes = [];
90
+ let streamed = true;
91
+ let final = null;
92
+ for await (const item of readTowerStream(response)) {
93
+ if (item.kind === "note") {
94
+ if (item.reason === "stream_not_available")
95
+ streamed = false;
96
+ notes.push({ reason: item.reason, detail: item.detail });
97
+ options.onNote?.(item.reason, item.detail);
98
+ continue;
99
+ }
100
+ if (item.event.type === "final") {
101
+ final = item.event;
102
+ break;
103
+ }
104
+ activity.push(item.event);
105
+ options.onActivity?.(item.event);
106
+ }
107
+ const elapsedMs = Date.now() - startedAt;
108
+ if (!final) {
109
+ // The last named note is the truest reason we have; absent one, a stream
110
+ // that simply stopped near the dashboard's ceiling is a timeout.
111
+ const terminal = notes.at(-1);
112
+ const reason = terminal && terminal.reason !== "stream_not_available"
113
+ ? terminal.reason
114
+ : elapsedMs >= CEILING_HINT_MS
115
+ ? "turn_timed_out"
116
+ : "stream_ended_without_final";
117
+ options.log?.(`[tower stream] turn incomplete ${JSON.stringify({
118
+ reason,
119
+ activity_events: activity.length,
120
+ notes: notes.length,
121
+ elapsed_ms: elapsedMs,
122
+ })}`);
123
+ return { ok: false, reason, detail: streamFailureDetail(reason, terminal?.detail) };
124
+ }
125
+ options.log?.(`[tower stream] turn complete ${JSON.stringify({
126
+ streamed,
127
+ activity_events: activity.length,
128
+ notes: notes.length,
129
+ elapsed_ms: elapsedMs,
130
+ })}`);
131
+ return { ok: true, final, activity, streamed };
132
+ }
133
+ /** One plain sentence per way a turn can end without an answer. */
134
+ export function streamFailureDetail(reason, detail) {
135
+ switch (reason) {
136
+ case "turn_timed_out":
137
+ return "Tower ran out of time before finishing that turn, so the answer is incomplete. Ask again.";
138
+ case "stream_ended_without_final":
139
+ return "Tower stopped sending mid-answer, so the answer is incomplete. Ask again.";
140
+ case "stream_read_failed":
141
+ return `The connection to Tower broke mid-answer${detail ? ` (${detail})` : ""}. Ask again.`;
142
+ case "response_body_not_json":
143
+ return "Tower answered with something that was not JSON.";
144
+ case "response_body_empty":
145
+ return "Tower answered with an empty body.";
146
+ default:
147
+ return detail ?? reason;
148
+ }
149
+ }
150
+ /**
151
+ * A dashboard that does not stream yet. The whole body is one JSON object; it
152
+ * becomes the final event so every caller has exactly one code path.
153
+ */
154
+ async function* readPlainJsonBody(response) {
155
+ yield {
156
+ kind: "note",
157
+ reason: "stream_not_available",
158
+ detail: response.headers.get("content-type") ?? "none",
159
+ };
160
+ let text;
161
+ try {
162
+ text = await response.text();
163
+ }
164
+ catch (error) {
165
+ yield {
166
+ kind: "note",
167
+ reason: isAbortError(error) ? "turn_timed_out" : "stream_read_failed",
168
+ detail: errorMessage(error),
169
+ };
170
+ return;
171
+ }
172
+ if (!text.trim()) {
173
+ yield { kind: "note", reason: "response_body_empty" };
174
+ return;
175
+ }
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(text);
179
+ }
180
+ catch {
181
+ yield { kind: "note", reason: "response_body_not_json" };
182
+ return;
183
+ }
184
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
185
+ yield { kind: "note", reason: "response_body_not_json" };
186
+ return;
187
+ }
188
+ yield {
189
+ kind: "event",
190
+ event: { ...parsed, type: "final" },
191
+ };
192
+ }
193
+ /** One NDJSON line: an event, a named skip, or nothing at all for blank lines. */
194
+ function parseLine(line) {
195
+ const trimmed = line.trim();
196
+ if (!trimmed)
197
+ return null;
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(trimmed);
201
+ }
202
+ catch {
203
+ // The line text is never logged: it is conversation content.
204
+ return { kind: "note", reason: "ndjson_line_unparseable" };
205
+ }
206
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
207
+ return { kind: "note", reason: "unexpected_event_type" };
208
+ }
209
+ const type = parsed.type;
210
+ if (type !== "activity" && type !== "final") {
211
+ return { kind: "note", reason: "unexpected_event_type" };
212
+ }
213
+ return { kind: "event", event: parsed };
214
+ }
215
+ /**
216
+ * Decoded text chunks from a response body.
217
+ *
218
+ * Node's web `ReadableStream` is async-iterable on both supported host
219
+ * families (macOS and Windows, Node 20+), and that is the fast path. The
220
+ * `getReader()` fallback is there because a test double or a polyfilled fetch
221
+ * may hand back a stream without the iterator, and losing the answer over an
222
+ * iteration protocol would be absurd.
223
+ */
224
+ async function* iterateBody(response) {
225
+ const body = response.body;
226
+ if (!body) {
227
+ const text = await response.text();
228
+ if (text)
229
+ yield text;
230
+ return;
231
+ }
232
+ const decoder = new TextDecoder();
233
+ const asyncIterable = body;
234
+ if (typeof asyncIterable[Symbol.asyncIterator] === "function") {
235
+ for await (const chunk of asyncIterable) {
236
+ yield decoder.decode(chunk, { stream: true });
237
+ }
238
+ }
239
+ else {
240
+ const reader = body.getReader();
241
+ while (true) {
242
+ const { done, value } = await reader.read();
243
+ if (done)
244
+ break;
245
+ if (value)
246
+ yield decoder.decode(value, { stream: true });
247
+ }
248
+ }
249
+ const rest = decoder.decode();
250
+ if (rest)
251
+ yield rest;
252
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "pretypecheck": "npm run build",
25
25
  "typecheck": "node -e \"await import('./dist/commands/public-root.js')\"",
26
26
  "pretest": "npm run build",
27
- "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
27
+ "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/telemetry-core": "0.1.25"