@bli-cockpit/cli 0.2.54 → 0.2.55

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.
Files changed (60) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +247 -0
  2. package/dist/adapters/attribution-core-paths.js +182 -0
  3. package/dist/adapters/attribution-core-score.js +159 -0
  4. package/dist/adapters/attribution-core-types.js +13 -0
  5. package/dist/adapters/attribution-core.js +13 -565
  6. package/dist/adapters/claude-attribution-discovery.js +186 -0
  7. package/dist/adapters/claude-attribution-score.js +204 -0
  8. package/dist/adapters/claude-attribution-signals.js +180 -0
  9. package/dist/adapters/claude-attribution-types.js +25 -0
  10. package/dist/adapters/claude-attribution.js +14 -569
  11. package/dist/commands/doctor-access.js +129 -0
  12. package/dist/commands/doctor-pipeline.js +326 -0
  13. package/dist/commands/doctor-registration.js +105 -0
  14. package/dist/commands/doctor-report.js +111 -0
  15. package/dist/commands/doctor-update.js +120 -0
  16. package/dist/commands/doctor.js +8 -753
  17. package/dist/commands/heartbeat.js +8 -0
  18. package/dist/commands/jarvis-contracts.js +8 -0
  19. package/dist/commands/jarvis-render.js +413 -0
  20. package/dist/commands/jarvis-turn.js +305 -0
  21. package/dist/commands/jarvis.js +23 -698
  22. package/dist/commands/local-args-collector-setup.js +250 -0
  23. package/dist/commands/local-args-collector-status.js +227 -0
  24. package/dist/commands/local-args-collector-work.js +175 -0
  25. package/dist/commands/local-args-collector.js +19 -624
  26. package/dist/commands/local-args-tower-admin.js +456 -0
  27. package/dist/commands/local-args-tower-chat.js +194 -0
  28. package/dist/commands/local-args-tower-pages.js +314 -0
  29. package/dist/commands/local-args-tower.js +13 -880
  30. package/dist/commands/local-help.js +10 -2
  31. package/dist/commands/onboard-completion.js +136 -0
  32. package/dist/commands/onboard-flows.js +165 -0
  33. package/dist/commands/onboard-setup.js +102 -0
  34. package/dist/commands/onboard.js +5 -392
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/session-sync-counters.js +55 -0
  37. package/dist/commands/session-sync-health.js +8 -1
  38. package/dist/commands/session-sync-plan.js +47 -7
  39. package/dist/commands/session-sync-scan.js +4 -4
  40. package/dist/commands/session-sync.js +6 -0
  41. package/dist/commands/settings-render.js +27 -0
  42. package/dist/commands/sync-followups.js +5 -1
  43. package/dist/commands/sync.js +5 -1
  44. package/dist/commands/team-device-reasons.js +16 -0
  45. package/dist/commands/team.js +87 -7
  46. package/dist/evidence-upload-client.js +14 -763
  47. package/dist/evidence-upload-object.js +181 -0
  48. package/dist/evidence-upload-plan.js +233 -0
  49. package/dist/evidence-upload-terminal.js +309 -0
  50. package/dist/evidence-upload-transport.js +104 -0
  51. package/dist/spool/local-spool-io.js +122 -0
  52. package/dist/spool/local-spool-mutations.js +174 -0
  53. package/dist/spool/local-spool-parse.js +143 -0
  54. package/dist/spool/local-spool-types.js +22 -0
  55. package/dist/spool/local-spool.js +20 -426
  56. package/dist/upload-evidence-delivery-offer.js +144 -0
  57. package/dist/upload-evidence-delivery-reconcile.js +134 -0
  58. package/dist/upload-evidence-delivery-summary.js +205 -0
  59. package/dist/upload-evidence-delivery.js +12 -482
  60. package/package.json +3 -3
@@ -102,6 +102,12 @@ export function buildCollectorHeartbeat(options) {
102
102
  ...(typeof options.facts.sessionsOutsideRoot === "number"
103
103
  ? { sessions_outside_root: options.facts.sessionsOutsideRoot }
104
104
  : {}),
105
+ ...(typeof options.facts.sessionsNewThisTick === "number"
106
+ ? { sessions_new_this_tick: options.facts.sessionsNewThisTick }
107
+ : {}),
108
+ ...(typeof options.facts.sessionsPendingUpload === "number"
109
+ ? { sessions_pending_upload: options.facts.sessionsPendingUpload }
110
+ : {}),
105
111
  };
106
112
  }
107
113
  /**
@@ -176,6 +182,8 @@ export async function sendCollectorHeartbeatBestEffort(options) {
176
182
  sync_reason: heartbeat.last_sync_reason ?? null,
177
183
  sessions_observed: heartbeat.sessions_observed ?? null,
178
184
  sessions_outside_root: heartbeat.sessions_outside_root ?? null,
185
+ sessions_new_this_tick: heartbeat.sessions_new_this_tick ?? null,
186
+ sessions_pending_upload: heartbeat.sessions_pending_upload ?? null,
179
187
  }));
180
188
  return true;
181
189
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The parsed `cockpit jarvis` command, and the reply shapes it reads off
3
+ * `/api/jarvis/cli` and `--threads`/`--history`. Every reply field is loose
4
+ * and optional on purpose — this package has no dependency on
5
+ * `apps/dashboard`, so a shape drifting a field should degrade to "say
6
+ * nothing about it" here, never a crash.
7
+ */
8
+ export {};
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Everything around a turn that is not the network call itself: getting a
3
+ * turn ready to send (the question, and the attachment multipart body),
4
+ * reporting what it did (the failure sentence, the fallback-model receipt,
5
+ * the latency split), and drawing the answer in a terminal — live, settled,
6
+ * and its "how I got there" trace — since a terminal cannot unprint.
7
+ */
8
+ import { colorEnabled, dim, isInteractiveStdin, readPipedText, writeFragment, writeLine } from "./cli-io.js";
9
+ import { attachedFileRefusalSentence, } from "./jarvis-attachment.js";
10
+ import { turnTimingFields } from "../tower-stream.js";
11
+ /**
12
+ * The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
13
+ * waits slightly longer so the server's own named failure wins the race
14
+ * whenever it manages to send one; past that, the terminal names the timeout
15
+ * itself rather than sitting there.
16
+ */
17
+ export const TURN_DEADLINE_MS = 125_000;
18
+ export async function resolveOneShotPrompt(command, io) {
19
+ if (command.prompt)
20
+ return validatePrompt(command.prompt);
21
+ if (isInteractiveStdin(io))
22
+ return null;
23
+ const piped = await readPipedText(io.stdin, { maxChars: 4000, overflowMessage: "JARVIS questions are limited to 4000 characters." });
24
+ return validatePrompt(piped);
25
+ }
26
+ export function validatePrompt(raw) {
27
+ const prompt = raw.trim();
28
+ if (!prompt)
29
+ throw new Error("JARVIS needs a non-empty question.");
30
+ if (prompt.length > 4000) {
31
+ throw new Error("JARVIS questions are limited to 4000 characters.");
32
+ }
33
+ return prompt;
34
+ }
35
+ /**
36
+ * The multipart body `/api/jarvis/cli` reads when a file is attached
37
+ * (BLI-3414) — same field names the request handler parses, mirroring the
38
+ * JSON body's fields plus one `image` file field.
39
+ */
40
+ export function buildAttachmentForm(command, prompt, attachment) {
41
+ const form = new FormData();
42
+ form.set("question", prompt);
43
+ form.set("thread", command.thread);
44
+ if (command.subject)
45
+ form.set("subject", command.subject);
46
+ if (command.model)
47
+ form.set("model", command.model);
48
+ if (command.date)
49
+ form.set("date", command.date);
50
+ form.set("image", new File([attachment.bytes], attachment.fileName, { type: attachment.mimeType }));
51
+ return form;
52
+ }
53
+ /** A refusal caught locally, before any request went out — never a stack trace. */
54
+ export function writeAttachmentRefusal(command, io, refusal, filePath) {
55
+ const message = attachedFileRefusalSentence(refusal, filePath);
56
+ if (command.json) {
57
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: refusal, detail: message }));
58
+ }
59
+ else {
60
+ writeLine(io.stderr, `JARVIS could not attach that file: ${message}`);
61
+ }
62
+ writeLine(io.stderr, `[jarvis cli] attachment refused ${JSON.stringify({ reason: refusal })}`);
63
+ }
64
+ export function writeFailure(command, io, reason, detail) {
65
+ if (command.json) {
66
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
67
+ return;
68
+ }
69
+ writeLine(io.stderr, `JARVIS could not answer: ${detail}`);
70
+ }
71
+ /**
72
+ * One line, only when the answer did not come from what was requested — a
73
+ * fallback, or an explicit `--model` that landed on a different model
74
+ * (BLI-3381). Silent otherwise, matching the web panel's own fallback notice.
75
+ */
76
+ export function writeModelReceipt(io, model) {
77
+ if (!model)
78
+ return;
79
+ const requested = typeof model.requestedModel === "string" ? model.requestedModel : null;
80
+ const answered = namedModel(model.model);
81
+ const mismatched = requested !== null && answered !== null && requested !== answered;
82
+ if (!model.fallback && !mismatched)
83
+ return;
84
+ // BLI-3467: never the word "unavailable" — this side cannot know why, and
85
+ // on 2026-09-01 the real cause was a healthy provider refusing our own tool
86
+ // schema. Say what the receipt actually reports.
87
+ writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered instead of ${requested ?? "the requested model"}`);
88
+ }
89
+ /** One millisecond span off the wire, or null for anything that is not a number. */
90
+ function latencyMs(value) {
91
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
92
+ }
93
+ /** The latency split for `--json`, always the same three keys so a consumer can rely on them. */
94
+ export function latencyFields(latency) {
95
+ return {
96
+ wireMs: latencyMs(latency?.wireMs),
97
+ prepMs: latencyMs(latency?.prepMs),
98
+ modelTtftMs: latencyMs(latency?.modelTtftMs),
99
+ };
100
+ }
101
+ /** The same three spans as log fields, snake_case like everything else on that line. */
102
+ export function latencyLogFields(latency) {
103
+ const split = latencyFields(latency);
104
+ return {
105
+ wire_ms: split.wireMs,
106
+ prep_ms: split.prepMs,
107
+ model_ttft_ms: split.modelTtftMs,
108
+ };
109
+ }
110
+ /**
111
+ * The receipt's `model` field, unless it is the server's own "I could not tell"
112
+ * sentinel (BLI-3582).
113
+ *
114
+ * `modelReceipt` on the dashboard writes the literal string `"unknown"` when
115
+ * the run reported no model, and this side used to read that as a model NAME —
116
+ * so a turn whose model was simply unnamed printed
117
+ * `Model: unknown answered instead of gpt-5.6-terra`, a fallback notice for a
118
+ * fallback that never happened. Every streamed turn hit it. The dashboard fix
119
+ * makes the streamed path name the model again; this one makes the sentinel
120
+ * unable to invent a fallback on any dashboard version, old or new.
121
+ */
122
+ function namedModel(value) {
123
+ if (typeof value !== "string")
124
+ return null;
125
+ const trimmed = value.trim();
126
+ if (!trimmed || trimmed.toLowerCase() === "unknown")
127
+ return null;
128
+ return trimmed;
129
+ }
130
+ /**
131
+ * The terminal's own spans as log fields (BLI-3591).
132
+ *
133
+ * Deliberately snake_case beside the dashboard's three, and deliberately
134
+ * distinct names: `model_ttft_ms` is the dashboard's clock, `first_token_ms`
135
+ * is this one, and confusing them is how a latency line stops being
136
+ * answerable.
137
+ */
138
+ export function clientTimingFields(boot, turn) {
139
+ return {
140
+ boot_ms: boot.bootMs,
141
+ session_ms: boot.sessionMs,
142
+ prompt_ms: boot.promptMs,
143
+ pre_request_ms: turn.preRequestMs,
144
+ responded_ms: turn.respondedMs,
145
+ ...turnTimingFields(turn.timing),
146
+ };
147
+ }
148
+ /**
149
+ * A citation line's link, as the dashboard mints it (BLI-3570):
150
+ * `Source: BLI-1234 — Judge outage <https://linear.app/…>`. The words are the
151
+ * receipt; the link is not part of the sentence, so the terminal prints it
152
+ * underneath, dim, rather than dragging a 118-character URL through the answer.
153
+ */
154
+ const CITATION_LINK = /^(\s*(?:Source|Receipt)s?:.*?)\s*<(https?:\/\/[^>\s]+)>\s*$/i;
155
+ /**
156
+ * The answer, one line at a time, so a receipt's link gets its own dim line.
157
+ * Every other line is printed exactly as it arrived — this never rewrites the
158
+ * words, only where the link sits.
159
+ */
160
+ export function writeReply(io, prefix, reply) {
161
+ const styled = colorEnabled(io);
162
+ const lines = reply.split("\n");
163
+ lines.forEach((line, index) => {
164
+ const head = index === 0 ? prefix : "";
165
+ const citation = line.match(CITATION_LINK);
166
+ if (!citation) {
167
+ writeLine(io.stdout, `${head}${line}`);
168
+ return;
169
+ }
170
+ writeLine(io.stdout, `${head}${citation[1]}`);
171
+ writeLine(io.stdout, dim(` ${citation[2]}`, styled));
172
+ });
173
+ }
174
+ /**
175
+ * The same link rule, applied to text that CONTINUES a line already on screen
176
+ * (BLI-3517 + BLI-3570).
177
+ *
178
+ * When the answer streamed, the citation block arrives after the last token,
179
+ * so `settle` has a tail to print rather than a whole reply. The first fragment
180
+ * is deliberately never tested against `CITATION_LINK`: it is the rest of a
181
+ * sentence the person is already reading, not a line of its own, and a regex
182
+ * anchored at `^` would be matching against a boundary that is not there.
183
+ * Every COMPLETE line after it gets the dim link treatment as usual.
184
+ *
185
+ * A citation that was already streamed inline cannot be split — a terminal
186
+ * cannot unprint — and that is the honest limit of this, not a bug to chase.
187
+ */
188
+ export function writeReplyContinuation(io, tail) {
189
+ const styled = colorEnabled(io);
190
+ const [first = "", ...rest] = tail.split("\n");
191
+ writeFragment(io.stdout, first);
192
+ for (const line of rest) {
193
+ writeLine(io.stdout, "");
194
+ const citation = line.match(CITATION_LINK);
195
+ if (!citation) {
196
+ writeFragment(io.stdout, line);
197
+ continue;
198
+ }
199
+ writeFragment(io.stdout, citation[1]);
200
+ writeLine(io.stdout, "");
201
+ writeFragment(io.stdout, dim(` ${citation[2]}`, styled));
202
+ }
203
+ writeLine(io.stdout, "");
204
+ }
205
+ /**
206
+ * What the terminal says when the grounding gate changed an answer it had
207
+ * already printed. The browser's own mark says the same thing in one word
208
+ * (`ANSWER_REVISED_NOTE`, `apps/dashboard/src/lib/webchat/tool-trace.ts`);
209
+ * this package cannot import from the dashboard, so the sentence is written
210
+ * here in the same register rather than shared through a dependency that does
211
+ * not exist.
212
+ */
213
+ const ANSWER_REVISED_LINE = "revised: I checked that against its sources and changed what they did not back up";
214
+ /**
215
+ * The three lines a correction's rewrite prints (BLI-3567).
216
+ *
217
+ * The browsers replace the paragraph where it stands. A terminal cannot —
218
+ * stdout is a river — so it says the same three things in sequence instead:
219
+ * that a rewrite is under way, what the paragraph now says, and that the
220
+ * printed one above it did not change. The last of those is the honest half:
221
+ * without it a person would have two versions on screen and no idea which one
222
+ * JARVIS believes.
223
+ *
224
+ * `ANSWER_UPDATING_LINE` is the terminal's copy of the browsers'
225
+ * `ANSWER_UPDATING_LABEL` (`apps/dashboard/src/lib/webchat/tool-trace.ts`),
226
+ * word for word. This package cannot import from the dashboard, so it is
227
+ * written out here rather than shared through a dependency that does not exist
228
+ * — the same arrangement `ANSWER_REVISED_LINE` above already has.
229
+ */
230
+ export const ANSWER_UPDATING_LINE = "(updating...)";
231
+ const ANSWER_CORRECTED_LINE = "revised after your correction";
232
+ const ANSWER_CORRECTED_NOTE_LINE = "The paragraph printed above is unchanged — a terminal cannot rewrite what it already " +
233
+ "printed. This is what it says now.";
234
+ const ANSWER_REWRITE_UNVERIFIED_LINE = "Your correction is recorded. I could not verify a rewrite of that paragraph against its " +
235
+ "sources, so it stays as it was.";
236
+ /**
237
+ * The answer as it is written, in a terminal (BLI-3517).
238
+ *
239
+ * A terminal cannot unprint. That single fact decides everything here:
240
+ *
241
+ * - The words stream out under the usual `jarvis> ` prefix, printed the moment
242
+ * they arrive rather than after the whole turn (a measured 4-16 s of nothing
243
+ * before this ticket).
244
+ * - A `reset` — the server taking back a draft the model wrote before deciding
245
+ * to call a tool — cannot erase what is on screen, so it SAYS so on its own
246
+ * line and the replacement follows. Never a retracted sentence left standing
247
+ * with nothing marking it.
248
+ * - The settled reply is the authority. The grounding gate runs on the whole
249
+ * text once the stream ends, so the answer can grow (citations, a fallback
250
+ * notice) or change. A pure continuation is printed as the remainder; a
251
+ * genuine change is reprinted whole under a line saying it was revised.
252
+ * - `--json` never streams a fragment: that contract is exactly one object on
253
+ * stdout, and every method here is a no-op for it.
254
+ */
255
+ export function createLiveAnswer(command, io) {
256
+ const styled = colorEnabled(io);
257
+ const quiet = command.json === true;
258
+ let printed = "";
259
+ let opened = false;
260
+ const open = (subject) => {
261
+ if (opened)
262
+ return;
263
+ opened = true;
264
+ writeFragment(io.stdout, `jarvis${subject}> `);
265
+ };
266
+ return {
267
+ token(event) {
268
+ if (quiet)
269
+ return;
270
+ const text = event.text ?? "";
271
+ if (event.reset) {
272
+ if (printed) {
273
+ writeLine(io.stdout, "");
274
+ writeLine(io.stdout, dim(" — that draft was replaced —", styled));
275
+ opened = false;
276
+ }
277
+ printed = "";
278
+ }
279
+ if (!text)
280
+ return;
281
+ open("");
282
+ printed += text;
283
+ writeFragment(io.stdout, text);
284
+ },
285
+ interrupt() {
286
+ if (quiet || !opened)
287
+ return;
288
+ writeLine(io.stdout, "");
289
+ opened = false;
290
+ },
291
+ settle(reply, subject, revised) {
292
+ if (quiet)
293
+ return;
294
+ if (!printed) {
295
+ // Nothing streamed: a non-streaming dashboard, `--no-stream`, or a
296
+ // turn whose words never arrived. Exactly the pre-ticket line, dim
297
+ // citation link and all (BLI-3570).
298
+ writeReply(io, `jarvis${subject}> `, reply);
299
+ return;
300
+ }
301
+ if (reply === printed) {
302
+ if (opened)
303
+ writeLine(io.stdout, "");
304
+ return;
305
+ }
306
+ if (reply.startsWith(printed)) {
307
+ // The gate only ADDED — citations going back under the answer, a
308
+ // fallback notice. Print the tail and leave the words alone; the
309
+ // citation block is exactly the thing BLI-3570 puts its link under, and
310
+ // it is arriving here rather than in the stream.
311
+ if (!opened)
312
+ writeFragment(io.stdout, `jarvis${subject}> `);
313
+ writeReplyContinuation(io, reply.slice(printed.length));
314
+ return;
315
+ }
316
+ if (opened)
317
+ writeLine(io.stdout, "");
318
+ writeLine(io.stdout, dim(revised ? ` — ${ANSWER_REVISED_LINE} —` : " — corrected —", styled));
319
+ writeReply(io, `jarvis${subject}> `, reply);
320
+ },
321
+ abandon() {
322
+ // The turn died with words already on screen. End the line so the
323
+ // failure sentence does not run on from a half-written answer.
324
+ if (!quiet && opened)
325
+ writeLine(io.stdout, "");
326
+ },
327
+ };
328
+ }
329
+ /**
330
+ * Print what a correction did to the answer above, once the turn has settled.
331
+ *
332
+ * `--json` prints nothing: that contract is exactly one object on stdout, and
333
+ * the revision rides it as a field. Every branch either prints or is a
334
+ * deliberate no-op with a stderr line behind it in `sendOneTurn`.
335
+ */
336
+ export function writeRevision(command, io, revision) {
337
+ if (command.json)
338
+ return;
339
+ const styled = colorEnabled(io);
340
+ if (revision.status === "unverified") {
341
+ writeLine(io.stdout, "");
342
+ writeLine(io.stdout, dim(` — ${ANSWER_REWRITE_UNVERIFIED_LINE}`, styled));
343
+ return;
344
+ }
345
+ if (revision.status !== "revised")
346
+ return;
347
+ const paragraphs = Array.isArray(revision.paragraphs) ? revision.paragraphs : [];
348
+ const written = paragraphs.filter((one) => typeof one === "object" && one !== null && typeof one.text === "string");
349
+ if (written.length === 0)
350
+ return;
351
+ writeLine(io.stdout, "");
352
+ writeLine(io.stdout, dim(` — ${ANSWER_CORRECTED_LINE} —`, styled));
353
+ for (const paragraph of written)
354
+ writeLine(io.stdout, paragraph.text);
355
+ writeLine(io.stdout, dim(` ${ANSWER_CORRECTED_NOTE_LINE}`, styled));
356
+ }
357
+ /**
358
+ * One dim line per tool the turn called (BLI-3381): what it was, how long it
359
+ * took, and — for a failure — the reason, matching what the web thinking
360
+ * trace shows (`describeToolTraceEvent` on the server side built these
361
+ * labels; this only renders them). Silent when no tool ran.
362
+ */
363
+ export function writeTraceBlock(io, trace) {
364
+ if (!trace || trace.length === 0)
365
+ return;
366
+ for (const step of trace)
367
+ writeTraceLine(io, step);
368
+ }
369
+ /** The one dim trace line, drawn identically live (BLI-3457) and after the fact. */
370
+ export function writeTraceLine(io, step) {
371
+ const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
372
+ const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
373
+ writeLine(io.stdout, dim(` ⏺ ${step.label}${elapsed}${failure}`, colorEnabled(io)));
374
+ }
375
+ /**
376
+ * Draws one streamed tool call, and says whether it drew anything.
377
+ *
378
+ * Only settled steps print: a `running` event is the same tool arriving a
379
+ * second time, and printing both would double every line in a terminal that
380
+ * cannot rewrite the one above it.
381
+ */
382
+ export function writeActivityLine(io, event) {
383
+ if (event.status !== "done" && event.status !== "failed")
384
+ return false;
385
+ const step = activityToStep(event);
386
+ if (!step)
387
+ return false;
388
+ writeTraceLine(io, step);
389
+ return true;
390
+ }
391
+ /** The settled steps of a streamed turn, in the shape `--json` already promises. */
392
+ export function activityToTrace(activity) {
393
+ const steps = [];
394
+ for (const event of activity) {
395
+ if (event.status !== "done" && event.status !== "failed")
396
+ continue;
397
+ const step = activityToStep(event);
398
+ if (step)
399
+ steps.push(step);
400
+ }
401
+ return steps;
402
+ }
403
+ function activityToStep(event) {
404
+ if (event.status !== "done" && event.status !== "failed")
405
+ return null;
406
+ return {
407
+ tool: event.tool ?? "unknown_tool",
408
+ label: event.label ?? event.tool ?? "A tool ran",
409
+ status: event.status,
410
+ ...(typeof event.elapsedMs === "number" ? { elapsedMs: event.elapsedMs } : {}),
411
+ ...(event.detail ? { detail: event.detail } : {}),
412
+ };
413
+ }