viveworker 0.8.0 → 0.8.1

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.
@@ -16,6 +16,7 @@ import zlib from "node:zlib";
16
16
  import webPush from "web-push";
17
17
  import * as hazbaseAuth from "@hazbase/auth";
18
18
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "../web/i18n.js";
19
+ import { APP_BUILD_ID as WEB_APP_BUILD_ID } from "../web/build-id.js";
19
20
  import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
20
21
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
21
22
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
@@ -48,8 +49,8 @@ const listSupportedPayments = typeof hazbaseAuth.listSupportedPayments === "func
48
49
  ? hazbaseAuth.listSupportedPayments.bind(hazbaseAuth)
49
50
  : async () => ({ networks: [], defaultNetwork: "base-sepolia" });
50
51
  const appPackageVersion = readPackageVersion();
51
- const WEB_APP_BUILD_ID = "20260428-remote-token-refresh";
52
52
  const WEB_APP_SCRIPT_URL = `/app.js?v=${WEB_APP_BUILD_ID}`;
53
+ const WEB_APP_BUILD_ID_PLACEHOLDER = "__VIVEWORKER_APP_BUILD_ID__";
53
54
  const sessionCookieName = "viveworker_session";
54
55
  const deviceCookieName = "viveworker_device";
55
56
  const historyKinds = new Set(["completion", "assistant_final", "plan_ready", "approval", "plan", "choice", "info", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
@@ -2839,6 +2840,7 @@ function normalizeProvider(value) {
2839
2840
  if (normalized === "moltbook") return "moltbook";
2840
2841
  if (normalized === "a2a") return "a2a";
2841
2842
  if (normalized === "viveworker") return "viveworker";
2843
+ if (normalized === "mcp") return "mcp";
2842
2844
  return "codex";
2843
2845
  }
2844
2846
 
@@ -2848,6 +2850,7 @@ function providerDisplayName(locale, provider) {
2848
2850
  if (p === "moltbook") return "Moltbook";
2849
2851
  if (p === "a2a") return "A2A";
2850
2852
  if (p === "viveworker") return t(locale, "common.appName");
2853
+ if (p === "mcp") return "MCP";
2851
2854
  return t(locale, "common.codex");
2852
2855
  }
2853
2856
 
@@ -2896,7 +2899,7 @@ function normalizeHistoryItem(raw) {
2896
2899
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
2897
2900
  const rawThreadLabel = cleanText(raw.threadLabel ?? "");
2898
2901
  const historyProvider = normalizeProvider(raw.provider);
2899
- const skipHistoryThreadLabelRewrite = historyProvider === "moltbook" || historyProvider === "a2a" || historyProvider === "viveworker"
2902
+ const skipHistoryThreadLabelRewrite = historyProvider === "moltbook" || historyProvider === "a2a" || historyProvider === "viveworker" || historyProvider === "mcp"
2900
2903
  || kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
2901
2904
  const threadLabel = skipHistoryThreadLabelRewrite
2902
2905
  ? rawThreadLabel
@@ -3183,7 +3186,7 @@ function normalizeTimelineEntry(raw) {
3183
3186
  (kind === "file_event" ? "" : cleanText(raw.title ?? "")) ||
3184
3187
  "";
3185
3188
  const rawProvider = normalizeProvider(raw.provider);
3186
- const skipThreadLabelRewrite = rawProvider === "moltbook" || rawProvider === "a2a" || rawProvider === "viveworker"
3189
+ const skipThreadLabelRewrite = rawProvider === "moltbook" || rawProvider === "a2a" || rawProvider === "viveworker" || rawProvider === "mcp"
3187
3190
  || kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
3188
3191
  const threadLabel =
3189
3192
  skipThreadLabelRewrite
@@ -4354,7 +4357,10 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4354
4357
  if (block?.type === "text" && block.text) text += block.text;
4355
4358
  }
4356
4359
  }
4357
- text = cleanText(text);
4360
+ // Preserve paragraph structure for the renderer — `cleanText` collapses
4361
+ // every \s+ (newlines included) into a single space, which would flatten
4362
+ // markdown tables / code fences / lists into a single illegible line.
4363
+ text = normalizeLongText(text);
4358
4364
  if (!text) continue;
4359
4365
  if (type === "user" && record.entrypoint === "sdk-cli") {
4360
4366
  const derivedThreadLabel = deriveClaudeSdkCliThreadLabel(text, threadId);
@@ -10644,6 +10650,273 @@ function buildPushStatusResponse(config, state, session) {
10644
10650
  };
10645
10651
  }
10646
10652
 
10653
+ async function buildMcpStatusResponse(config, runtime, state) {
10654
+ const remote = getRemotePairingStatus(runtime);
10655
+ let remotePairings = 0;
10656
+ try {
10657
+ remotePairings = (await loadPairings()).length;
10658
+ } catch {
10659
+ remotePairings = 0;
10660
+ }
10661
+ const now = Date.now();
10662
+ const pairedDevices = isPlainObject(state.pairedDevices) ? state.pairedDevices : {};
10663
+ const trustedDevices = Object.values(pairedDevices).filter((raw) => {
10664
+ const record = normalizeDeviceTrustRecord(raw, { trustTtlMs: config.deviceTrustTtlMs, now });
10665
+ return record && !record.revokedAtMs && Number(record.trustedUntilMs) > now;
10666
+ }).length;
10667
+ const a2aRelay = getRelayStatus();
10668
+ return {
10669
+ ok: true,
10670
+ bridge: {
10671
+ running: true,
10672
+ publicBaseUrl: config.nativeApprovalPublicBaseUrl,
10673
+ locale: config.defaultLocale,
10674
+ codexConnected: Boolean(runtime.ipcClient?.clientId),
10675
+ },
10676
+ pairing: {
10677
+ trustedDevices,
10678
+ webPushEnabled: Boolean(config.webPushEnabled),
10679
+ pushSubscriptions: listPushSubscriptions(state).length,
10680
+ },
10681
+ remote: {
10682
+ enabled: Boolean(remote.enabled),
10683
+ relayUrl: remote.relayUrl || config.remotePairingRelayUrl || "",
10684
+ identityFingerprint: remote.identityFingerprint || null,
10685
+ pairings: remotePairings,
10686
+ sessions: Array.isArray(remote.sessions)
10687
+ ? remote.sessions.map((session) => ({
10688
+ state: cleanText(session?.state || ""),
10689
+ lastRoute: cleanText(session?.lastRoute || ""),
10690
+ connected: cleanText(session?.state || "") === "connected",
10691
+ phoneFingerprint: cleanText(session?.phoneFingerprint || ""),
10692
+ lastSeenAtMs: Number(session?.lastSeenAtMs) || 0,
10693
+ }))
10694
+ : [],
10695
+ },
10696
+ a2a: {
10697
+ enabled: Boolean(config.a2aApiKey),
10698
+ relayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
10699
+ relayConnected: Boolean(a2aRelay.polling && a2aRelay.lastPollOk),
10700
+ userIdConfigured: Boolean(config.a2aRelayUserId),
10701
+ },
10702
+ share: {
10703
+ enabled: Boolean(config.a2aRelayUserId && config.a2aApiKey),
10704
+ shareUrl: config.a2aShareUrl || "",
10705
+ },
10706
+ moltbook: {
10707
+ enabled: Boolean(config.moltbookApiKey),
10708
+ },
10709
+ };
10710
+ }
10711
+
10712
+ function mcpTimeoutMs(value) {
10713
+ const n = Number(value);
10714
+ if (!Number.isFinite(n)) return 600_000;
10715
+ return Math.max(10_000, Math.min(900_000, Math.floor(n)));
10716
+ }
10717
+
10718
+ function mcpText(value, maxChars = 50_000) {
10719
+ const text = String(value ?? "");
10720
+ return text.length > maxChars ? `${text.slice(0, maxChars)}\n\n[truncated by viveworker MCP]` : text;
10721
+ }
10722
+
10723
+ function normalizeMcpStringArray(value, maxItems = 20) {
10724
+ if (!Array.isArray(value)) return [];
10725
+ return value
10726
+ .map((item) => cleanText(item ?? ""))
10727
+ .filter(Boolean)
10728
+ .slice(0, maxItems);
10729
+ }
10730
+
10731
+ function normalizeMcpQuestionOptions(value) {
10732
+ if (!Array.isArray(value)) return [];
10733
+ return value
10734
+ .map((option, index) => {
10735
+ if (typeof option === "string") {
10736
+ const label = cleanText(option);
10737
+ return label ? { label } : null;
10738
+ }
10739
+ if (!isPlainObject(option)) return null;
10740
+ const label = cleanText(option.label ?? option.title ?? `Option ${index + 1}`);
10741
+ if (!label) return null;
10742
+ const description = cleanText(option.description ?? option.detail ?? "");
10743
+ return { label, ...(description ? { description } : {}) };
10744
+ })
10745
+ .filter(Boolean)
10746
+ .slice(0, 10);
10747
+ }
10748
+
10749
+ function normalizeMcpApprovalKind(value) {
10750
+ const normalized = cleanText(value || "mcp").toLowerCase();
10751
+ if (!/^[a-z0-9_-]{1,40}$/u.test(normalized)) {
10752
+ return "mcp";
10753
+ }
10754
+ // Keep MCP in the normal approval lane. These kinds have provider-specific
10755
+ // UI/actions elsewhere and should not be spoofable from a generic MCP client.
10756
+ if (normalized === "payment" || normalized === "hazbase_wallet_payment" || normalized === "plan" || normalized === "question") {
10757
+ return "mcp";
10758
+ }
10759
+ return normalized;
10760
+ }
10761
+
10762
+ function createMcpPendingApproval(body, kind) {
10763
+ const token = crypto.randomBytes(18).toString("hex");
10764
+ const requestId = cleanText(body.requestId || crypto.randomUUID());
10765
+ const title = cleanText(body.title || (kind === "question" ? "MCP question" : "MCP approval"));
10766
+ const messageText = kind === "question"
10767
+ ? mcpText(body.question || body.message || "")
10768
+ : mcpText(body.messageText || body.message || "");
10769
+ const requestKey = `mcp:${requestId}`;
10770
+ const approval = {
10771
+ token,
10772
+ requestKey,
10773
+ conversationId: "mcp",
10774
+ requestId,
10775
+ ownerClientId: null,
10776
+ kind,
10777
+ threadLabel: title || "MCP",
10778
+ title,
10779
+ messageText,
10780
+ fileRefs: normalizeMcpStringArray(body.fileRefs),
10781
+ previousFileRefs: [],
10782
+ diffText: kind === "question" ? "" : mcpText(body.diffText || "", 100_000),
10783
+ diffAvailable: kind !== "question" && Boolean(body.diffText),
10784
+ diffSource: kind === "question" ? "" : "mcp",
10785
+ diffAddedLines: 0,
10786
+ diffRemovedLines: 0,
10787
+ cwd: "",
10788
+ workspaceRoot: "",
10789
+ createdAtMs: Date.now(),
10790
+ resolved: false,
10791
+ resolving: false,
10792
+ resolveMcpWaiter: null,
10793
+ provider: "mcp",
10794
+ planText: "",
10795
+ questions: [],
10796
+ notifyOnly: false,
10797
+ readOnly: false,
10798
+ };
10799
+ if (kind === "question") {
10800
+ approval.questions = [
10801
+ {
10802
+ question: mcpText(body.question || body.message || "", 2000),
10803
+ options: normalizeMcpQuestionOptions(body.options),
10804
+ multiSelect: body.multiSelect === true,
10805
+ },
10806
+ ];
10807
+ }
10808
+ return approval;
10809
+ }
10810
+
10811
+ async function waitForMcpApprovalResult(runtime, approval, timeoutMs) {
10812
+ const waitMs = mcpTimeoutMs(timeoutMs);
10813
+ const result = await new Promise((resolve) => {
10814
+ const timer = setTimeout(() => {
10815
+ resolve({ approved: false, decision: "timeout" });
10816
+ }, waitMs);
10817
+ approval.resolveMcpWaiter = (value) => {
10818
+ clearTimeout(timer);
10819
+ resolve(value);
10820
+ };
10821
+ });
10822
+ if (!approval.resolved) {
10823
+ approval.resolved = true;
10824
+ approval.resolving = false;
10825
+ runtime.nativeApprovalsByToken.delete(approval.token);
10826
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
10827
+ }
10828
+ return result;
10829
+ }
10830
+
10831
+ async function handleMcpProviderEvent({ config, runtime, state, body, res }) {
10832
+ const eventType = cleanText(body.eventType || "");
10833
+ if (eventType === "status") {
10834
+ return writeJson(res, 200, await buildMcpStatusResponse(config, runtime, state));
10835
+ }
10836
+
10837
+ if (eventType === "notify") {
10838
+ const title = cleanText(body.title || "MCP");
10839
+ const messageText = mcpText(body.message || body.messageText || "");
10840
+ if (!messageText) {
10841
+ return writeJson(res, 400, { error: "missing-message" });
10842
+ }
10843
+ const stableId = `mcp_notify:${historyToken(`${title}:${messageText}:${Date.now()}`)}`;
10844
+ const token = historyToken(stableId);
10845
+ const entry = {
10846
+ stableId,
10847
+ token,
10848
+ kind: "assistant_commentary",
10849
+ threadId: "mcp",
10850
+ threadLabel: cleanText(body.threadLabel || "MCP"),
10851
+ title,
10852
+ summary: formatNotificationBody(messageText, 160) || messageText,
10853
+ messageText,
10854
+ createdAtMs: Date.now(),
10855
+ readOnly: true,
10856
+ provider: "mcp",
10857
+ };
10858
+ const timelineChanged = recordTimelineEntry({ config, runtime, state, entry });
10859
+ const historyChanged = recordHistoryItem({ config, runtime, state, item: entry });
10860
+ const pushChanged = await deliverWebPushItem({
10861
+ config,
10862
+ state,
10863
+ kind: "assistant_commentary",
10864
+ tab: "timeline",
10865
+ token,
10866
+ stableId,
10867
+ title,
10868
+ body: messageText,
10869
+ }).catch((error) => {
10870
+ console.error(`[mcp-notify-push] ${error.message}`);
10871
+ return false;
10872
+ });
10873
+ if (timelineChanged || historyChanged || pushChanged) {
10874
+ await saveState(config.stateFile, state);
10875
+ }
10876
+ return writeJson(res, 200, { ok: true, token });
10877
+ }
10878
+
10879
+ if (eventType === "approval_request" || eventType === "choice_request") {
10880
+ const kind = eventType === "choice_request" ? "question" : normalizeMcpApprovalKind(body.approvalKind);
10881
+ if (kind !== "question" && !mcpText(body.message || body.messageText || "")) {
10882
+ return writeJson(res, 400, { error: "missing-message" });
10883
+ }
10884
+ if (kind === "question" && !mcpText(body.question || body.message || "")) {
10885
+ return writeJson(res, 400, { error: "missing-question" });
10886
+ }
10887
+ const approval = createMcpPendingApproval(body, kind);
10888
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
10889
+ runtime.nativeApprovalsByRequestKey.set(approval.requestKey, approval);
10890
+ deliverWebPushItem({
10891
+ config,
10892
+ state,
10893
+ kind: "approval",
10894
+ tab: "inbox",
10895
+ subtab: "pending",
10896
+ token: approval.token,
10897
+ stableId: pendingApprovalStableId(approval),
10898
+ title: approval.title || "MCP",
10899
+ body: approval.messageText,
10900
+ buildLocalizedContent: () => ({
10901
+ title: approval.title || "MCP",
10902
+ body: approval.messageText,
10903
+ }),
10904
+ }).catch((error) => {
10905
+ console.error(`[mcp-approval-push] ${approval.requestKey} | ${error.message}`);
10906
+ });
10907
+ const result = await waitForMcpApprovalResult(runtime, approval, body.timeoutMs);
10908
+ return writeJson(res, 200, {
10909
+ ok: true,
10910
+ approved: result?.approved === true || result?.decision === "accept",
10911
+ decision: result?.decision || (result?.approved ? "accept" : "reject"),
10912
+ ...(result?.answerText ? { answerText: result.answerText } : {}),
10913
+ ...(Array.isArray(result?.answers) ? { answers: result.answers } : {}),
10914
+ });
10915
+ }
10916
+
10917
+ return writeJson(res, 400, { error: "unsupported-mcp-event" });
10918
+ }
10919
+
10647
10920
  function requestUserAgent(req) {
10648
10921
  return cleanText(req.headers?.["user-agent"] ?? "");
10649
10922
  }
@@ -13380,7 +13653,12 @@ async function autoApproveTrustedWrite({
13380
13653
  }
13381
13654
 
13382
13655
  async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
13383
- if (approval.resolveClaudeWaiter) {
13656
+ if (approval.resolveMcpWaiter) {
13657
+ approval.resolveMcpWaiter({
13658
+ approved: decision === "accept",
13659
+ decision,
13660
+ });
13661
+ } else if (approval.resolveClaudeWaiter) {
13384
13662
  if (approval.provider === "claude" && approval.kind === "plan") {
13385
13663
  // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
13386
13664
  // (Claude still shows the native PC plan dialog). Instead, deny the tool
@@ -13751,13 +14029,32 @@ const GZIPPABLE_EXTENSIONS = new Set([
13751
14029
  ]);
13752
14030
  const MIN_GZIP_BYTES = 512;
13753
14031
 
14032
+ function renderWebAssetBuffer(filePath, raw) {
14033
+ const basename = path.basename(filePath);
14034
+ if (basename !== "index.html" && basename !== "sw.js" && basename !== "app.js") {
14035
+ return raw;
14036
+ }
14037
+
14038
+ const text = raw.toString("utf8");
14039
+ if (!text.includes(WEB_APP_BUILD_ID_PLACEHOLDER)) {
14040
+ return raw;
14041
+ }
14042
+
14043
+ return Buffer.from(text.replaceAll(WEB_APP_BUILD_ID_PLACEHOLDER, WEB_APP_BUILD_ID), "utf8");
14044
+ }
14045
+
13754
14046
  async function loadWebAssetEntry(filePath) {
13755
14047
  const stat = await fs.stat(filePath);
13756
14048
  const cached = WEB_ASSET_CACHE.get(filePath);
13757
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
14049
+ if (
14050
+ cached &&
14051
+ cached.mtimeMs === stat.mtimeMs &&
14052
+ cached.sourceSize === stat.size &&
14053
+ cached.buildId === WEB_APP_BUILD_ID
14054
+ ) {
13758
14055
  return cached;
13759
14056
  }
13760
- const raw = await fs.readFile(filePath);
14057
+ const raw = renderWebAssetBuffer(filePath, await fs.readFile(filePath));
13761
14058
  const extension = path.extname(filePath).toLowerCase();
13762
14059
  let gzip = null;
13763
14060
  if (GZIPPABLE_EXTENSIONS.has(extension) && raw.length >= MIN_GZIP_BYTES) {
@@ -13771,7 +14068,8 @@ async function loadWebAssetEntry(filePath) {
13771
14068
  raw,
13772
14069
  gzip,
13773
14070
  mtimeMs: stat.mtimeMs,
13774
- size: stat.size,
14071
+ sourceSize: stat.size,
14072
+ buildId: WEB_APP_BUILD_ID,
13775
14073
  contentType: contentTypeForFile(filePath),
13776
14074
  };
13777
14075
  WEB_ASSET_CACHE.set(filePath, entry);
@@ -14173,6 +14471,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
14173
14471
  if (
14174
14472
  url.pathname === "/app.js" ||
14175
14473
  url.pathname === "/app.css" ||
14474
+ url.pathname === "/build-id.js" ||
14176
14475
  url.pathname === "/i18n.js" ||
14177
14476
  url.pathname === "/hazbase-passkey.js" ||
14178
14477
  url.pathname === "/sw.js" ||
@@ -15748,7 +16047,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15748
16047
  messageText: content,
15749
16048
  createdAtMs: share.createdAtMs,
15750
16049
  readOnly: false,
15751
- provider: "viveworker",
16050
+ provider: sourceTool === "mcp" ? "mcp" : "viveworker",
15752
16051
  },
15753
16052
  });
15754
16053
  await saveState(config.stateFile, state);
@@ -16252,6 +16551,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16252
16551
  return writeJson(res, 403, { approved: false, decision: "decline", error: "payment-approval-declined" });
16253
16552
  }
16254
16553
 
16554
+ if (url.pathname === "/api/providers/mcp/events" && req.method === "POST") {
16555
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
16556
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
16557
+ return writeJson(res, 401, { error: "unauthorized" });
16558
+ }
16559
+
16560
+ let body;
16561
+ try {
16562
+ body = await parseJsonBody(req);
16563
+ } catch {
16564
+ return writeJson(res, 400, { error: "invalid-json-body" });
16565
+ }
16566
+
16567
+ return handleMcpProviderEvent({ config, runtime, state, body, res });
16568
+ }
16569
+
16255
16570
  if (url.pathname === "/api/providers/claude/events" && req.method === "POST") {
16256
16571
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
16257
16572
  if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
@@ -17127,7 +17442,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17127
17442
 
17128
17443
  approval.resolving = true;
17129
17444
  try {
17130
- if (approval.resolveClaudeWaiter) {
17445
+ if (approval.resolveMcpWaiter) {
17446
+ approval.resolveMcpWaiter({
17447
+ approved: true,
17448
+ decision: "answered",
17449
+ answers,
17450
+ answerText: reasonText,
17451
+ });
17452
+ } else if (approval.resolveClaudeWaiter) {
17131
17453
  approval.resolveClaudeWaiter({
17132
17454
  permissionDecision: "deny",
17133
17455
  permissionDecisionReason: reasonText,
@@ -17163,8 +17485,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17163
17485
  if (stateChanged) {
17164
17486
  await saveState(config.stateFile, state);
17165
17487
  }
17166
- console.log(`[claude-question-answer] ${approval.requestKey}`);
17167
- activateClaudeDesktopIfMac(req);
17488
+ console.log(`[${approval.provider === "mcp" ? "mcp" : "claude"}-question-answer] ${approval.requestKey}`);
17489
+ if (approval.provider === "claude") {
17490
+ activateClaudeDesktopIfMac(req);
17491
+ }
17168
17492
  return writeJson(res, 200, { ok: true });
17169
17493
  } catch (error) {
17170
17494
  approval.resolving = false;
@@ -20109,16 +20433,20 @@ function extractRolloutMessageText(content) {
20109
20433
  if (!Array.isArray(content)) {
20110
20434
  return "";
20111
20435
  }
20112
- return cleanText(
20113
- content
20114
- .map((entry) =>
20115
- isPlainObject(entry) && (entry.type === "input_text" || entry.type === "output_text")
20116
- ? normalizeTimelineMessageText(entry.text ?? "")
20117
- : ""
20118
- )
20119
- .filter(Boolean)
20120
- .join("\n")
20121
- );
20436
+ // The per-entry text is already passed through `normalizeTimelineMessageText`
20437
+ // (which preserves newlines for the markdown renderer). The final wrap used
20438
+ // to be `cleanText`, which collapses every \s+ — newlines included — into
20439
+ // a single space, flattening tables / code fences / lists into one
20440
+ // illegible line. Use a plain trim instead so the structure survives.
20441
+ return content
20442
+ .map((entry) =>
20443
+ isPlainObject(entry) && (entry.type === "input_text" || entry.type === "output_text")
20444
+ ? normalizeTimelineMessageText(entry.text ?? "")
20445
+ : ""
20446
+ )
20447
+ .filter(Boolean)
20448
+ .join("\n")
20449
+ .trim();
20122
20450
  }
20123
20451
 
20124
20452
  function extractTitleOnlyJsonTitleFromRolloutContent(content) {