@bli-cockpit/cli 0.2.37 → 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.
@@ -5,13 +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";
8
+ import { isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
9
9
  import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
10
- import { getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
10
+ import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
11
+ import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
11
12
  const TRACE_DIM = "\x1b[2m";
12
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;
13
21
  export async function runJarvis(command, io) {
14
- const session = await loadPairedSession(command.homeDir);
22
+ const session = await loadPairedSession("jarvis", command.homeDir);
15
23
  const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
16
24
  const oneShotPrompt = await resolveOneShotPrompt(command, io);
17
25
  if (oneShotPrompt !== null) {
@@ -32,19 +40,6 @@ export async function runJarvis(command, io) {
32
40
  return exitCode;
33
41
  }
34
42
  }
35
- async function loadPairedSession(homeDir) {
36
- const paths = getCollectorRuntimePaths(homeDir);
37
- try {
38
- const session = await readLocalCollectorSessionFile(paths);
39
- if (session.session_state !== "valid") {
40
- throw new Error(`session_${session.session_state}`);
41
- }
42
- return session;
43
- }
44
- catch (error) {
45
- throw new Error(`JARVIS needs a valid paired Tower session. Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
46
- }
47
- }
48
43
  async function resolveOneShotPrompt(command, io) {
49
44
  if (command.prompt)
50
45
  return validatePrompt(command.prompt);
@@ -68,65 +63,100 @@ async function sendOneTurn(context, prompt, io) {
68
63
  }
69
64
  attachment = read;
70
65
  }
71
- let response;
72
- try {
73
- response = attachment
74
- ? await io.fetch(`${context.dashboardUrl}/api/jarvis/cli`, {
75
- method: "POST",
76
- headers: { authorization: `Bearer ${context.deviceToken}` },
77
- body: buildAttachmentForm(context.command, prompt, attachment),
78
- })
79
- : await io.fetch(`${context.dashboardUrl}/api/jarvis/cli`, {
80
- method: "POST",
81
- headers: {
82
- authorization: `Bearer ${context.deviceToken}`,
83
- "content-type": "application/json",
84
- },
85
- body: JSON.stringify({
86
- question: prompt,
87
- thread: context.command.thread,
88
- subject: context.command.subject,
89
- model: context.command.model,
90
- }),
91
- });
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
+ : {
83
+ question: prompt,
84
+ thread: context.command.thread,
85
+ subject: context.command.subject,
86
+ model: context.command.model,
87
+ },
88
+ log,
89
+ });
90
+ if (!requested.ok) {
91
+ writeFailure(context.command, io, requested.reason, towerFailureDetail(requested.reason, requested.detail));
92
+ return 1;
92
93
  }
93
- catch (error) {
94
- 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));
95
116
  return 1;
96
117
  }
97
- const body = await readReply(response);
98
- if (!response.ok || !body.ok || !body.reply) {
99
- 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}`;
100
122
  writeFailure(context.command, io, "turn_failed", reason);
101
123
  return 1;
102
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);
103
129
  if (context.command.json) {
104
130
  writeLine(io.stdout, JSON.stringify({
105
131
  ok: true,
106
132
  reply: body.reply,
107
133
  thread: body.thread ?? context.command.thread,
108
134
  model: body.model ?? null,
109
- trace: body.trace ?? [],
135
+ trace,
110
136
  subject: body.subject ?? null,
111
137
  }));
112
138
  }
113
139
  else {
114
140
  const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
115
141
  writeLine(io.stdout, `jarvis${subject}> ${body.reply}`);
116
- 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);
117
145
  writeModelReceipt(io, body.model);
118
146
  }
119
147
  writeLine(io.stderr, `[jarvis cli] answered ${JSON.stringify({
120
148
  prompt_length: prompt.length,
121
149
  reply_length: body.reply.length,
122
- trace_steps: body.trace?.length ?? 0,
123
- 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,
124
152
  model_requested: context.command.model ?? null,
125
153
  elapsed_ms: Date.now() - startedAt,
126
154
  thread: context.command.thread === "main" ? "default" : "named",
127
155
  subject: context.command.subject ? "selected" : "caller",
128
156
  image_attached: attachment !== null,
129
157
  image_byte_size: attachment?.bytes.byteLength ?? null,
158
+ streamed: turn.streamed,
159
+ live_trace_lines: liveTraceLines,
130
160
  })}`);
131
161
  return 0;
132
162
  }
@@ -166,11 +196,53 @@ function writeAttachmentRefusal(command, io, refusal, filePath) {
166
196
  function writeTraceBlock(io, trace) {
167
197
  if (!trace || trace.length === 0)
168
198
  return;
169
- for (const step of trace) {
170
- const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
171
- const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
172
- 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);
173
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
+ };
174
246
  }
175
247
  /**
176
248
  * One line, only when the answer did not come from what was requested — a
@@ -187,21 +259,6 @@ function writeModelReceipt(io, model) {
187
259
  return;
188
260
  writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered (${requested ?? "the requested model"} unavailable)`);
189
261
  }
190
- async function readReply(response) {
191
- const text = await response.text();
192
- if (!text)
193
- return { ok: false, error: "empty_response" };
194
- try {
195
- const value = JSON.parse(text);
196
- if (!value || typeof value !== "object") {
197
- return { ok: false, error: "response_body_not_object" };
198
- }
199
- return value;
200
- }
201
- catch {
202
- return { ok: false, error: "response_body_not_json" };
203
- }
204
- }
205
262
  function writeFailure(command, io, reason, detail) {
206
263
  if (command.json) {
207
264
  writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
@@ -620,6 +620,7 @@ function parseJarvisArgs(args) {
620
620
  "--model",
621
621
  "--image",
622
622
  "--file",
623
+ "--no-stream",
623
624
  "--json",
624
625
  ],
625
626
  valueFlags: ["--home", "--dashboard-url", "--prompt", "--as", "--thread", "--model", "--image", "--file"],
@@ -651,6 +652,10 @@ function parseJarvisArgs(args) {
651
652
  // to the inference server's own allowlist and relays its refusal.
652
653
  model: optionalNonEmpty(values.flags.get("--model")),
653
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"),
654
659
  json: values.booleans.has("--json"),
655
660
  };
656
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>] [--image <path>|--file <path>] [--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,7 +190,7 @@ function localSubcommandHelp(command) {
190
190
  [
191
191
  "jarvis",
192
192
  [
193
- "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--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.",
@@ -199,7 +199,9 @@ function localSubcommandHelp(command) {
199
199
  "Run with no question for an interactive terminal conversation.",
200
200
  "Agents can pass --prompt, positional text, or pipe one question on stdin.",
201
201
  "Each answer prints a per-tool trace line and, only on a fallback or model mismatch, one model receipt line.",
202
- "--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.",
203
205
  "The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
204
206
  "Run `cockpit login` first if this machine is not paired.",
205
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.37");
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.37",
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"