@bli-cockpit/cli 0.2.37 → 0.2.39

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,133 @@
1
+ /**
2
+ * `cockpit brief` — the TODAY page, read from a terminal (BLI-3458).
3
+ *
4
+ * This file owns terminal output only. The page itself, whose it is, whether
5
+ * this person may open it and which version they asked for are all decided by
6
+ * `GET /api/jarvis/brief`, which calls the SAME `loadBriefView` the website's
7
+ * home page calls. There is deliberately no local rendering of a `JarvisPage`
8
+ * here: the dashboard hands back finished text (the serializer's
9
+ * `TERMINAL_FLAVOR`), so the terminal and the browser cannot drift into showing
10
+ * two different briefs.
11
+ *
12
+ * Identity is the existing paired device session. `--for` changes WHOSE page is
13
+ * asked for, never who is asking — the same rule `cockpit jarvis --as` follows.
14
+ */
15
+ import { writeLine } from "./cli-io.js";
16
+ import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
17
+ const DIM = "\x1b[2m";
18
+ const RESET = "\x1b[0m";
19
+ const REQUEST_DEADLINE_MS = 30_000;
20
+ export async function runBrief(command, io) {
21
+ const session = await loadPairedSession("brief", command.homeDir);
22
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
23
+ const startedAt = Date.now();
24
+ const result = await towerJsonRequest({
25
+ dashboardUrl,
26
+ path: `/api/jarvis/brief${queryFor(command)}`,
27
+ deviceToken: session.device_token,
28
+ fetch: io.fetch,
29
+ method: "GET",
30
+ label: "brief",
31
+ timeoutMs: REQUEST_DEADLINE_MS,
32
+ log: (line) => writeLine(io.stderr, line),
33
+ });
34
+ if (!result.ok) {
35
+ // `towerJsonRequest` already turned a non-2xx into the server's own
36
+ // sentence, which for this route is the plain-words notice a person reads
37
+ // on the page ("Nothing has been written for you yet."). Relayed verbatim
38
+ // rather than translated into a code.
39
+ writeFailure(command, io, result.reason, result.detail);
40
+ return 1;
41
+ }
42
+ const body = result.body;
43
+ if (!body.ok || typeof body.text !== "string") {
44
+ const detail = body.reply ?? body.error ?? "Tower answered without a page.";
45
+ writeFailure(command, io, body.error ?? "no_page", detail);
46
+ return 1;
47
+ }
48
+ if (command.json) {
49
+ writeLine(io.stdout, JSON.stringify(body));
50
+ }
51
+ else {
52
+ writeHuman(io, command, body);
53
+ }
54
+ writeLine(io.stderr, `[brief cli] read ${JSON.stringify({
55
+ text_length: body.text.length,
56
+ cadence: body.page?.cadence ?? null,
57
+ visible_because: body.visibleBecause ?? null,
58
+ tldr: Boolean(command.tldr),
59
+ pinned_version: command.version != null,
60
+ subject: command.subject ? "selected" : "caller",
61
+ version_count: body.versions?.length ?? null,
62
+ versions_reason: body.versionsReason ?? null,
63
+ claim_count: body.claims?.length ?? null,
64
+ elapsed_ms: Date.now() - startedAt,
65
+ })}`);
66
+ return 0;
67
+ }
68
+ function queryFor(command) {
69
+ const params = new URLSearchParams();
70
+ if (command.subject)
71
+ params.set("p", command.subject);
72
+ if (command.version)
73
+ params.set("v", command.version);
74
+ if (command.tldr)
75
+ params.set("tldr", "1");
76
+ if (command.versions)
77
+ params.set("versions", "1");
78
+ if (command.claims)
79
+ params.set("claims", "1");
80
+ const query = params.toString();
81
+ return query ? `?${query}` : "";
82
+ }
83
+ function writeHuman(io, command, body) {
84
+ const page = body.page ?? {};
85
+ // Whose page, and — when it is not the newest — that it is a record of a
86
+ // moment rather than something that failed to update.
87
+ const whose = page.displayName ? `${page.displayName}'s page` : "This page";
88
+ const pinned = page.olderVersionLabel ? ` · version from ${page.olderVersionLabel}` : "";
89
+ writeLine(io.stdout, `${DIM}${whose}${pinned}${RESET}`);
90
+ writeLine(io.stdout, "");
91
+ writeLine(io.stdout, body.text ?? "");
92
+ if (body.claims && body.claims.length > 0) {
93
+ writeLine(io.stdout, "");
94
+ writeLine(io.stdout, `${DIM}Claim ids — pass one to \`cockpit correct --claim\`:${RESET}`);
95
+ for (const claim of body.claims) {
96
+ writeLine(io.stdout, `${DIM} [${claim.claimId}]${RESET} ${claim.text ?? ""}`);
97
+ }
98
+ }
99
+ if (body.versions) {
100
+ writeLine(io.stdout, "");
101
+ if (body.versions.length === 0) {
102
+ // Never "there is one version". An empty list with a reason is an
103
+ // answer; an empty list without one is a wrong answer.
104
+ writeLine(io.stdout, `${DIM}Earlier versions could not be listed (${body.versionsReason ?? "no reason given"}).${RESET}`);
105
+ }
106
+ else {
107
+ writeLine(io.stdout, `${DIM}Versions — pass one to \`cockpit brief --version\`:${RESET}`);
108
+ for (const version of body.versions) {
109
+ const headline = version.headline ? ` — ${version.headline}` : "";
110
+ writeLine(io.stdout, `${DIM} ${version.version}/${version.of} ${version.pageId} ${version.asOf}${headline}${RESET}`);
111
+ }
112
+ }
113
+ }
114
+ if (!command.subject && body.people && body.people.length > 1) {
115
+ const others = body.people
116
+ .filter((person) => person.personId !== page.personId)
117
+ .map((person) => person.slug)
118
+ .filter(Boolean);
119
+ if (others.length > 0) {
120
+ writeLine(io.stdout, "");
121
+ writeLine(io.stdout, `${DIM}You can also open: ${others.join(", ")} (\`--for <name>\`)${RESET}`);
122
+ }
123
+ }
124
+ }
125
+ function writeFailure(command, io, reason, detail) {
126
+ if (command.json) {
127
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
128
+ }
129
+ else {
130
+ writeLine(io.stderr, detail);
131
+ }
132
+ writeLine(io.stderr, `[brief cli] not read ${JSON.stringify({ reason })}`);
133
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `cockpit correct` — say that one line on the page is wrong (BLI-3458).
3
+ *
4
+ * Two requests, in this order, and the order matters:
5
+ *
6
+ * 1. `GET /api/jarvis/brief?claims=1` — resolve WHICH page and WHOSE, and
7
+ * look up the line the person named. That read is also what supplies the
8
+ * two facts the correction door needs and a terminal cannot know:
9
+ * whether the clause rests on a machine record (`observed`, which decides
10
+ * tier 2 vs tier 3) and the receipt links the live ground-truth check
11
+ * mines for an identifier.
12
+ * 2. `POST /api/jarvis/corrections` — the same door the panel's form uses,
13
+ * which checks the claim against the live record BEFORE writing, and can
14
+ * answer back that the record disagrees.
15
+ *
16
+ * A claim id the page does not carry is refused HERE, before any write, with
17
+ * the fix named — `cockpit brief --claims` prints every id. Filing it anyway
18
+ * would land a correction on a clause nobody can find, which is how a ledger
19
+ * fills with rows that mean nothing.
20
+ *
21
+ * The push-back is the point of this command, not an error path: when JARVIS
22
+ * says the record disagrees, that sentence is what the person came for, and it
23
+ * is printed on stdout with an exit code of 0 — the correction WAS filed, with
24
+ * its outcome recorded. Only a failure to file at all is a non-zero exit.
25
+ */
26
+ import { isInteractiveStdin, writeLine } from "./cli-io.js";
27
+ import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
28
+ const DIM = "\x1b[2m";
29
+ const RESET = "\x1b[0m";
30
+ const REQUEST_DEADLINE_MS = 60_000;
31
+ const MAX_CORRECTION_LENGTH = 4000;
32
+ export async function runCorrect(command, io) {
33
+ const session = await loadPairedSession("correct", command.homeDir);
34
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
35
+ const log = (line) => writeLine(io.stderr, line);
36
+ const startedAt = Date.now();
37
+ const text = await resolveText(command, io);
38
+ if (!text) {
39
+ writeFailure(command, io, "no_correction_text", 'Say what is wrong: `cockpit correct --claim <id> --text "..."`, or pipe it in on stdin.');
40
+ return 1;
41
+ }
42
+ // Step one: which page, whose, and does that line exist.
43
+ const read = await towerJsonRequest({
44
+ dashboardUrl,
45
+ path: `/api/jarvis/brief${briefQuery(command)}`,
46
+ deviceToken: session.device_token,
47
+ fetch: io.fetch,
48
+ method: "GET",
49
+ label: "correct:brief",
50
+ timeoutMs: REQUEST_DEADLINE_MS,
51
+ log,
52
+ });
53
+ if (!read.ok) {
54
+ writeFailure(command, io, read.reason, read.detail);
55
+ return 1;
56
+ }
57
+ const brief = read.body;
58
+ const personId = brief.page?.personId;
59
+ if (!brief.ok || !personId) {
60
+ writeFailure(command, io, brief.error ?? "no_page", brief.reply ?? "There is no page to correct.");
61
+ return 1;
62
+ }
63
+ const claim = (brief.claims ?? []).find((candidate) => candidate.claimId === command.claimId);
64
+ if (!claim) {
65
+ writeFailure(command, io, "unknown_claim", `That page has no line called “${command.claimId}”. Run \`cockpit brief --claims\` to see the ids.`);
66
+ return 1;
67
+ }
68
+ // Step two: the same door the panel's form posts to.
69
+ const written = await towerJsonRequest({
70
+ dashboardUrl,
71
+ path: "/api/jarvis/corrections",
72
+ deviceToken: session.device_token,
73
+ fetch: io.fetch,
74
+ label: "correct",
75
+ timeoutMs: REQUEST_DEADLINE_MS,
76
+ log,
77
+ body: {
78
+ personId,
79
+ pageId: brief.page?.pageId ?? null,
80
+ claimId: command.claimId,
81
+ // The line as it reads now, so the ledger records what was disputed and
82
+ // the live check has the sentence to work from.
83
+ quotedText: claim.text ?? null,
84
+ correctionText: text,
85
+ contextLinks: claim.links ?? [],
86
+ clauseOnPage: true,
87
+ clauseIsObserved: Boolean(claim.observed),
88
+ ...(command.supersedes ? { supersedes: command.supersedes } : {}),
89
+ },
90
+ });
91
+ if (!written.ok) {
92
+ writeFailure(command, io, written.reason, written.detail);
93
+ return 1;
94
+ }
95
+ const filed = written.body;
96
+ if (!filed.correctionId) {
97
+ writeFailure(command, io, "not_filed", filed.error ?? "Tower did not record that correction.");
98
+ return 1;
99
+ }
100
+ if (command.json) {
101
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...filed }));
102
+ }
103
+ else {
104
+ writeLine(io.stdout, filed.reply ?? "Filed.");
105
+ if (filed.finding)
106
+ writeLine(io.stdout, `${DIM}The record says: ${filed.finding}${RESET}`);
107
+ if (filed.link)
108
+ writeLine(io.stdout, `${DIM}${filed.link}${RESET}`);
109
+ }
110
+ writeLine(io.stderr, `[correct cli] filed ${JSON.stringify({
111
+ tier: filed.tier ?? null,
112
+ outcome: filed.outcome ?? null,
113
+ pushed_back: Boolean(filed.pushBack),
114
+ observed_clause: Boolean(claim.observed),
115
+ context_links: (claim.links ?? []).length,
116
+ supersedes: command.supersedes != null,
117
+ subject: command.subject ? "selected" : "caller",
118
+ text_length: text.length,
119
+ elapsed_ms: Date.now() - startedAt,
120
+ })}`);
121
+ return 0;
122
+ }
123
+ function briefQuery(command) {
124
+ const params = new URLSearchParams({ claims: "1" });
125
+ if (command.subject)
126
+ params.set("p", command.subject);
127
+ if (command.version)
128
+ params.set("v", command.version);
129
+ return `?${params.toString()}`;
130
+ }
131
+ /** `--text`, or whatever was piped in. Never a prompt — this may run headless. */
132
+ async function resolveText(command, io) {
133
+ if (command.text)
134
+ return command.text;
135
+ if (isInteractiveStdin(io))
136
+ return null;
137
+ const piped = await readAll(io.stdin);
138
+ const trimmed = piped.trim();
139
+ return trimmed.length > 0 ? trimmed : null;
140
+ }
141
+ async function readAll(stream) {
142
+ stream.setEncoding("utf8");
143
+ let text = "";
144
+ for await (const chunk of stream) {
145
+ text += chunk;
146
+ if (text.length > MAX_CORRECTION_LENGTH) {
147
+ throw new Error(`A correction is limited to ${MAX_CORRECTION_LENGTH} characters.`);
148
+ }
149
+ }
150
+ return text;
151
+ }
152
+ function writeFailure(command, io, reason, detail) {
153
+ if (command.json) {
154
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
155
+ }
156
+ else {
157
+ writeLine(io.stderr, detail);
158
+ }
159
+ writeLine(io.stderr, `[correct cli] not filed ${JSON.stringify({ reason })}`);
160
+ }
@@ -5,14 +5,28 @@
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, towerJsonRequest, 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;
24
+ // BLI-3458: reading back what was already said. No turn, no model, no
25
+ // conversation-ledger write — and, on the server, only ever THIS device
26
+ // holder's own terminal rows.
27
+ if (command.threads || command.history) {
28
+ return readHistory({ command, dashboardUrl, deviceToken: session.device_token }, io);
29
+ }
16
30
  const oneShotPrompt = await resolveOneShotPrompt(command, io);
17
31
  if (oneShotPrompt !== null) {
18
32
  return sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, oneShotPrompt, io);
@@ -32,17 +46,89 @@ export async function runJarvis(command, io) {
32
46
  return exitCode;
33
47
  }
34
48
  }
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;
49
+ /**
50
+ * `--threads` and `--thread <name> --history`.
51
+ *
52
+ * The whole conversation lives on the server; nothing is cached locally, so a
53
+ * person who moves between machines sees the same history on both. The server
54
+ * scopes it to the device holder's own account — this command cannot ask for
55
+ * anybody else's, and there is no flag that would let it.
56
+ */
57
+ async function readHistory(context, io) {
58
+ const { command } = context;
59
+ const params = new URLSearchParams();
60
+ if (command.history) {
61
+ params.set("thread", command.thread);
62
+ if (command.limit)
63
+ params.set("limit", String(command.limit));
64
+ }
65
+ else {
66
+ params.set("threads", "1");
67
+ }
68
+ const result = await towerJsonRequest({
69
+ dashboardUrl: context.dashboardUrl,
70
+ path: `/api/jarvis/cli?${params.toString()}`,
71
+ deviceToken: context.deviceToken,
72
+ fetch: io.fetch,
73
+ method: "GET",
74
+ label: command.history ? "jarvis:thread" : "jarvis:threads",
75
+ timeoutMs: 30_000,
76
+ log: (line) => writeLine(io.stderr, line),
77
+ });
78
+ if (!result.ok) {
79
+ writeFailure(command, io, result.reason, result.detail);
80
+ return 1;
81
+ }
82
+ const body = result.body;
83
+ if (body.ok === false) {
84
+ writeFailure(command, io, "history_unavailable", body.reply ?? "Tower had nothing to show.");
85
+ return 1;
86
+ }
87
+ if (command.json) {
88
+ writeLine(io.stdout, JSON.stringify(body));
89
+ }
90
+ else if (command.history) {
91
+ writeThreadHistory(io, body);
92
+ }
93
+ else {
94
+ writeThreadList(io, body.threads ?? []);
95
+ }
96
+ writeLine(io.stderr, `[jarvis cli] history read ${JSON.stringify({
97
+ mode: command.history ? "thread" : "threads",
98
+ thread_count: body.threads?.length ?? null,
99
+ message_count: body.messages?.length ?? null,
100
+ truncated: body.truncated ?? null,
101
+ })}`);
102
+ return 0;
103
+ }
104
+ function writeThreadList(io, threads) {
105
+ if (threads.length === 0) {
106
+ // A real answer, not a blank. Nothing here means nothing was ever asked
107
+ // from this terminal, which is worth saying rather than implying.
108
+ writeLine(io.stdout, "No terminal conversations yet. Ask something with `cockpit jarvis`.");
109
+ return;
43
110
  }
44
- catch (error) {
45
- throw new Error(`JARVIS needs a valid paired Tower session. Run \`cockpit login\`, then try again (${errorMessage(error)}).`);
111
+ for (const thread of threads) {
112
+ const turns = thread.turnCount === 1 ? "1 turn" : `${thread.turnCount ?? 0} turns`;
113
+ writeLine(io.stdout, `${thread.name} ${TRACE_DIM}${turns} · ${thread.lastAt ?? "unknown"}${TRACE_RESET}`);
114
+ if (thread.preview)
115
+ writeLine(io.stdout, `${TRACE_DIM} ${thread.preview}${TRACE_RESET}`);
116
+ }
117
+ writeLine(io.stdout, "");
118
+ writeLine(io.stdout, `${TRACE_DIM}Replay one with \`cockpit jarvis --thread <name> --history\`.${TRACE_RESET}`);
119
+ }
120
+ function writeThreadHistory(io, body) {
121
+ const messages = body.messages ?? [];
122
+ if (messages.length === 0) {
123
+ writeLine(io.stdout, `Nothing has been said in “${body.thread ?? "that thread"}” yet.`);
124
+ return;
125
+ }
126
+ if (body.truncated) {
127
+ writeLine(io.stdout, `${TRACE_DIM}Showing the most recent ${messages.length}; there is more before this (\`--limit\`).${TRACE_RESET}`);
128
+ }
129
+ for (const message of messages) {
130
+ const speaker = message.role === "you" ? "you" : "jarvis";
131
+ writeLine(io.stdout, `${speaker}> ${message.text ?? ""}`);
46
132
  }
47
133
  }
48
134
  async function resolveOneShotPrompt(command, io) {
@@ -68,65 +154,100 @@ async function sendOneTurn(context, prompt, io) {
68
154
  }
69
155
  attachment = read;
70
156
  }
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
- });
92
- }
93
- catch (error) {
94
- writeFailure(context.command, io, "gateway_unreachable", errorMessage(error));
157
+ // BLI-3457: streaming is the default. A dashboard that has not shipped the
158
+ // NDJSON half yet answers `application/json`, which the reader takes as a
159
+ // single final event and names `stream_not_available` — so the terminal
160
+ // works against both server versions with no flag.
161
+ const wantsStream = context.command.stream !== false;
162
+ const log = (line) => writeLine(io.stderr, line);
163
+ const requested = await towerRequest({
164
+ dashboardUrl: context.dashboardUrl,
165
+ path: "/api/jarvis/cli",
166
+ deviceToken: context.deviceToken,
167
+ fetch: io.fetch,
168
+ label: "jarvis",
169
+ timeoutMs: TURN_DEADLINE_MS,
170
+ headers: wantsStream ? { accept: "application/x-ndjson" } : {},
171
+ body: attachment
172
+ ? buildAttachmentForm(context.command, prompt, attachment)
173
+ : {
174
+ question: prompt,
175
+ thread: context.command.thread,
176
+ subject: context.command.subject,
177
+ model: context.command.model,
178
+ },
179
+ log,
180
+ });
181
+ if (!requested.ok) {
182
+ writeFailure(context.command, io, requested.reason, towerFailureDetail(requested.reason, requested.detail));
183
+ return 1;
184
+ }
185
+ // Live trace lines go to a person as they land, never to a `--json`
186
+ // consumer: that contract is exactly one object on stdout, so the events are
187
+ // buffered and folded into the final payload instead.
188
+ let liveTraceLines = 0;
189
+ const turn = await readTowerTurn(requested.response, {
190
+ startedAt,
191
+ log,
192
+ onActivity: (event) => {
193
+ if (context.command.json)
194
+ return;
195
+ if (writeActivityLine(io, event))
196
+ liveTraceLines += 1;
197
+ },
198
+ onNote: (reason, detail) => {
199
+ log(`[jarvis cli] stream note ${JSON.stringify({
200
+ reason,
201
+ ...(detail && reason !== "ndjson_line_unparseable" ? { detail } : {}),
202
+ })}`);
203
+ },
204
+ });
205
+ if (!turn.ok) {
206
+ writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
95
207
  return 1;
96
208
  }
97
- const body = await readReply(response);
98
- if (!response.ok || !body.ok || !body.reply) {
99
- const reason = body.error ?? body.reply ?? `http_${response.status}`;
209
+ const body = turn.final;
210
+ const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
211
+ if (httpStatus >= 400 || !body.ok || !body.reply) {
212
+ const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
100
213
  writeFailure(context.command, io, "turn_failed", reason);
101
214
  return 1;
102
215
  }
216
+ // A streaming server may leave the settled trace out of the final event
217
+ // because it already sent every step live; the activity we collected is that
218
+ // same trace, so `--json` still gets one.
219
+ const trace = body.trace ?? activityToTrace(turn.activity);
103
220
  if (context.command.json) {
104
221
  writeLine(io.stdout, JSON.stringify({
105
222
  ok: true,
106
223
  reply: body.reply,
107
224
  thread: body.thread ?? context.command.thread,
108
225
  model: body.model ?? null,
109
- trace: body.trace ?? [],
226
+ trace,
110
227
  subject: body.subject ?? null,
111
228
  }));
112
229
  }
113
230
  else {
114
231
  const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
115
232
  writeLine(io.stdout, `jarvis${subject}> ${body.reply}`);
116
- writeTraceBlock(io, body.trace);
233
+ // Only when nothing was drawn live — otherwise every tool would print twice.
234
+ if (liveTraceLines === 0)
235
+ writeTraceBlock(io, trace);
117
236
  writeModelReceipt(io, body.model);
118
237
  }
119
238
  writeLine(io.stderr, `[jarvis cli] answered ${JSON.stringify({
120
239
  prompt_length: prompt.length,
121
240
  reply_length: body.reply.length,
122
- trace_steps: body.trace?.length ?? 0,
123
- trace_failed: body.trace?.filter((step) => step.status === "failed").length ?? 0,
241
+ trace_steps: trace?.length ?? 0,
242
+ trace_failed: trace?.filter((step) => step.status === "failed").length ?? 0,
124
243
  model_requested: context.command.model ?? null,
125
244
  elapsed_ms: Date.now() - startedAt,
126
245
  thread: context.command.thread === "main" ? "default" : "named",
127
246
  subject: context.command.subject ? "selected" : "caller",
128
247
  image_attached: attachment !== null,
129
248
  image_byte_size: attachment?.bytes.byteLength ?? null,
249
+ streamed: turn.streamed,
250
+ live_trace_lines: liveTraceLines,
130
251
  })}`);
131
252
  return 0;
132
253
  }
@@ -166,11 +287,53 @@ function writeAttachmentRefusal(command, io, refusal, filePath) {
166
287
  function writeTraceBlock(io, trace) {
167
288
  if (!trace || trace.length === 0)
168
289
  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}`);
290
+ for (const step of trace)
291
+ writeTraceLine(io, step);
292
+ }
293
+ /** The one dim trace line, drawn identically live (BLI-3457) and after the fact. */
294
+ function writeTraceLine(io, step) {
295
+ const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
296
+ const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
297
+ writeLine(io.stdout, `${TRACE_DIM} ⏺ ${step.label}${elapsed}${failure}${TRACE_RESET}`);
298
+ }
299
+ /**
300
+ * Draws one streamed tool call, and says whether it drew anything.
301
+ *
302
+ * Only settled steps print: a `running` event is the same tool arriving a
303
+ * second time, and printing both would double every line in a terminal that
304
+ * cannot rewrite the one above it.
305
+ */
306
+ function writeActivityLine(io, event) {
307
+ if (event.status !== "done" && event.status !== "failed")
308
+ return false;
309
+ const step = activityToStep(event);
310
+ if (!step)
311
+ return false;
312
+ writeTraceLine(io, step);
313
+ return true;
314
+ }
315
+ /** The settled steps of a streamed turn, in the shape `--json` already promises. */
316
+ function activityToTrace(activity) {
317
+ const steps = [];
318
+ for (const event of activity) {
319
+ if (event.status !== "done" && event.status !== "failed")
320
+ continue;
321
+ const step = activityToStep(event);
322
+ if (step)
323
+ steps.push(step);
173
324
  }
325
+ return steps;
326
+ }
327
+ function activityToStep(event) {
328
+ if (event.status !== "done" && event.status !== "failed")
329
+ return null;
330
+ return {
331
+ tool: event.tool ?? "unknown_tool",
332
+ label: event.label ?? event.tool ?? "A tool ran",
333
+ status: event.status,
334
+ ...(typeof event.elapsedMs === "number" ? { elapsedMs: event.elapsedMs } : {}),
335
+ ...(event.detail ? { detail: event.detail } : {}),
336
+ };
174
337
  }
175
338
  /**
176
339
  * One line, only when the answer did not come from what was requested — a
@@ -185,22 +348,10 @@ function writeModelReceipt(io, model) {
185
348
  const mismatched = requested !== null && answered !== null && requested !== answered;
186
349
  if (!model.fallback && !mismatched)
187
350
  return;
188
- writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered (${requested ?? "the requested model"} unavailable)`);
189
- }
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
- }
351
+ // BLI-3467: never the word "unavailable" this side cannot know why, and
352
+ // on 2026-09-01 the real cause was a healthy provider refusing our own tool
353
+ // schema. Say what the receipt actually reports.
354
+ writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered instead of ${requested ?? "the requested model"}`);
204
355
  }
205
356
  function writeFailure(command, io, reason, detail) {
206
357
  if (command.json) {