viveworker 0.8.0 → 0.8.2

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 (35) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +141 -0
  3. package/package.json +7 -1
  4. package/plugins/viveworker-control-plane/.codex-plugin/plugin.json +49 -0
  5. package/plugins/viveworker-control-plane/.mcp.json +11 -0
  6. package/plugins/viveworker-control-plane/DISTRIBUTION.md +96 -0
  7. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.png +0 -0
  8. package/plugins/viveworker-control-plane/assets/viveworker-logo-v2.svg +39 -0
  9. package/plugins/viveworker-control-plane/skills/viveworker-control-plane/SKILL.md +83 -0
  10. package/scripts/a2a-executor.mjs +261 -7
  11. package/scripts/a2a-handler.mjs +88 -0
  12. package/scripts/a2a-relay-client.mjs +6 -0
  13. package/scripts/lib/markdown-render.mjs +128 -1
  14. package/scripts/mcp-server.mjs +891 -0
  15. package/scripts/stats-cli.mjs +683 -0
  16. package/scripts/viveworker-bridge.mjs +1504 -128
  17. package/scripts/viveworker.mjs +262 -1
  18. package/skills/viveworker-control-plane/SKILL.md +104 -0
  19. package/skills/viveworker-control-plane/agents/openai.yaml +12 -0
  20. package/templates/CLAUDE.viveworker.md +67 -0
  21. package/web/app.css +162 -0
  22. package/web/app.js +1164 -101
  23. package/web/build-id.js +1 -0
  24. package/web/i18n.js +123 -15
  25. package/web/icons/apple-touch-icon.png +0 -0
  26. package/web/icons/viveworker-icon-192.png +0 -0
  27. package/web/icons/viveworker-icon-512.png +0 -0
  28. package/web/icons/viveworker-v-pulse.svg +16 -1
  29. package/web/index.html +2 -2
  30. package/web/remote-pairing/api-router.js +84 -0
  31. package/web/remote-pairing/transport.js +67 -2
  32. package/web/sw.js +16 -6
  33. package/web/icons/viveworker-beacon-v.svg +0 -19
  34. package/web/icons/viveworker-icon-1024.png +0 -0
  35. package/web/icons/viveworker-v-check.svg +0 -19
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import crypto from "node:crypto";
5
5
  import { createServer as createHttpServer } from "node:http";
6
6
  import { createServer as createHttpsServer } from "node:https";
7
- import { promises as fs, readFileSync, createReadStream } from "node:fs";
7
+ import { promises as fs, readFileSync, createReadStream, watch as watchFs } from "node:fs";
8
8
  import net from "node:net";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
@@ -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,13 +49,13 @@ 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"]);
56
57
  const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
57
- const timelineKinds = new Set([...timelineMessageKinds, "ambient_suggestions", "approval", "plan", "choice", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
58
+ const timelineKinds = new Set([...timelineMessageKinds, "ambient_suggestions", "approval", "plan", "choice", "plan_ready", "file_event", "command_event", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
58
59
  const SQLITE_COMPLETION_BATCH_SIZE = 200;
59
60
  const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
60
61
  const MAX_PAIRED_DEVICES = 200;
@@ -63,7 +64,9 @@ const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
63
64
  const REMOTE_PAIRING_RELAY_TOKEN_ROTATION_MS = 30 * 24 * 60 * 60 * 1000;
64
65
  const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
65
66
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
67
+ const DEFAULT_COMPLETION_REPLY_ACK_TIMEOUT_MS = 1200;
66
68
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
69
+ const COMPLETION_PUSH_CONTENT_DEDUPE_WINDOW_MS = 2 * 60 * 1000;
67
70
  const HAZBASE_METADATA_TIMEOUT_MS = 1500;
68
71
  const NPM_VERSION_CHECK_TIMEOUT_MS = 2500;
69
72
  const NPM_VERSION_CHECK_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
@@ -269,6 +272,15 @@ const runtime = {
269
272
  recentHistoryItems: [],
270
273
  recentTimelineEntries: [],
271
274
  recentCodeEvents: [],
275
+ timelineBus: null,
276
+ timelineRevision: 0,
277
+ timelineLiveWatchers: [],
278
+ timelineLiveScanTimer: null,
279
+ timelineLiveScanInFlight: false,
280
+ timelineLiveScanReschedule: false,
281
+ timelineLiveScanReasons: new Set(),
282
+ timelineLiveClaudeFiles: new Set(),
283
+ timelineLiveRolloutFiles: new Set(),
272
284
  pairingAttemptsByRemoteAddress: new Map(),
273
285
  ipcClient: null,
274
286
  remotePairingHandle: null,
@@ -516,6 +528,8 @@ function kindTitle(locale, kind) {
516
528
  return t(locale, "server.title.complete");
517
529
  case "file_event":
518
530
  return t(locale, "common.fileEvent");
531
+ case "command_event":
532
+ return t(locale, "common.commandEvent");
519
533
  case "diff_thread":
520
534
  return t(locale, "common.diff");
521
535
  case "a2a_task":
@@ -671,6 +685,118 @@ function formatLocalizedTitle(locale, baseKeyOrTitle, threadLabel) {
671
685
  return formatTitle(baseTitle, threadLabel);
672
686
  }
673
687
 
688
+ function localizedThreadSharePushContent(locale, { sourceLabel = "", sourceTool = "", targetLabel = "", targetTool = "", body = "" } = {}) {
689
+ const source = cleanText(sourceLabel || sourceTool || "agent");
690
+ const target = cleanText(targetLabel || targetTool || "target");
691
+ return {
692
+ title: t(locale, "server.push.threadShare.title", { source, target }),
693
+ body: pushBodySnippet(body),
694
+ };
695
+ }
696
+
697
+ function localizedThreadShareFallbackPushContent(locale, target) {
698
+ const normalizedTarget = cleanText(target || "");
699
+ return {
700
+ title: t(locale, "server.push.threadShareFallback.title"),
701
+ body: t(locale, "server.push.threadShareFallback.body", { target: normalizedTarget }),
702
+ };
703
+ }
704
+
705
+ function localizedMcpNotifyTitle(locale, title) {
706
+ const normalizedTitle = cleanText(title || "");
707
+ if (!normalizedTitle || normalizedTitle === "MCP") {
708
+ return t(locale, "server.push.mcpNotify.title");
709
+ }
710
+ return normalizedTitle;
711
+ }
712
+
713
+ function localizedMcpApprovalPushContent(locale, approval) {
714
+ const kind = cleanText(approval?.kind || "mcp");
715
+ const title = cleanText(approval?.title || "");
716
+ const body = cleanText(approval?.messageText || "");
717
+ if (kind === "question") {
718
+ return {
719
+ title: t(locale, "server.push.mcpQuestion.title"),
720
+ body: pushBodySnippet(body),
721
+ };
722
+ }
723
+ if (kind === "file_share") {
724
+ const filePath = compactPath(normalizeTimelineFileRefs(approval?.fileRefs ?? [])[0] || t(locale, "common.fileEvent"));
725
+ const sizeMatch = body.match(/^\s*Size:\s*([^\n]+)$/imu);
726
+ return {
727
+ title: t(locale, "server.push.mcpShareFile.title"),
728
+ body: t(locale, "server.push.mcpShareFile.body", {
729
+ path: filePath,
730
+ size: cleanText(sizeMatch?.[1] || ""),
731
+ }),
732
+ };
733
+ }
734
+ if (kind === "a2a_task") {
735
+ const target = cleanText(title.match(/^Send A2A task to\s+(.+)$/iu)?.[1] || "A2A target");
736
+ return {
737
+ title: t(locale, "server.push.mcpA2aTask.title", { target }),
738
+ body: t(locale, "server.push.mcpA2aTask.body", { target }),
739
+ };
740
+ }
741
+ if (!title || title === "MCP approval") {
742
+ return {
743
+ title: t(locale, "server.push.mcpApproval.title"),
744
+ body: pushBodySnippet(body),
745
+ };
746
+ }
747
+ return { title, body: pushBodySnippet(body) };
748
+ }
749
+
750
+ function localizedPaymentApprovalPushContent(locale, approval) {
751
+ const payment = isPlainObject(approval?.rawParams) ? approval.rawParams : {};
752
+ const amount = cleanText(payment.amountUsdc || payment.amountAtomic || "");
753
+ const network = cleanText(payment.network || "");
754
+ const resource = cleanText(payment.resource || payment.url || "");
755
+ const titleKey = cleanText(approval?.kind || "") === "hazbase_wallet_payment"
756
+ ? "server.push.hazbasePayment.title"
757
+ : "server.push.paymentApproval.title";
758
+ return {
759
+ title: t(locale, titleKey, { amount }),
760
+ body: t(locale, "server.push.paymentApproval.body", { amount, network, resource }),
761
+ };
762
+ }
763
+
764
+ function localizedMoltbookReplyPushContent(locale, item) {
765
+ const title = cleanText(item?.title || "");
766
+ return {
767
+ title: !title || title === "Moltbook reply" ? t(locale, "server.push.moltbookReply.title") : title,
768
+ body: cleanText(item?.summary || "") || t(locale, "server.push.moltbookReply.body"),
769
+ };
770
+ }
771
+
772
+ function localizedMoltbookDraftPushTitle(locale, { draftType = "", postTitle = "", slot = "" } = {}) {
773
+ const normalizedPostTitle = cleanText(postTitle || "");
774
+ if (draftType !== "original_post") {
775
+ return t(locale, "server.push.moltbookDraft.replyTitle", { postTitle: normalizedPostTitle });
776
+ }
777
+ switch (cleanText(slot || "")) {
778
+ case "morning":
779
+ return t(locale, "server.push.moltbookDraft.originalMorning");
780
+ case "noon":
781
+ return t(locale, "server.push.moltbookDraft.originalNoon");
782
+ case "evening":
783
+ return t(locale, "server.push.moltbookDraft.originalEvening");
784
+ default:
785
+ return normalizedPostTitle || t(locale, "server.push.moltbookDraft.originalTitle");
786
+ }
787
+ }
788
+
789
+ function localizedMoltbookPostedBody(locale, draft, finalTitle) {
790
+ if (cleanText(draft?.draftType || "") === "original_post") {
791
+ return t(locale, "server.push.moltbookDraft.postedOriginalBody", { title: cleanText(finalTitle || draft?.postTitle || "") });
792
+ }
793
+ return t(locale, "server.push.moltbookDraft.postedReplyBody", { postTitle: cleanText(draft?.postTitle || "") });
794
+ }
795
+
796
+ function pushBodySnippet(value, maxChars = 160) {
797
+ return truncate(singleLine(value || ""), maxChars);
798
+ }
799
+
674
800
  function notificationIconPrefix(kind) {
675
801
  switch (kind) {
676
802
  case "approval":
@@ -703,13 +829,17 @@ function normalizeTimelineOutcome(value) {
703
829
 
704
830
  function normalizeTimelineFileEventType(value) {
705
831
  const normalized = cleanText(value || "").toLowerCase();
706
- return ["read", "write", "create", "delete", "rename"].includes(normalized) ? normalized : "";
832
+ return ["read", "search", "command", "write", "create", "delete", "rename"].includes(normalized) ? normalized : "";
707
833
  }
708
834
 
709
835
  function fileEventTitle(locale, fileEventType) {
710
836
  switch (normalizeTimelineFileEventType(fileEventType)) {
711
837
  case "read":
712
838
  return t(locale, "fileEvent.read");
839
+ case "search":
840
+ return t(locale, "fileEvent.search");
841
+ case "command":
842
+ return t(locale, "fileEvent.command");
713
843
  case "write":
714
844
  return t(locale, "fileEvent.write");
715
845
  case "create":
@@ -728,6 +858,10 @@ function fileEventDetailCopy(locale, fileEventType, provider) {
728
858
  switch (normalizeTimelineFileEventType(fileEventType)) {
729
859
  case "read":
730
860
  return t(locale, "detail.fileEvent.read", vars);
861
+ case "search":
862
+ return t(locale, "detail.fileEvent.search", vars);
863
+ case "command":
864
+ return t(locale, "detail.fileEvent.command", vars);
731
865
  case "write":
732
866
  return t(locale, "detail.fileEvent.write", vars);
733
867
  case "create":
@@ -1435,6 +1569,87 @@ function extractCommandLineFromFunctionOutput(outputText) {
1435
1569
  return unwrapShellCommand(match?.[1] || "");
1436
1570
  }
1437
1571
 
1572
+ function parseToolArgumentsJson(value) {
1573
+ if (isPlainObject(value)) {
1574
+ return value;
1575
+ }
1576
+ if (typeof value !== "string" || !value.trim()) {
1577
+ return null;
1578
+ }
1579
+ const parsed = safeJsonParse(value);
1580
+ return isPlainObject(parsed) ? parsed : null;
1581
+ }
1582
+
1583
+ function commandBaseName(command) {
1584
+ const normalized = cleanText(command || "");
1585
+ if (!normalized) {
1586
+ return "";
1587
+ }
1588
+ return path.basename(normalized);
1589
+ }
1590
+
1591
+ function classifyTimelineCommand(commandText) {
1592
+ const normalizedCommand = unwrapShellCommand(commandText);
1593
+ const tokens = tokenizeShellWords(normalizedCommand);
1594
+ if (tokens.length === 0) {
1595
+ return {
1596
+ fileEventType: "command",
1597
+ command: "",
1598
+ commandText: normalizedCommand,
1599
+ fileRefs: [],
1600
+ };
1601
+ }
1602
+
1603
+ const command = commandBaseName(tokens[0]);
1604
+ const gitSubcommand = command === "git" ? cleanText(tokens[1] || "") : "";
1605
+ let fileEventType = "command";
1606
+ if (["rg", "grep", "ag", "ack", "fd", "find"].includes(command) || gitSubcommand === "grep") {
1607
+ fileEventType = "search";
1608
+ } else if (
1609
+ ["cat", "sed", "nl", "head", "tail", "wc", "ls", "pwd", "less", "more"].includes(command) ||
1610
+ (command === "git" && ["status", "diff", "show", "log", "ls-files", "branch"].includes(gitSubcommand))
1611
+ ) {
1612
+ fileEventType = "read";
1613
+ }
1614
+
1615
+ return {
1616
+ fileEventType,
1617
+ command,
1618
+ commandText: normalizedCommand,
1619
+ fileRefs: extractReadFileRefsFromCommand(normalizedCommand),
1620
+ };
1621
+ }
1622
+
1623
+ function redactTimelineCommandText(commandText) {
1624
+ return cleanText(commandText || "")
1625
+ .replace(/((?:--)?(?:api[-_]?key|token|secret|password|credential)(?:=|\s+))(["']?)[^\s"']+\2/giu, "$1[redacted]")
1626
+ .replace(/\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*=)(["']?)[^\s"']+\2/giu, "$1[redacted]");
1627
+ }
1628
+
1629
+ function timelineCommandMessage(locale, { commandText = "", toolName = "", fileRefs = [] } = {}) {
1630
+ const commandBlock = commandText
1631
+ ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${redactTimelineCommandText(commandText)}\n\`\`\``
1632
+ : "";
1633
+ const toolBlock = !commandBlock && toolName
1634
+ ? `${t(locale, "server.message.toolLabel")}\n\`\`\`text\n${cleanText(toolName)}\n\`\`\``
1635
+ : "";
1636
+ const files = normalizeTimelineFileRefs(fileRefs);
1637
+ const filesBlock = files.length
1638
+ ? `${t(locale, "server.message.filesLabel")}\n\`\`\`text\n${files.join("\n")}\n\`\`\``
1639
+ : "";
1640
+ return [commandBlock || toolBlock, filesBlock].filter(Boolean).join("\n\n");
1641
+ }
1642
+
1643
+ function timelineCommandSummary(commandText, fallback = "") {
1644
+ const redacted = redactTimelineCommandText(commandText);
1645
+ return truncate(singleLine(redacted || fallback || ""), 180);
1646
+ }
1647
+
1648
+ function firstMarkdownCodeFenceText(text) {
1649
+ const match = String(text || "").match(/```(?:\w+)?\n([\s\S]*?)\n```/u);
1650
+ return cleanText(match?.[1] || "");
1651
+ }
1652
+
1438
1653
  function extractReadFileRefsFromCommand(commandText) {
1439
1654
  const normalizedCommand = unwrapShellCommand(commandText);
1440
1655
  const tokens = tokenizeShellWords(normalizedCommand);
@@ -1663,6 +1878,36 @@ function rememberApplyPatchInput(fileState, payload, createdAtMs = Date.now()) {
1663
1878
  }
1664
1879
  }
1665
1880
 
1881
+ function rememberToolEventInput(fileState, payload, createdAtMs = Date.now()) {
1882
+ const callId = cleanText(payload?.call_id || payload?.callId || payload?.id || "");
1883
+ if (!callId) {
1884
+ return null;
1885
+ }
1886
+ if (!(fileState.toolEventInputsByCallId instanceof Map)) {
1887
+ fileState.toolEventInputsByCallId = new Map();
1888
+ }
1889
+ const stored = {
1890
+ callId,
1891
+ name: cleanText(payload?.name || ""),
1892
+ arguments: parseToolArgumentsJson(payload?.arguments),
1893
+ createdAtMs: Number(createdAtMs) || Date.now(),
1894
+ };
1895
+ fileState.toolEventInputsByCallId.set(callId, stored);
1896
+ while (fileState.toolEventInputsByCallId.size > 128) {
1897
+ const oldestKey = fileState.toolEventInputsByCallId.keys().next().value;
1898
+ fileState.toolEventInputsByCallId.delete(oldestKey);
1899
+ }
1900
+ return stored;
1901
+ }
1902
+
1903
+ function findStoredToolEventInput(fileState, callId) {
1904
+ const normalizedCallId = cleanText(callId || "");
1905
+ if (!normalizedCallId || !(fileState?.toolEventInputsByCallId instanceof Map)) {
1906
+ return null;
1907
+ }
1908
+ return fileState.toolEventInputsByCallId.get(normalizedCallId) || null;
1909
+ }
1910
+
1666
1911
  async function findStoredApplyPatchInput({ fileState, callId, rolloutFilePath }) {
1667
1912
  const normalizedCallId = cleanText(callId || "");
1668
1913
  if (!normalizedCallId) {
@@ -2067,6 +2312,7 @@ async function ensureRolloutFileState(runtime, threadId, rolloutFilePath) {
2067
2312
  threadId: cleanText(threadId || ""),
2068
2313
  cwd: await findRolloutThreadCwd(runtime, threadId || ""),
2069
2314
  applyPatchInputsByCallId: new Map(),
2315
+ toolEventInputsByCallId: new Map(),
2070
2316
  startupCutoffMs: 0,
2071
2317
  skipPartialLine: false,
2072
2318
  };
@@ -2839,6 +3085,7 @@ function normalizeProvider(value) {
2839
3085
  if (normalized === "moltbook") return "moltbook";
2840
3086
  if (normalized === "a2a") return "a2a";
2841
3087
  if (normalized === "viveworker") return "viveworker";
3088
+ if (normalized === "mcp") return "mcp";
2842
3089
  return "codex";
2843
3090
  }
2844
3091
 
@@ -2848,6 +3095,7 @@ function providerDisplayName(locale, provider) {
2848
3095
  if (p === "moltbook") return "Moltbook";
2849
3096
  if (p === "a2a") return "A2A";
2850
3097
  if (p === "viveworker") return t(locale, "common.appName");
3098
+ if (p === "mcp") return "MCP";
2851
3099
  return t(locale, "common.codex");
2852
3100
  }
2853
3101
 
@@ -2896,7 +3144,7 @@ function normalizeHistoryItem(raw) {
2896
3144
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
2897
3145
  const rawThreadLabel = cleanText(raw.threadLabel ?? "");
2898
3146
  const historyProvider = normalizeProvider(raw.provider);
2899
- const skipHistoryThreadLabelRewrite = historyProvider === "moltbook" || historyProvider === "a2a" || historyProvider === "viveworker"
3147
+ const skipHistoryThreadLabelRewrite = historyProvider === "moltbook" || historyProvider === "a2a" || historyProvider === "viveworker" || historyProvider === "mcp"
2900
3148
  || kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
2901
3149
  const threadLabel = skipHistoryThreadLabelRewrite
2902
3150
  ? rawThreadLabel
@@ -3138,6 +3386,8 @@ function timelineKindSortPriority(kind) {
3138
3386
  return 60;
3139
3387
  case "ambient_suggestions":
3140
3388
  return 52;
3389
+ case "command_event":
3390
+ return 46;
3141
3391
  case "file_event":
3142
3392
  return 45;
3143
3393
  case "assistant_final":
@@ -3160,7 +3410,9 @@ function normalizeTimelineEntry(raw) {
3160
3410
  }
3161
3411
 
3162
3412
  const stableId = cleanText(raw.stableId ?? raw.id ?? "");
3163
- const kind = cleanText(raw.kind ?? "");
3413
+ const rawKind = cleanText(raw.kind ?? "");
3414
+ const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
3415
+ const kind = rawKind === "file_event" && fileEventType === "command" ? "command_event" : rawKind;
3164
3416
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
3165
3417
  if (!stableId || !timelineKinds.has(kind)) {
3166
3418
  return null;
@@ -3169,7 +3421,6 @@ function normalizeTimelineEntry(raw) {
3169
3421
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
3170
3422
  const rawMessageText = raw.messageText ?? "";
3171
3423
  const messageText = normalizeTimelineMessageText(rawMessageText);
3172
- const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
3173
3424
  const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
3174
3425
  const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
3175
3426
  const diffCounts = diffLineCounts(diffText);
@@ -3183,7 +3434,7 @@ function normalizeTimelineEntry(raw) {
3183
3434
  (kind === "file_event" ? "" : cleanText(raw.title ?? "")) ||
3184
3435
  "";
3185
3436
  const rawProvider = normalizeProvider(raw.provider);
3186
- const skipThreadLabelRewrite = rawProvider === "moltbook" || rawProvider === "a2a" || rawProvider === "viveworker"
3437
+ const skipThreadLabelRewrite = rawProvider === "moltbook" || rawProvider === "a2a" || rawProvider === "viveworker" || rawProvider === "mcp"
3187
3438
  || kind === "moltbook_reply" || kind === "moltbook_draft" || kind === "a2a_task" || kind === "a2a_task_result" || kind === "thread_share";
3188
3439
  const threadLabel =
3189
3440
  skipThreadLabelRewrite
@@ -3261,7 +3512,78 @@ function normalizeTimelineEntry(raw) {
3261
3512
  return shouldHideInternalTimelineItem(normalized) ? null : normalized;
3262
3513
  }
3263
3514
 
3264
- function recordTimelineEntry({ config, runtime, state, entry }) {
3515
+ class TimelineBus {
3516
+ constructor() {
3517
+ this.clients = new Set();
3518
+ }
3519
+
3520
+ addClient(client) {
3521
+ this.clients.add(client);
3522
+ return () => {
3523
+ this.clients.delete(client);
3524
+ };
3525
+ }
3526
+
3527
+ emit(eventName, payload) {
3528
+ for (const client of [...this.clients]) {
3529
+ try {
3530
+ client.send(eventName, payload);
3531
+ } catch {
3532
+ try {
3533
+ client.close?.();
3534
+ } catch {}
3535
+ this.clients.delete(client);
3536
+ }
3537
+ }
3538
+ }
3539
+
3540
+ closeAll() {
3541
+ for (const client of [...this.clients]) {
3542
+ try {
3543
+ client.close?.();
3544
+ } catch {}
3545
+ }
3546
+ this.clients.clear();
3547
+ }
3548
+ }
3549
+
3550
+ function ensureTimelineBus(runtime) {
3551
+ if (!runtime.timelineBus) {
3552
+ runtime.timelineBus = new TimelineBus();
3553
+ }
3554
+ return runtime.timelineBus;
3555
+ }
3556
+
3557
+ function buildTimelineUpdatePayload({ runtime, entry, source = "unknown" }) {
3558
+ runtime.timelineRevision = (Number(runtime.timelineRevision) || 0) + 1;
3559
+ return {
3560
+ revision: runtime.timelineRevision,
3561
+ token: cleanText(entry?.token || historyToken(entry?.stableId || entry?.createdAtMs || "")).slice(0, 120),
3562
+ stableId: cleanText(entry?.stableId || "").slice(0, 180),
3563
+ kind: cleanText(entry?.kind || "").slice(0, 80),
3564
+ provider: cleanText(entry?.provider || "").slice(0, 40),
3565
+ threadId: cleanText(entry?.threadId || "").slice(0, 120),
3566
+ createdAtMs: Number(entry?.createdAtMs) || 0,
3567
+ source: cleanText(source || "unknown").slice(0, 80),
3568
+ ingestAtMs: Date.now(),
3569
+ };
3570
+ }
3571
+
3572
+ function publishTimelineUpdate({ config, runtime, entry, source = "unknown" }) {
3573
+ if (!config?.timelineLiveSync || !runtime || !entry) {
3574
+ return;
3575
+ }
3576
+ const payload = buildTimelineUpdatePayload({ runtime, entry, source });
3577
+ console.log(
3578
+ `[timeline-ingest] at=${new Date(payload.ingestAtMs).toISOString()} ` +
3579
+ `provider=${payload.provider || "unknown"} kind=${payload.kind || "unknown"} ` +
3580
+ `token=${payload.token || "none"} createdAtMs=${payload.createdAtMs || 0} ` +
3581
+ `source=${payload.source || "unknown"} revision=${payload.revision}`
3582
+ );
3583
+ ensureTimelineBus(runtime).emit("timeline:update", payload);
3584
+ }
3585
+
3586
+ function recordTimelineEntry({ config, runtime, state, entry, source = "unknown" }) {
3265
3587
  const normalized = normalizeTimelineEntry(entry);
3266
3588
  if (!normalized) {
3267
3589
  return false;
@@ -3278,6 +3600,10 @@ function recordTimelineEntry({ config, runtime, state, entry }) {
3278
3600
  const changed = timelineProjectionChanged(nextItems, runtime.recentTimelineEntries, [
3279
3601
  "stableId",
3280
3602
  "title",
3603
+ "summary",
3604
+ "messageText",
3605
+ "fileEventType",
3606
+ "fileRefs",
3281
3607
  "createdAtMs",
3282
3608
  "diffAvailable",
3283
3609
  "diffSource",
@@ -3288,6 +3614,9 @@ function recordTimelineEntry({ config, runtime, state, entry }) {
3288
3614
  ]);
3289
3615
  runtime.recentTimelineEntries = nextItems;
3290
3616
  state.recentTimelineEntries = nextItems;
3617
+ if (changed) {
3618
+ publishTimelineUpdate({ config, runtime, entry: normalized, source });
3619
+ }
3291
3620
  return changed;
3292
3621
  }
3293
3622
 
@@ -3408,6 +3737,18 @@ function historyItemFromEvent(event) {
3408
3737
  });
3409
3738
  }
3410
3739
 
3740
+ function completionPushContentDedupeId(event) {
3741
+ if (!event || event.kind !== "task_complete") {
3742
+ return "";
3743
+ }
3744
+ const threadId = cleanText(event.threadId ?? event.conversationId ?? "unknown") || "unknown";
3745
+ const messageText = normalizeLongText(event.detailText || event.message || "");
3746
+ if (!messageText) {
3747
+ return "";
3748
+ }
3749
+ return `task_complete_content:${threadId}:${historyToken(messageText)}`;
3750
+ }
3751
+
3411
3752
  function recordHistoryItem({ config, runtime, state, item }) {
3412
3753
  const normalized = normalizeHistoryItem(item);
3413
3754
  if (!normalized) {
@@ -3570,6 +3911,7 @@ function recordActionHistoryItem({
3570
3911
  runtime,
3571
3912
  state,
3572
3913
  entry: item,
3914
+ source: "event",
3573
3915
  });
3574
3916
  return historyChanged || timelineChanged;
3575
3917
  }
@@ -3729,7 +4071,20 @@ function pushDeliveryKey(deviceId, stableId) {
3729
4071
  return `${cleanText(deviceId || "")}:${cleanText(stableId || "")}`;
3730
4072
  }
3731
4073
 
3732
- async function deliverWebPushItem({ config, state, kind, token, stableId, title, body, tab = "", subtab = "", buildLocalizedContent = null }) {
4074
+ async function deliverWebPushItem({
4075
+ config,
4076
+ state,
4077
+ kind,
4078
+ token,
4079
+ stableId,
4080
+ title,
4081
+ body,
4082
+ tab = "",
4083
+ subtab = "",
4084
+ buildLocalizedContent = null,
4085
+ dedupeId = "",
4086
+ dedupeWindowMs = 0,
4087
+ }) {
3733
4088
  if (!config.webPushEnabled || config.dryRun) {
3734
4089
  return false;
3735
4090
  }
@@ -3746,8 +4101,18 @@ async function deliverWebPushItem({ config, state, kind, token, stableId, title,
3746
4101
  let changed = false;
3747
4102
 
3748
4103
  for (const subscription of subscriptions) {
3749
- const deliveryKey = pushDeliveryKey(subscription.deviceId, stableId);
3750
- if (state.pushDeliveries[deliveryKey]) {
4104
+ const now = Date.now();
4105
+ const stableDeliveryKey = pushDeliveryKey(subscription.deviceId, stableId);
4106
+ const dedupeDeliveryKey = pushDeliveryKey(subscription.deviceId, dedupeId || stableId);
4107
+ const stableDeliveredAt = Number(state.pushDeliveries[stableDeliveryKey]) || 0;
4108
+ const dedupeDeliveredAt = Number(state.pushDeliveries[dedupeDeliveryKey]) || 0;
4109
+ if (stableDeliveredAt) {
4110
+ continue;
4111
+ }
4112
+ if (
4113
+ dedupeDeliveredAt &&
4114
+ (!dedupeWindowMs || now - dedupeDeliveredAt <= Math.max(0, Number(dedupeWindowMs) || 0))
4115
+ ) {
3751
4116
  continue;
3752
4117
  }
3753
4118
 
@@ -3775,8 +4140,10 @@ async function deliverWebPushItem({ config, state, kind, token, stableId, title,
3775
4140
  },
3776
4141
  payload
3777
4142
  );
3778
- const now = Date.now();
3779
- state.pushDeliveries[deliveryKey] = now;
4143
+ state.pushDeliveries[stableDeliveryKey] = now;
4144
+ if (dedupeDeliveryKey !== stableDeliveryKey) {
4145
+ state.pushDeliveries[dedupeDeliveryKey] = now;
4146
+ }
3780
4147
  trimSeenEvents(state.pushDeliveries, config.maxSeenEvents * 4);
3781
4148
  const stored = normalizePushSubscriptionRecord(state.pushSubscriptions?.[subscription.id]);
3782
4149
  if (stored) {
@@ -3928,6 +4295,207 @@ async function scanOnce({ config, runtime, state }) {
3928
4295
  return dirty;
3929
4296
  }
3930
4297
 
4298
+ function isPathWithin(parentDir, candidatePath) {
4299
+ const parent = path.resolve(parentDir || "");
4300
+ const candidate = path.resolve(candidatePath || "");
4301
+ if (!parent || !candidate) return false;
4302
+ return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
4303
+ }
4304
+
4305
+ function isClaudeTranscriptPath(config, filePath) {
4306
+ return Boolean(
4307
+ filePath &&
4308
+ filePath.endsWith(".jsonl") &&
4309
+ isPathWithin(config.claudeProjectsDir, filePath)
4310
+ );
4311
+ }
4312
+
4313
+ function isRolloutFilePath(filePath) {
4314
+ const base = path.basename(filePath || "");
4315
+ return base.startsWith("rollout-") && base.endsWith(".jsonl");
4316
+ }
4317
+
4318
+ function watchedEventPath(root, filename) {
4319
+ if (!filename) return root;
4320
+ const text = Buffer.isBuffer(filename) ? filename.toString("utf8") : String(filename);
4321
+ if (!text) return root;
4322
+ return path.isAbsolute(text) ? text : path.join(root, text);
4323
+ }
4324
+
4325
+ function addTimelineLiveWatcher({ runtime, label, targetPath, options = {}, onChange }) {
4326
+ if (!targetPath) return;
4327
+ try {
4328
+ const watcher = watchFs(targetPath, options, (_eventType, filename) => {
4329
+ onChange(watchedEventPath(targetPath, filename));
4330
+ });
4331
+ watcher.on?.("error", (err) => {
4332
+ console.warn(`[timeline-live-watch-error] ${label} ${err?.message || err}`);
4333
+ });
4334
+ runtime.timelineLiveWatchers.push(watcher);
4335
+ console.log(`[timeline-live-watch] ${label} ${targetPath}`);
4336
+ } catch (err) {
4337
+ console.warn(`[timeline-live-watch-skip] ${label} ${targetPath} ${err?.message || err}`);
4338
+ }
4339
+ }
4340
+
4341
+ function scheduleTimelineLiveScan({ config, runtime, state, reason = "unknown", filePath = "" }) {
4342
+ if (!config.timelineLiveSync || runtime.stopping) {
4343
+ return;
4344
+ }
4345
+ runtime.timelineLiveScanReasons.add(cleanText(reason || "unknown").slice(0, 80));
4346
+ const normalizedPath = cleanText(filePath || "");
4347
+ if (isClaudeTranscriptPath(config, normalizedPath)) {
4348
+ runtime.timelineLiveClaudeFiles.add(normalizedPath);
4349
+ } else if (isRolloutFilePath(normalizedPath)) {
4350
+ runtime.timelineLiveRolloutFiles.add(normalizedPath);
4351
+ }
4352
+
4353
+ if (runtime.timelineLiveScanTimer) {
4354
+ return;
4355
+ }
4356
+ runtime.timelineLiveScanTimer = setTimeout(() => {
4357
+ runtime.timelineLiveScanTimer = null;
4358
+ runPendingTimelineLiveScan({ config, runtime, state }).catch((err) => {
4359
+ console.warn(`[timeline-live-scan-error] ${err?.message || err}`);
4360
+ });
4361
+ }, Math.max(25, Number(config.timelineLiveDebounceMs) || 150));
4362
+ runtime.timelineLiveScanTimer.unref?.();
4363
+ }
4364
+
4365
+ async function runPendingTimelineLiveScan({ config, runtime, state }) {
4366
+ if (runtime.timelineLiveScanInFlight) {
4367
+ runtime.timelineLiveScanReschedule = true;
4368
+ return;
4369
+ }
4370
+ runtime.timelineLiveScanInFlight = true;
4371
+ const reasons = new Set(runtime.timelineLiveScanReasons);
4372
+ const claudeFiles = [...runtime.timelineLiveClaudeFiles];
4373
+ const rolloutFiles = [...runtime.timelineLiveRolloutFiles];
4374
+ runtime.timelineLiveScanReasons.clear();
4375
+ runtime.timelineLiveClaudeFiles.clear();
4376
+ runtime.timelineLiveRolloutFiles.clear();
4377
+
4378
+ let dirty = false;
4379
+ const now = Date.now();
4380
+ try {
4381
+ if (reasons.has("codex-home") || reasons.has("codex-history") || reasons.has("codex-logs")) {
4382
+ if (!config.codexLogsDbFile) {
4383
+ const latestLogsDbFile = await findLatestCodexLogsDbFile(config.codexHome);
4384
+ if (latestLogsDbFile && latestLogsDbFile !== runtime.logsDbFile) {
4385
+ runtime.logsDbFile = latestLogsDbFile;
4386
+ }
4387
+ }
4388
+ dirty = (await processHistoryTimelineFile({ config, runtime, state, now })) || dirty;
4389
+ if (config.notifyCompletions || config.webUiEnabled) {
4390
+ dirty = (await processSqliteCompletionLog({ config, runtime, state, now })) || dirty;
4391
+ }
4392
+ if (config.webUiEnabled) {
4393
+ dirty = (await processSqliteTimelineLog({ config, runtime, state, now })) || dirty;
4394
+ }
4395
+ }
4396
+
4397
+ for (const filePath of rolloutFiles.slice(0, 12)) {
4398
+ if (!runtime.knownFiles.includes(filePath)) {
4399
+ runtime.knownFiles.push(filePath);
4400
+ runtime.knownFiles.sort();
4401
+ }
4402
+ dirty = (await processRolloutFile({ filePath, config, runtime, state, now })) || dirty;
4403
+ }
4404
+
4405
+ let claudeTranscriptChanged = false;
4406
+ let claudeTargets = claudeFiles;
4407
+ if (reasons.has("claude-dir") && claudeTargets.length === 0) {
4408
+ claudeTargets = await listClaudeTranscriptFiles(config.claudeProjectsDir, config.claudeTranscriptMaxAgeMs);
4409
+ }
4410
+ for (const filePath of claudeTargets.slice(0, 24)) {
4411
+ if (!runtime.claudeKnownFiles.includes(filePath)) {
4412
+ runtime.claudeKnownFiles.push(filePath);
4413
+ }
4414
+ const changed = await processClaudeTranscriptFile({ filePath, config, runtime, state, now });
4415
+ claudeTranscriptChanged = claudeTranscriptChanged || changed;
4416
+ dirty = dirty || changed;
4417
+ }
4418
+ if (claudeTranscriptChanged) {
4419
+ dirty = refreshResolvedThreadLabels({ config, runtime, state }) || dirty;
4420
+ }
4421
+
4422
+ if (dirty) {
4423
+ scheduleSaveState(config, state);
4424
+ }
4425
+ } finally {
4426
+ runtime.timelineLiveScanInFlight = false;
4427
+ if (runtime.timelineLiveScanReschedule || runtime.timelineLiveScanReasons.size > 0) {
4428
+ runtime.timelineLiveScanReschedule = false;
4429
+ scheduleTimelineLiveScan({ config, runtime, state, reason: "reschedule" });
4430
+ }
4431
+ }
4432
+ }
4433
+
4434
+ function startTimelineLiveSync({ config, runtime, state }) {
4435
+ if (config.dryRun || !config.webUiEnabled || !config.timelineLiveSync) {
4436
+ return null;
4437
+ }
4438
+
4439
+ addTimelineLiveWatcher({
4440
+ runtime,
4441
+ label: "codex-home",
4442
+ targetPath: config.codexHome,
4443
+ onChange: (filePath) => {
4444
+ const base = path.basename(filePath || "");
4445
+ if (filePath === config.historyFile || base === path.basename(config.historyFile || "")) {
4446
+ scheduleTimelineLiveScan({ config, runtime, state, reason: "codex-history", filePath });
4447
+ return;
4448
+ }
4449
+ if (/^logs(?:_\d+)?\.sqlite(?:-wal|-shm)?$/u.test(base)) {
4450
+ scheduleTimelineLiveScan({ config, runtime, state, reason: "codex-logs", filePath });
4451
+ }
4452
+ },
4453
+ });
4454
+
4455
+ addTimelineLiveWatcher({
4456
+ runtime,
4457
+ label: "codex-sessions",
4458
+ targetPath: config.sessionsDir,
4459
+ options: { recursive: true },
4460
+ onChange: (filePath) => {
4461
+ if (isRolloutFilePath(filePath)) {
4462
+ scheduleTimelineLiveScan({ config, runtime, state, reason: "codex-rollout", filePath });
4463
+ }
4464
+ },
4465
+ });
4466
+
4467
+ addTimelineLiveWatcher({
4468
+ runtime,
4469
+ label: "claude-projects",
4470
+ targetPath: config.claudeProjectsDir,
4471
+ options: { recursive: true },
4472
+ onChange: (filePath) => {
4473
+ scheduleTimelineLiveScan({
4474
+ config,
4475
+ runtime,
4476
+ state,
4477
+ reason: isClaudeTranscriptPath(config, filePath) ? "claude-file" : "claude-dir",
4478
+ filePath,
4479
+ });
4480
+ },
4481
+ });
4482
+
4483
+ return {
4484
+ close() {
4485
+ if (runtime.timelineLiveScanTimer) {
4486
+ clearTimeout(runtime.timelineLiveScanTimer);
4487
+ runtime.timelineLiveScanTimer = null;
4488
+ }
4489
+ for (const watcher of runtime.timelineLiveWatchers.splice(0)) {
4490
+ try {
4491
+ watcher.close();
4492
+ } catch {}
4493
+ }
4494
+ runtime.timelineBus?.closeAll?.();
4495
+ },
4496
+ };
4497
+ }
4498
+
3931
4499
  async function processRolloutFile({ filePath, config, runtime, state, now }) {
3932
4500
  let stat;
3933
4501
  try {
@@ -3951,6 +4519,7 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
3951
4519
  threadId: extractThreadIdFromRolloutPath(filePath),
3952
4520
  cwd: null,
3953
4521
  applyPatchInputsByCallId: new Map(),
4522
+ toolEventInputsByCallId: new Map(),
3954
4523
  startupCutoffMs:
3955
4524
  typeof restoredOffset === "number" ? 0 : now - config.replaySeconds * 1000,
3956
4525
  skipPartialLine:
@@ -4028,6 +4597,7 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
4028
4597
  runtime,
4029
4598
  state,
4030
4599
  entry: timelineEntry,
4600
+ source: "codex-rollout",
4031
4601
  }) || dirty;
4032
4602
  }
4033
4603
 
@@ -4045,6 +4615,7 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
4045
4615
  runtime,
4046
4616
  state,
4047
4617
  entry: fileTimelineEntry,
4618
+ source: "codex-rollout",
4048
4619
  }) || dirty;
4049
4620
  dirty =
4050
4621
  recordCodeEvent({
@@ -4231,6 +4802,109 @@ async function listClaudeTranscriptFiles(claudeProjectsDir, maxAgeMs = 0) {
4231
4802
  }
4232
4803
  }
4233
4804
 
4805
+ function buildClaudeToolTimelineEntries({ content, threadId, threadLabel, uuid, createdAtMs, cwd }) {
4806
+ if (!Array.isArray(content) || !threadId) {
4807
+ return [];
4808
+ }
4809
+
4810
+ const entries = [];
4811
+ for (const block of content) {
4812
+ if (!isPlainObject(block) || block.type !== "tool_use") {
4813
+ continue;
4814
+ }
4815
+ const toolName = cleanText(block.name || "");
4816
+ const input = isPlainObject(block.input) ? block.input : {};
4817
+ const toolId = cleanText(block.id || block.tool_use_id || "") || historyToken(JSON.stringify(block));
4818
+ const callId = `${uuid || createdAtMs}:${toolId}`;
4819
+ const lowerToolName = toolName.toLowerCase();
4820
+
4821
+ if (lowerToolName === "bash") {
4822
+ const commandText = cleanText(input.command || "");
4823
+ if (!commandText) {
4824
+ continue;
4825
+ }
4826
+ const classified = classifyTimelineCommand(commandText);
4827
+ entries.push(
4828
+ buildToolTimelineEntry({
4829
+ provider: "claude",
4830
+ threadId,
4831
+ threadLabel,
4832
+ callId,
4833
+ createdAtMs,
4834
+ fileEventType: classified.fileEventType,
4835
+ commandText: classified.commandText || commandText,
4836
+ fileRefs: classified.fileRefs,
4837
+ cwd,
4838
+ })
4839
+ );
4840
+ continue;
4841
+ }
4842
+
4843
+ if (["write", "edit", "multiedit", "todowrite", "exitplanmode", "askuserquestion"].includes(lowerToolName)) {
4844
+ continue;
4845
+ }
4846
+
4847
+ if (lowerToolName === "read") {
4848
+ const fileRefs = normalizeTimelineFileRefs([input.file_path || input.path || input.filePath].filter(Boolean));
4849
+ entries.push(
4850
+ buildToolTimelineEntry({
4851
+ provider: "claude",
4852
+ threadId,
4853
+ threadLabel,
4854
+ callId,
4855
+ createdAtMs,
4856
+ fileEventType: "read",
4857
+ toolName,
4858
+ summaryText: fileRefs[0] ? `${toolName}: ${fileRefs[0]}` : toolName,
4859
+ fileRefs,
4860
+ cwd,
4861
+ })
4862
+ );
4863
+ continue;
4864
+ }
4865
+
4866
+ if (["grep", "glob", "websearch", "webfetch"].includes(lowerToolName)) {
4867
+ const query = cleanText(input.pattern || input.query || input.url || "");
4868
+ const fileRefs = normalizeTimelineFileRefs([input.path || input.file_path || input.filePath].filter(Boolean));
4869
+ entries.push(
4870
+ buildToolTimelineEntry({
4871
+ provider: "claude",
4872
+ threadId,
4873
+ threadLabel,
4874
+ callId,
4875
+ createdAtMs,
4876
+ fileEventType: "search",
4877
+ toolName,
4878
+ summaryText: query ? `${toolName}: ${query}` : toolName,
4879
+ fileRefs,
4880
+ cwd,
4881
+ })
4882
+ );
4883
+ continue;
4884
+ }
4885
+
4886
+ if (lowerToolName === "ls") {
4887
+ const fileRefs = normalizeTimelineFileRefs([input.path].filter(Boolean));
4888
+ entries.push(
4889
+ buildToolTimelineEntry({
4890
+ provider: "claude",
4891
+ threadId,
4892
+ threadLabel,
4893
+ callId,
4894
+ createdAtMs,
4895
+ fileEventType: "read",
4896
+ toolName,
4897
+ summaryText: fileRefs[0] ? `${toolName}: ${fileRefs[0]}` : toolName,
4898
+ fileRefs,
4899
+ cwd,
4900
+ })
4901
+ );
4902
+ }
4903
+ }
4904
+
4905
+ return entries.filter(Boolean);
4906
+ }
4907
+
4234
4908
  async function processClaudeTranscriptFile({ filePath, config, runtime, state, now }) {
4235
4909
  let fileState = runtime.claudeFileStates.get(filePath);
4236
4910
  if (!fileState) {
@@ -4346,6 +5020,20 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4346
5020
  const claudeTitle = threadId ? runtime.claudeSessionTitles.get(threadId) || "" : "";
4347
5021
  const threadLabel = claudeTitle || fileState.threadLabel || "";
4348
5022
 
5023
+ if (type === "assistant" && Array.isArray(content)) {
5024
+ const toolEntries = buildClaudeToolTimelineEntries({
5025
+ content,
5026
+ threadId,
5027
+ threadLabel,
5028
+ uuid,
5029
+ createdAtMs,
5030
+ cwd: fileState.cwd,
5031
+ });
5032
+ for (const toolEntry of toolEntries) {
5033
+ dirty = recordTimelineEntry({ config, runtime, state, entry: toolEntry, source: "claude-tool" }) || dirty;
5034
+ }
5035
+ }
5036
+
4349
5037
  let text = "";
4350
5038
  if (typeof content === "string") {
4351
5039
  text = content;
@@ -4354,7 +5042,10 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4354
5042
  if (block?.type === "text" && block.text) text += block.text;
4355
5043
  }
4356
5044
  }
4357
- text = cleanText(text);
5045
+ // Preserve paragraph structure for the renderer — `cleanText` collapses
5046
+ // every \s+ (newlines included) into a single space, which would flatten
5047
+ // markdown tables / code fences / lists into a single illegible line.
5048
+ text = normalizeLongText(text);
4358
5049
  if (!text) continue;
4359
5050
  if (type === "user" && record.entrypoint === "sdk-cli") {
4360
5051
  const derivedThreadLabel = deriveClaudeSdkCliThreadLabel(text, threadId);
@@ -4398,7 +5089,7 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4398
5089
  provider: "claude",
4399
5090
  });
4400
5091
  if (entry) {
4401
- dirty = recordTimelineEntry({ config, runtime, state, entry }) || dirty;
5092
+ dirty = recordTimelineEntry({ config, runtime, state, entry, source: "claude-transcript" }) || dirty;
4402
5093
 
4403
5094
  // Also record Claude assistant_final as a history item for the completed list
4404
5095
  if (kind === "assistant_final") {
@@ -4707,6 +5398,7 @@ async function processSqliteTimelineLog({ config, runtime, state, now }) {
4707
5398
  runtime,
4708
5399
  state,
4709
5400
  entry,
5401
+ source: "codex-sqlite",
4710
5402
  }) || dirty;
4711
5403
 
4712
5404
  // Also record Codex assistant_final as a history item so it appears
@@ -4858,6 +5550,7 @@ async function processHistoryTimelineFile({ config, runtime, state, now }) {
4858
5550
  runtime,
4859
5551
  state,
4860
5552
  entry,
5553
+ source: "codex-history",
4861
5554
  }) || dirty;
4862
5555
  }
4863
5556
 
@@ -5032,6 +5725,7 @@ async function backfillRecentTimelineEntryDiffs({ config, runtime, state }) {
5032
5725
  threadId: cleanText(entry?.threadId || ""),
5033
5726
  cwd: await findRolloutThreadCwd(runtime, entry?.threadId || ""),
5034
5727
  applyPatchInputsByCallId: new Map(),
5728
+ toolEventInputsByCallId: new Map(),
5035
5729
  startupCutoffMs: 0,
5036
5730
  skipPartialLine: false,
5037
5731
  };
@@ -5604,6 +6298,47 @@ function buildRolloutUserTimelineEntry({ record, fileState, runtime }) {
5604
6298
  });
5605
6299
  }
5606
6300
 
6301
+ function buildToolTimelineEntry({
6302
+ provider,
6303
+ threadId,
6304
+ threadLabel,
6305
+ callId,
6306
+ createdAtMs,
6307
+ fileEventType,
6308
+ commandText = "",
6309
+ toolName = "",
6310
+ summaryText = "",
6311
+ fileRefs = [],
6312
+ cwd = "",
6313
+ }) {
6314
+ const normalizedType = normalizeTimelineFileEventType(fileEventType) || "command";
6315
+ const normalizedRefs = normalizeTimelineFileRefs(fileRefs);
6316
+ const kind = normalizedType === "command" ? "command_event" : "file_event";
6317
+ const stableKey = callId || historyToken(`${threadId}:${createdAtMs}:${normalizedType}:${commandText || toolName}:${normalizedRefs.join("|")}`);
6318
+ const messageText = timelineCommandMessage(DEFAULT_LOCALE, {
6319
+ commandText,
6320
+ toolName,
6321
+ fileRefs: normalizedRefs,
6322
+ });
6323
+ const summary = timelineCommandSummary(commandText, summaryText || toolName) || fileEventTitle(DEFAULT_LOCALE, normalizedType);
6324
+ return normalizeTimelineEntry({
6325
+ stableId: `${kind}:${normalizedType}:${threadId}:${stableKey}`,
6326
+ token: historyToken(`${kind}:${normalizedType}:${threadId}:${stableKey}`),
6327
+ kind,
6328
+ fileEventType: normalizedType,
6329
+ threadId,
6330
+ threadLabel,
6331
+ title: kind === "command_event" ? kindTitle(DEFAULT_LOCALE, "command_event") : fileEventTitle(DEFAULT_LOCALE, normalizedType),
6332
+ summary,
6333
+ messageText,
6334
+ fileRefs: normalizedRefs,
6335
+ createdAtMs,
6336
+ cwd,
6337
+ readOnly: true,
6338
+ provider,
6339
+ });
6340
+ }
6341
+
5607
6342
  async function buildRolloutFileTimelineEntries({ config, record, fileState, runtime, rolloutFilePath = "" }) {
5608
6343
  if (!isPlainObject(record) || cleanText(record.type) !== "response_item") {
5609
6344
  return [];
@@ -5624,30 +6359,54 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
5624
6359
  cwd: fileState.cwd || "",
5625
6360
  });
5626
6361
 
6362
+ if (payloadType === "function_call" && cleanText(payload?.name || "") === "exec_command") {
6363
+ const stored = rememberToolEventInput(fileState, payload, createdAtMs);
6364
+ const args = stored?.arguments || {};
6365
+ const commandText = cleanText(args.cmd || args.command || "");
6366
+ if (!commandText) {
6367
+ return [];
6368
+ }
6369
+ const classified = classifyTimelineCommand(commandText);
6370
+ return [
6371
+ buildToolTimelineEntry({
6372
+ provider: "codex",
6373
+ threadId,
6374
+ threadLabel,
6375
+ callId,
6376
+ createdAtMs,
6377
+ fileEventType: classified.fileEventType,
6378
+ commandText: classified.commandText || commandText,
6379
+ fileRefs: classified.fileRefs,
6380
+ cwd: cleanText(args.workdir || fileState.cwd || ""),
6381
+ }),
6382
+ ].filter(Boolean);
6383
+ }
6384
+
5627
6385
  if (payloadType === "custom_tool_call") {
5628
6386
  rememberApplyPatchInput(fileState, payload, createdAtMs);
5629
6387
  return [];
5630
6388
  }
5631
6389
 
5632
6390
  if (payloadType === "function_call_output") {
6391
+ if (findStoredToolEventInput(fileState, callId)) {
6392
+ return [];
6393
+ }
5633
6394
  const commandText = extractCommandLineFromFunctionOutput(payload.output ?? "");
5634
- const fileRefs = extractReadFileRefsFromCommand(commandText);
5635
- if (fileRefs.length === 0) {
6395
+ if (!commandText) {
5636
6396
  return [];
5637
6397
  }
6398
+ const classified = classifyTimelineCommand(commandText);
5638
6399
  return [
5639
- normalizeTimelineEntry({
5640
- stableId: `file_event:read:${threadId}:${callId || historyToken(`${threadId}:${createdAtMs}:${fileRefs.join("|")}`)}`,
5641
- token: historyToken(`file_event:read:${threadId}:${callId || createdAtMs}`),
5642
- kind: "file_event",
5643
- fileEventType: "read",
6400
+ buildToolTimelineEntry({
6401
+ provider: "codex",
5644
6402
  threadId,
5645
6403
  threadLabel,
5646
- title: fileEventTitle(DEFAULT_LOCALE, "read"),
5647
- summary: "",
5648
- fileRefs,
6404
+ callId,
5649
6405
  createdAtMs,
5650
- readOnly: true,
6406
+ fileEventType: classified.fileEventType,
6407
+ commandText: classified.commandText || commandText,
6408
+ fileRefs: classified.fileRefs,
6409
+ cwd: fileState.cwd || "",
5651
6410
  }),
5652
6411
  ].filter(Boolean);
5653
6412
  }
@@ -5878,6 +6637,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
5878
6637
  subtab: "completed",
5879
6638
  token: historyToken(event.id),
5880
6639
  stableId: event.id,
6640
+ dedupeId: completionPushContentDedupeId(event),
6641
+ dedupeWindowMs: COMPLETION_PUSH_CONTENT_DEDUPE_WINDOW_MS,
5881
6642
  title: event.title,
5882
6643
  body: event.detailText || event.message,
5883
6644
  buildLocalizedContent: ({ locale }) => ({
@@ -6096,6 +6857,8 @@ function buildRolloutEvent({ record, filePath, fileState, sessionIndex, config,
6096
6857
  threadLabel: context.threadLabel,
6097
6858
  message,
6098
6859
  detailText,
6860
+ threadId,
6861
+ turnId,
6099
6862
  priority: config.completePriority,
6100
6863
  tags: config.completeTags,
6101
6864
  clickUrl: config.clickUrl,
@@ -6329,7 +7092,7 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
6329
7092
  body: approval.messageText,
6330
7093
  buildLocalizedContent: ({ locale }) => ({
6331
7094
  title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
6332
- body: approval.messageText,
7095
+ body: formatNativeApprovalMessage(approval.kind, approval.rawParams, locale),
6333
7096
  }),
6334
7097
  }) || stateChanged;
6335
7098
  }
@@ -6665,7 +7428,11 @@ async function syncGenericUserInputRequests({
6665
7428
  userInputRequest.supported ? "server.title.choice" : "server.title.choiceReadOnly",
6666
7429
  userInputRequest.threadLabel
6667
7430
  ),
6668
- body: userInputRequest.notificationText || userInputRequest.messageText,
7431
+ body: buildUserInputNotificationText(
7432
+ userInputRequest.questions,
7433
+ userInputRequest.supported,
7434
+ locale
7435
+ ),
6669
7436
  }),
6670
7437
  }) || stateChanged;
6671
7438
  }
@@ -8463,7 +9230,7 @@ class NativeIpcClient {
8463
9230
  );
8464
9231
  }
8465
9232
 
8466
- async startTurn(conversationId, turnStartParams, ownerClientId = null) {
9233
+ async startTurn(conversationId, turnStartParams, ownerClientId = null, options = {}) {
8467
9234
  return this.sendThreadFollowerRequest(
8468
9235
  "thread-follower-start-turn",
8469
9236
  {
@@ -8471,11 +9238,12 @@ class NativeIpcClient {
8471
9238
  turnStartParams,
8472
9239
  },
8473
9240
  conversationId,
8474
- ownerClientId
9241
+ ownerClientId,
9242
+ options
8475
9243
  );
8476
9244
  }
8477
9245
 
8478
- async startTurnDirect(conversationId, turnStartParams, ownerClientId = null) {
9246
+ async startTurnDirect(conversationId, turnStartParams, ownerClientId = null, options = {}) {
8479
9247
  const targetClientId =
8480
9248
  ownerClientId ??
8481
9249
  this.runtime.threadOwnerClientIds.get(conversationId) ??
@@ -8483,7 +9251,7 @@ class NativeIpcClient {
8483
9251
  return this.sendRequest(
8484
9252
  "turn/start",
8485
9253
  buildDirectTurnStartPayload(conversationId, turnStartParams),
8486
- { targetClientId }
9254
+ { targetClientId, ...options }
8487
9255
  );
8488
9256
  }
8489
9257
 
@@ -8513,12 +9281,13 @@ class NativeIpcClient {
8513
9281
  );
8514
9282
  }
8515
9283
 
8516
- sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null) {
9284
+ sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null, options = {}) {
8517
9285
  return this.sendRequest(method, params, {
8518
9286
  targetClientId:
8519
9287
  ownerClientId ??
8520
9288
  this.runtime.threadOwnerClientIds.get(conversationId) ??
8521
9289
  null,
9290
+ ...options,
8522
9291
  });
8523
9292
  }
8524
9293
 
@@ -10593,55 +11362,323 @@ function setPairingCookies(res, config, existingDeviceId = "") {
10593
11362
  ]);
10594
11363
  }
10595
11364
 
10596
- function clearAuthCookies(res, config) {
10597
- const secure = config.nativeApprovalPublicBaseUrl.startsWith("https://");
10598
- res.setHeader("Set-Cookie", [
10599
- buildCookieHeader(sessionCookieName, { value: "", maxAgeSecs: 0, secure }),
10600
- buildCookieHeader(deviceCookieName, { value: "", maxAgeSecs: 0, secure }),
10601
- ]);
11365
+ function clearAuthCookies(res, config) {
11366
+ const secure = config.nativeApprovalPublicBaseUrl.startsWith("https://");
11367
+ res.setHeader("Set-Cookie", [
11368
+ buildCookieHeader(sessionCookieName, { value: "", maxAgeSecs: 0, secure }),
11369
+ buildCookieHeader(deviceCookieName, { value: "", maxAgeSecs: 0, secure }),
11370
+ ]);
11371
+ }
11372
+
11373
+ function normalizePushSubscriptionBody(payload, deviceId) {
11374
+ const endpoint = cleanText(payload?.subscription?.endpoint ?? payload?.endpoint ?? "");
11375
+ const p256dh = cleanText(
11376
+ payload?.subscription?.keys?.p256dh ??
11377
+ payload?.keys?.p256dh ??
11378
+ payload?.p256dh ??
11379
+ ""
11380
+ );
11381
+ const auth = cleanText(
11382
+ payload?.subscription?.keys?.auth ??
11383
+ payload?.keys?.auth ??
11384
+ payload?.auth ??
11385
+ ""
11386
+ );
11387
+ if (!endpoint || !p256dh || !auth || !deviceId) {
11388
+ return null;
11389
+ }
11390
+ return normalizePushSubscriptionRecord({
11391
+ id: pushSubscriptionId(endpoint),
11392
+ endpoint,
11393
+ keys: { p256dh, auth },
11394
+ deviceId,
11395
+ userAgent: cleanText(payload?.userAgent ?? ""),
11396
+ standalone: payload?.standalone === true,
11397
+ createdAtMs: Date.now(),
11398
+ updatedAtMs: Date.now(),
11399
+ });
11400
+ }
11401
+
11402
+ function buildPushStatusResponse(config, state, session) {
11403
+ const current = getPushSubscriptionForDevice(state, session.deviceId);
11404
+ return {
11405
+ enabled: config.webPushEnabled,
11406
+ secureOrigin: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
11407
+ vapidPublicKey: config.webPushEnabled ? config.webPushVapidPublicKey : "",
11408
+ subject: config.webPushEnabled ? config.webPushSubject : "",
11409
+ deviceId: session.deviceId || null,
11410
+ subscribed: Boolean(current),
11411
+ subscriptionId: current?.id || null,
11412
+ lastSuccessfulDeliveryAtMs: Number(current?.lastSuccessfulDeliveryAtMs) || 0,
11413
+ };
11414
+ }
11415
+
11416
+ async function buildMcpStatusResponse(config, runtime, state) {
11417
+ const remote = getRemotePairingStatus(runtime);
11418
+ let remotePairings = 0;
11419
+ try {
11420
+ remotePairings = (await loadPairings()).length;
11421
+ } catch {
11422
+ remotePairings = 0;
11423
+ }
11424
+ const now = Date.now();
11425
+ const pairedDevices = isPlainObject(state.pairedDevices) ? state.pairedDevices : {};
11426
+ const trustedDevices = Object.values(pairedDevices).filter((raw) => {
11427
+ const record = normalizeDeviceTrustRecord(raw, { trustTtlMs: config.deviceTrustTtlMs, now });
11428
+ return record && !record.revokedAtMs && Number(record.trustedUntilMs) > now;
11429
+ }).length;
11430
+ const a2aRelay = getRelayStatus();
11431
+ return {
11432
+ ok: true,
11433
+ bridge: {
11434
+ running: true,
11435
+ publicBaseUrl: config.nativeApprovalPublicBaseUrl,
11436
+ locale: config.defaultLocale,
11437
+ codexConnected: Boolean(runtime.ipcClient?.clientId),
11438
+ },
11439
+ pairing: {
11440
+ trustedDevices,
11441
+ webPushEnabled: Boolean(config.webPushEnabled),
11442
+ pushSubscriptions: listPushSubscriptions(state).length,
11443
+ },
11444
+ remote: {
11445
+ enabled: Boolean(remote.enabled),
11446
+ relayUrl: remote.relayUrl || config.remotePairingRelayUrl || "",
11447
+ identityFingerprint: remote.identityFingerprint || null,
11448
+ pairings: remotePairings,
11449
+ sessions: Array.isArray(remote.sessions)
11450
+ ? remote.sessions.map((session) => ({
11451
+ state: cleanText(session?.state || ""),
11452
+ lastRoute: cleanText(session?.lastRoute || ""),
11453
+ connected: cleanText(session?.state || "") === "connected",
11454
+ phoneFingerprint: cleanText(session?.phoneFingerprint || ""),
11455
+ lastSeenAtMs: Number(session?.lastSeenAtMs) || 0,
11456
+ }))
11457
+ : [],
11458
+ },
11459
+ a2a: {
11460
+ enabled: Boolean(config.a2aApiKey),
11461
+ relayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
11462
+ relayConnected: Boolean(a2aRelay.polling && a2aRelay.lastPollOk),
11463
+ userIdConfigured: Boolean(config.a2aRelayUserId),
11464
+ },
11465
+ share: {
11466
+ enabled: Boolean(config.a2aRelayUserId && config.a2aApiKey),
11467
+ shareUrl: config.a2aShareUrl || "",
11468
+ },
11469
+ moltbook: {
11470
+ enabled: Boolean(config.moltbookApiKey),
11471
+ },
11472
+ };
11473
+ }
11474
+
11475
+ function mcpTimeoutMs(value) {
11476
+ const n = Number(value);
11477
+ if (!Number.isFinite(n)) return 600_000;
11478
+ return Math.max(10_000, Math.min(900_000, Math.floor(n)));
11479
+ }
11480
+
11481
+ function mcpText(value, maxChars = 50_000) {
11482
+ const text = String(value ?? "");
11483
+ return text.length > maxChars ? `${text.slice(0, maxChars)}\n\n[truncated by viveworker MCP]` : text;
11484
+ }
11485
+
11486
+ function normalizeMcpStringArray(value, maxItems = 20) {
11487
+ if (!Array.isArray(value)) return [];
11488
+ return value
11489
+ .map((item) => cleanText(item ?? ""))
11490
+ .filter(Boolean)
11491
+ .slice(0, maxItems);
11492
+ }
11493
+
11494
+ function normalizeMcpQuestionOptions(value) {
11495
+ if (!Array.isArray(value)) return [];
11496
+ return value
11497
+ .map((option, index) => {
11498
+ if (typeof option === "string") {
11499
+ const label = cleanText(option);
11500
+ return label ? { label } : null;
11501
+ }
11502
+ if (!isPlainObject(option)) return null;
11503
+ const label = cleanText(option.label ?? option.title ?? `Option ${index + 1}`);
11504
+ if (!label) return null;
11505
+ const description = cleanText(option.description ?? option.detail ?? "");
11506
+ return { label, ...(description ? { description } : {}) };
11507
+ })
11508
+ .filter(Boolean)
11509
+ .slice(0, 10);
11510
+ }
11511
+
11512
+ function normalizeMcpApprovalKind(value) {
11513
+ const normalized = cleanText(value || "mcp").toLowerCase();
11514
+ if (!/^[a-z0-9_-]{1,40}$/u.test(normalized)) {
11515
+ return "mcp";
11516
+ }
11517
+ // Keep MCP in the normal approval lane. These kinds have provider-specific
11518
+ // UI/actions elsewhere and should not be spoofable from a generic MCP client.
11519
+ if (normalized === "payment" || normalized === "hazbase_wallet_payment" || normalized === "plan" || normalized === "question") {
11520
+ return "mcp";
11521
+ }
11522
+ return normalized;
11523
+ }
11524
+
11525
+ function createMcpPendingApproval(body, kind) {
11526
+ const token = crypto.randomBytes(18).toString("hex");
11527
+ const requestId = cleanText(body.requestId || crypto.randomUUID());
11528
+ const title = cleanText(body.title || (kind === "question" ? "MCP question" : "MCP approval"));
11529
+ const messageText = kind === "question"
11530
+ ? mcpText(body.question || body.message || "")
11531
+ : mcpText(body.messageText || body.message || "");
11532
+ const requestKey = `mcp:${requestId}`;
11533
+ const approval = {
11534
+ token,
11535
+ requestKey,
11536
+ conversationId: "mcp",
11537
+ requestId,
11538
+ ownerClientId: null,
11539
+ kind,
11540
+ threadLabel: title || "MCP",
11541
+ title,
11542
+ messageText,
11543
+ fileRefs: normalizeMcpStringArray(body.fileRefs),
11544
+ previousFileRefs: [],
11545
+ diffText: kind === "question" ? "" : mcpText(body.diffText || "", 100_000),
11546
+ diffAvailable: kind !== "question" && Boolean(body.diffText),
11547
+ diffSource: kind === "question" ? "" : "mcp",
11548
+ diffAddedLines: 0,
11549
+ diffRemovedLines: 0,
11550
+ cwd: "",
11551
+ workspaceRoot: "",
11552
+ createdAtMs: Date.now(),
11553
+ resolved: false,
11554
+ resolving: false,
11555
+ resolveMcpWaiter: null,
11556
+ provider: "mcp",
11557
+ planText: "",
11558
+ questions: [],
11559
+ notifyOnly: false,
11560
+ readOnly: false,
11561
+ };
11562
+ if (kind === "question") {
11563
+ approval.questions = [
11564
+ {
11565
+ question: mcpText(body.question || body.message || "", 2000),
11566
+ options: normalizeMcpQuestionOptions(body.options),
11567
+ multiSelect: body.multiSelect === true,
11568
+ },
11569
+ ];
11570
+ }
11571
+ return approval;
11572
+ }
11573
+
11574
+ async function waitForMcpApprovalResult(runtime, approval, timeoutMs) {
11575
+ const waitMs = mcpTimeoutMs(timeoutMs);
11576
+ const result = await new Promise((resolve) => {
11577
+ const timer = setTimeout(() => {
11578
+ resolve({ approved: false, decision: "timeout" });
11579
+ }, waitMs);
11580
+ approval.resolveMcpWaiter = (value) => {
11581
+ clearTimeout(timer);
11582
+ resolve(value);
11583
+ };
11584
+ });
11585
+ if (!approval.resolved) {
11586
+ approval.resolved = true;
11587
+ approval.resolving = false;
11588
+ runtime.nativeApprovalsByToken.delete(approval.token);
11589
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
11590
+ }
11591
+ return result;
10602
11592
  }
10603
11593
 
10604
- function normalizePushSubscriptionBody(payload, deviceId) {
10605
- const endpoint = cleanText(payload?.subscription?.endpoint ?? payload?.endpoint ?? "");
10606
- const p256dh = cleanText(
10607
- payload?.subscription?.keys?.p256dh ??
10608
- payload?.keys?.p256dh ??
10609
- payload?.p256dh ??
10610
- ""
10611
- );
10612
- const auth = cleanText(
10613
- payload?.subscription?.keys?.auth ??
10614
- payload?.keys?.auth ??
10615
- payload?.auth ??
10616
- ""
10617
- );
10618
- if (!endpoint || !p256dh || !auth || !deviceId) {
10619
- return null;
11594
+ async function handleMcpProviderEvent({ config, runtime, state, body, res }) {
11595
+ const eventType = cleanText(body.eventType || "");
11596
+ if (eventType === "status") {
11597
+ return writeJson(res, 200, await buildMcpStatusResponse(config, runtime, state));
10620
11598
  }
10621
- return normalizePushSubscriptionRecord({
10622
- id: pushSubscriptionId(endpoint),
10623
- endpoint,
10624
- keys: { p256dh, auth },
10625
- deviceId,
10626
- userAgent: cleanText(payload?.userAgent ?? ""),
10627
- standalone: payload?.standalone === true,
10628
- createdAtMs: Date.now(),
10629
- updatedAtMs: Date.now(),
10630
- });
10631
- }
10632
11599
 
10633
- function buildPushStatusResponse(config, state, session) {
10634
- const current = getPushSubscriptionForDevice(state, session.deviceId);
10635
- return {
10636
- enabled: config.webPushEnabled,
10637
- secureOrigin: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
10638
- vapidPublicKey: config.webPushEnabled ? config.webPushVapidPublicKey : "",
10639
- subject: config.webPushEnabled ? config.webPushSubject : "",
10640
- deviceId: session.deviceId || null,
10641
- subscribed: Boolean(current),
10642
- subscriptionId: current?.id || null,
10643
- lastSuccessfulDeliveryAtMs: Number(current?.lastSuccessfulDeliveryAtMs) || 0,
10644
- };
11600
+ if (eventType === "notify") {
11601
+ const title = cleanText(body.title || "MCP");
11602
+ const messageText = mcpText(body.message || body.messageText || "");
11603
+ if (!messageText) {
11604
+ return writeJson(res, 400, { error: "missing-message" });
11605
+ }
11606
+ const stableId = `mcp_notify:${historyToken(`${title}:${messageText}:${Date.now()}`)}`;
11607
+ const token = historyToken(stableId);
11608
+ const entry = {
11609
+ stableId,
11610
+ token,
11611
+ kind: "assistant_commentary",
11612
+ threadId: "mcp",
11613
+ threadLabel: cleanText(body.threadLabel || "MCP"),
11614
+ title,
11615
+ summary: formatNotificationBody(messageText, 160) || messageText,
11616
+ messageText,
11617
+ createdAtMs: Date.now(),
11618
+ readOnly: true,
11619
+ provider: "mcp",
11620
+ };
11621
+ const timelineChanged = recordTimelineEntry({ config, runtime, state, entry });
11622
+ const historyChanged = recordHistoryItem({ config, runtime, state, item: entry });
11623
+ const pushChanged = await deliverWebPushItem({
11624
+ config,
11625
+ state,
11626
+ kind: "assistant_commentary",
11627
+ tab: "timeline",
11628
+ token,
11629
+ stableId,
11630
+ title,
11631
+ body: messageText,
11632
+ buildLocalizedContent: ({ locale }) => ({
11633
+ title: localizedMcpNotifyTitle(locale, title),
11634
+ body: pushBodySnippet(messageText),
11635
+ }),
11636
+ }).catch((error) => {
11637
+ console.error(`[mcp-notify-push] ${error.message}`);
11638
+ return false;
11639
+ });
11640
+ if (timelineChanged || historyChanged || pushChanged) {
11641
+ await saveState(config.stateFile, state);
11642
+ }
11643
+ return writeJson(res, 200, { ok: true, token });
11644
+ }
11645
+
11646
+ if (eventType === "approval_request" || eventType === "choice_request") {
11647
+ const kind = eventType === "choice_request" ? "question" : normalizeMcpApprovalKind(body.approvalKind);
11648
+ if (kind !== "question" && !mcpText(body.message || body.messageText || "")) {
11649
+ return writeJson(res, 400, { error: "missing-message" });
11650
+ }
11651
+ if (kind === "question" && !mcpText(body.question || body.message || "")) {
11652
+ return writeJson(res, 400, { error: "missing-question" });
11653
+ }
11654
+ const approval = createMcpPendingApproval(body, kind);
11655
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
11656
+ runtime.nativeApprovalsByRequestKey.set(approval.requestKey, approval);
11657
+ deliverWebPushItem({
11658
+ config,
11659
+ state,
11660
+ kind: "approval",
11661
+ tab: "inbox",
11662
+ subtab: "pending",
11663
+ token: approval.token,
11664
+ stableId: pendingApprovalStableId(approval),
11665
+ title: approval.title || "MCP",
11666
+ body: approval.messageText,
11667
+ buildLocalizedContent: ({ locale }) => localizedMcpApprovalPushContent(locale, approval),
11668
+ }).catch((error) => {
11669
+ console.error(`[mcp-approval-push] ${approval.requestKey} | ${error.message}`);
11670
+ });
11671
+ const result = await waitForMcpApprovalResult(runtime, approval, body.timeoutMs);
11672
+ return writeJson(res, 200, {
11673
+ ok: true,
11674
+ approved: result?.approved === true || result?.decision === "accept",
11675
+ decision: result?.decision || (result?.approved ? "accept" : "reject"),
11676
+ ...(result?.answerText ? { answerText: result.answerText } : {}),
11677
+ ...(Array.isArray(result?.answers) ? { answers: result.answers } : {}),
11678
+ });
11679
+ }
11680
+
11681
+ return writeJson(res, 400, { error: "unsupported-mcp-event" });
10645
11682
  }
10646
11683
 
10647
11684
  function requestUserAgent(req) {
@@ -10992,6 +12029,17 @@ function historyItemByToken(runtime, kind, token) {
10992
12029
  ) ?? null;
10993
12030
  }
10994
12031
 
12032
+ function approvalDecisionFromHistoryItem(item) {
12033
+ const outcome = cleanText(item?.outcome || "");
12034
+ if (outcome === "approved") {
12035
+ return "accept";
12036
+ }
12037
+ if (outcome === "rejected") {
12038
+ return "decline";
12039
+ }
12040
+ return "";
12041
+ }
12042
+
10995
12043
  function listLatestPersistedUserInputRequests({ config, runtime, state }) {
10996
12044
  const byToken = new Map();
10997
12045
  for (const userInputRequest of runtime.userInputRequestsByToken.values()) {
@@ -12065,6 +13113,48 @@ function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
12065
13113
  .sort((left, right) => Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0))[0] || null;
12066
13114
  }
12067
13115
 
13116
+ function normalizeCompletionReplyComparisonText(value) {
13117
+ return normalizeLongText(value).replace(/\s+/gu, " ").trim();
13118
+ }
13119
+
13120
+ function isMatchingCompletionReplyTimelineMessage(entry, messageText, expectedImageCount = 0) {
13121
+ if (cleanText(entry?.kind || "") !== "user_message") {
13122
+ return false;
13123
+ }
13124
+ const expectedText = normalizeCompletionReplyComparisonText(messageText);
13125
+ const actualText = normalizeCompletionReplyComparisonText(entry?.messageText || entry?.summary || entry?.title || "");
13126
+ if (!expectedText || actualText !== expectedText) {
13127
+ return false;
13128
+ }
13129
+ const entryImageCount = normalizeTimelineImagePaths(entry?.imagePaths || []).length;
13130
+ return expectedImageCount <= 0 || entryImageCount === expectedImageCount;
13131
+ }
13132
+
13133
+ function findMatchingCompletionReplyAfterCompletion(runtime, completionItem, messageText, expectedImageCount = 0) {
13134
+ const itemKind = cleanText(completionItem?.kind || "");
13135
+ if (!runtime || !REPLYABLE_HISTORY_KINDS.has(itemKind)) {
13136
+ return null;
13137
+ }
13138
+
13139
+ const threadId = historyItemThreadId(completionItem);
13140
+ const completionCreatedAtMs = Number(completionItem?.createdAtMs) || 0;
13141
+ if (!threadId || !completionCreatedAtMs) {
13142
+ return null;
13143
+ }
13144
+
13145
+ return runtime.recentTimelineEntries
13146
+ .filter((entry) => {
13147
+ if (cleanText(entry?.threadId || "") !== threadId) {
13148
+ return false;
13149
+ }
13150
+ if (Number(entry?.createdAtMs) <= completionCreatedAtMs) {
13151
+ return false;
13152
+ }
13153
+ return isMatchingCompletionReplyTimelineMessage(entry, messageText, expectedImageCount);
13154
+ })
13155
+ .sort((left, right) => Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0))[0] || null;
13156
+ }
13157
+
12068
13158
  function buildHistoryDetail(item, locale, runtime = null) {
12069
13159
  const threadId = historyItemThreadId(item);
12070
13160
  const replyEnabled =
@@ -12182,6 +13272,13 @@ function buildAmbientSuggestionsDetail(entry, locale) {
12182
13272
 
12183
13273
  function buildTimelineFileEventDetail(entry, locale) {
12184
13274
  const fileEventType = normalizeTimelineFileEventType(entry?.fileEventType ?? "");
13275
+ const commandText = ["read", "search"].includes(fileEventType)
13276
+ ? firstMarkdownCodeFenceText(entry?.messageText ?? "")
13277
+ : "";
13278
+ const detailText = [
13279
+ fileEventDetailCopy(locale, fileEventType, entry?.provider),
13280
+ commandText ? "" : normalizeTimelineMessageText(entry?.messageText ?? "", locale),
13281
+ ].filter(Boolean).join("\n\n");
12185
13282
  return {
12186
13283
  kind: "file_event",
12187
13284
  token: entry.token,
@@ -12190,9 +13287,10 @@ function buildTimelineFileEventDetail(entry, locale) {
12190
13287
  threadLabel: entry.threadLabel || "",
12191
13288
  fileEventType,
12192
13289
  createdAtMs: Number(entry.createdAtMs) || 0,
12193
- messageHtml: renderMessageHtml(fileEventDetailCopy(locale, fileEventType, entry?.provider), `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
13290
+ messageHtml: renderMessageHtml(detailText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
12194
13291
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
12195
13292
  previousFileRefs: normalizeTimelineFileRefs(entry.previousFileRefs ?? []),
13293
+ ...(commandText ? { commandText } : {}),
12196
13294
  diffAvailable: Boolean(entry.diffAvailable),
12197
13295
  diffText: normalizeTimelineDiffText(entry.diffText ?? ""),
12198
13296
  diffSource: normalizeTimelineDiffSource(entry.diffSource ?? ""),
@@ -12203,6 +13301,25 @@ function buildTimelineFileEventDetail(entry, locale) {
12203
13301
  };
12204
13302
  }
12205
13303
 
13304
+ function buildTimelineCommandEventDetail(entry, locale) {
13305
+ const commandText = firstMarkdownCodeFenceText(entry?.messageText ?? "") || cleanText(entry?.summary || "");
13306
+ const detailText = t(locale, "detail.commandEvent.copy", { provider: providerDisplayName(locale, entry?.provider) });
13307
+ return {
13308
+ kind: "command_event",
13309
+ token: entry.token,
13310
+ threadId: cleanText(entry.threadId || ""),
13311
+ title: cleanText(entry.threadLabel || entry.title || "") || kindTitle(locale, "command_event"),
13312
+ threadLabel: entry.threadLabel || "",
13313
+ fileEventType: "command",
13314
+ createdAtMs: Number(entry.createdAtMs) || 0,
13315
+ messageHtml: renderMessageHtml(detailText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
13316
+ commandText,
13317
+ fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
13318
+ readOnly: true,
13319
+ actions: [],
13320
+ };
13321
+ }
13322
+
12206
13323
  function buildDiffThreadDetail(group, locale) {
12207
13324
  const changedFileCount = Math.max(0, Number(group.changedFileCount) || 0);
12208
13325
  return {
@@ -12576,6 +13693,25 @@ async function handleCompletionReply({
12576
13693
  if (!conversationId) {
12577
13694
  throw new Error("completion-reply-unavailable");
12578
13695
  }
13696
+ console.log(
13697
+ `[completion-reply] received at=${new Date().toISOString()} ` +
13698
+ `token=${cleanText(completionItem?.token || "") || "unknown"} thread=${conversationId} ` +
13699
+ `images=${normalizedLocalImagePaths.length} plan=${planMode ? 1 : 0} force=${force ? 1 : 0}`
13700
+ );
13701
+
13702
+ const alreadyAcceptedReply = findMatchingCompletionReplyAfterCompletion(
13703
+ runtime,
13704
+ completionItem,
13705
+ messageText,
13706
+ normalizedLocalImagePaths.length
13707
+ );
13708
+ if (alreadyAcceptedReply && !force) {
13709
+ console.log(
13710
+ `[completion-reply] idempotent token=${cleanText(completionItem?.token || "") || "unknown"} thread=${conversationId} timeline=${cleanText(alreadyAcceptedReply.stableId || alreadyAcceptedReply.token || "") || "unknown"}`
13711
+ );
13712
+ return { alreadyAccepted: true };
13713
+ }
13714
+
12579
13715
  // For assistant_final, check against timeline entries (avoids maxHistoryItems eviction).
12580
13716
  // For legacy completion, check against history items.
12581
13717
  const itemKind = cleanText(completionItem?.kind || "");
@@ -12666,30 +13802,32 @@ async function handleCompletionReply({
12666
13802
  await runtime.ipcClient.startTurnDirect(
12667
13803
  conversationId,
12668
13804
  candidate.turnStartParams,
12669
- ownerClientId
13805
+ ownerClientId,
13806
+ { timeoutMs: config.completionReplyAckTimeoutMs }
12670
13807
  );
12671
13808
  } else {
12672
13809
  await runtime.ipcClient.startTurn(
12673
13810
  conversationId,
12674
13811
  candidate.turnStartParams,
12675
- ownerClientId
13812
+ ownerClientId,
13813
+ { timeoutMs: config.completionReplyAckTimeoutMs }
12676
13814
  );
12677
13815
  }
12678
13816
  console.log(
12679
- `[completion-reply] success candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
13817
+ `[completion-reply] success at=${new Date().toISOString()} candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
12680
13818
  );
12681
13819
  await finalizeReplyAccepted();
12682
- return;
13820
+ return { alreadyAccepted: false };
12683
13821
  } catch (error) {
12684
13822
  if (isStartTurnAckTimeout(error)) {
12685
13823
  // Codex can create the turn but fail to send the bridge an ACK before
12686
13824
  // its internal follower timeout fires. Retrying risks duplicate user
12687
13825
  // turns, so treat this as accepted and let the timeline catch up.
12688
13826
  console.log(
12689
- `[completion-reply] accepted candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} ack-timeout=${normalizeIpcErrorMessage(error)}`
13827
+ `[completion-reply] accepted at=${new Date().toISOString()} candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} ack-timeout=${normalizeIpcErrorMessage(error)}`
12690
13828
  );
12691
13829
  await finalizeReplyAccepted();
12692
- return;
13830
+ return { alreadyAccepted: false, ackTimeout: true };
12693
13831
  }
12694
13832
  lastError = error;
12695
13833
  console.log(
@@ -13380,7 +14518,12 @@ async function autoApproveTrustedWrite({
13380
14518
  }
13381
14519
 
13382
14520
  async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
13383
- if (approval.resolveClaudeWaiter) {
14521
+ if (approval.resolveMcpWaiter) {
14522
+ approval.resolveMcpWaiter({
14523
+ approved: decision === "accept",
14524
+ decision,
14525
+ });
14526
+ } else if (approval.resolveClaudeWaiter) {
13384
14527
  if (approval.provider === "claude" && approval.kind === "plan") {
13385
14528
  // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
13386
14529
  // (Claude still shows the native PC plan dialog). Instead, deny the tool
@@ -13440,6 +14583,10 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13440
14583
  const entry = timelineEntryByToken(runtime, token, kind);
13441
14584
  return entry ? buildTimelineFileEventDetail(entry, locale) : null;
13442
14585
  }
14586
+ if (kind === "command_event") {
14587
+ const entry = timelineEntryByToken(runtime, token, kind);
14588
+ return entry ? buildTimelineCommandEventDetail(entry, locale) : null;
14589
+ }
13443
14590
  if (kind === "ambient_suggestions") {
13444
14591
  const entry = timelineEntryByToken(runtime, token, kind);
13445
14592
  if (entry) {
@@ -13621,6 +14768,8 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13621
14768
  provider: "a2a",
13622
14769
  instruction,
13623
14770
  messageText: responseText,
14771
+ viveworker: task?.viveworker || entry?.viveworker || {},
14772
+ paidDeliverable: task?.paidDeliverable || entry?.paidDeliverable || null,
13624
14773
  taskId: task?.id || "",
13625
14774
  taskStatus: task?.status || entry?.taskStatus || (kind === "a2a_task_result" ? "completed" : "submitted"),
13626
14775
  callerInfo: task?.callerInfo || {},
@@ -13751,13 +14900,32 @@ const GZIPPABLE_EXTENSIONS = new Set([
13751
14900
  ]);
13752
14901
  const MIN_GZIP_BYTES = 512;
13753
14902
 
14903
+ function renderWebAssetBuffer(filePath, raw) {
14904
+ const basename = path.basename(filePath);
14905
+ if (basename !== "index.html" && basename !== "sw.js" && basename !== "app.js") {
14906
+ return raw;
14907
+ }
14908
+
14909
+ const text = raw.toString("utf8");
14910
+ if (!text.includes(WEB_APP_BUILD_ID_PLACEHOLDER)) {
14911
+ return raw;
14912
+ }
14913
+
14914
+ return Buffer.from(text.replaceAll(WEB_APP_BUILD_ID_PLACEHOLDER, WEB_APP_BUILD_ID), "utf8");
14915
+ }
14916
+
13754
14917
  async function loadWebAssetEntry(filePath) {
13755
14918
  const stat = await fs.stat(filePath);
13756
14919
  const cached = WEB_ASSET_CACHE.get(filePath);
13757
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
14920
+ if (
14921
+ cached &&
14922
+ cached.mtimeMs === stat.mtimeMs &&
14923
+ cached.sourceSize === stat.size &&
14924
+ cached.buildId === WEB_APP_BUILD_ID
14925
+ ) {
13758
14926
  return cached;
13759
14927
  }
13760
- const raw = await fs.readFile(filePath);
14928
+ const raw = renderWebAssetBuffer(filePath, await fs.readFile(filePath));
13761
14929
  const extension = path.extname(filePath).toLowerCase();
13762
14930
  let gzip = null;
13763
14931
  if (GZIPPABLE_EXTENSIONS.has(extension) && raw.length >= MIN_GZIP_BYTES) {
@@ -13771,7 +14939,8 @@ async function loadWebAssetEntry(filePath) {
13771
14939
  raw,
13772
14940
  gzip,
13773
14941
  mtimeMs: stat.mtimeMs,
13774
- size: stat.size,
14942
+ sourceSize: stat.size,
14943
+ buildId: WEB_APP_BUILD_ID,
13775
14944
  contentType: contentTypeForFile(filePath),
13776
14945
  };
13777
14946
  WEB_ASSET_CACHE.set(filePath, entry);
@@ -13870,7 +15039,7 @@ function buildWebAppHtml({ pairToken }) {
13870
15039
  <link rel="manifest" href="${escapeHtml(manifestHref)}">
13871
15040
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
13872
15041
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/viveworker-icon-192.png">
13873
- <link rel="stylesheet" href="/app.css">
15042
+ <link rel="stylesheet" href="/app.css?v=${escapeHtml(WEB_APP_BUILD_ID)}">
13874
15043
  <style>
13875
15044
  .boot-splash {
13876
15045
  position: fixed;
@@ -14080,6 +15249,107 @@ function logRemotePairingBootTrace(body, session, req) {
14080
15249
  }
14081
15250
  }
14082
15251
 
15252
+ function logClientEvent(body, session, req) {
15253
+ const type = cleanText(body?.type || "").slice(0, 80);
15254
+ if (type !== "timeline-render") {
15255
+ return;
15256
+ }
15257
+
15258
+ const route = cleanText(body?.route || "").slice(0, 24) || "unknown";
15259
+ const reason = cleanText(body?.reason || "").slice(0, 80) || "unknown";
15260
+ const latestToken = cleanText(body?.latestToken || "").slice(0, 80) || "none";
15261
+ const latestKind = cleanText(body?.latestKind || "").slice(0, 80) || "unknown";
15262
+ const appBuildId = cleanText(body?.appBuildId || "").slice(0, 80) || "unknown";
15263
+ const currentTab = cleanText(body?.currentTab || "").slice(0, 40) || "unknown";
15264
+ const entryCount = Math.max(0, Math.min(10_000, Math.round(Number(body?.entryCount) || 0)));
15265
+ const latestCreatedAtMs = Math.max(0, Math.min(9_999_999_999_999, Math.round(Number(body?.latestCreatedAtMs) || 0)));
15266
+ const clientAtMs = Math.max(0, Math.min(9_999_999_999_999, Math.round(Number(body?.clientAtMs) || 0)));
15267
+ const visible = body?.visible === true;
15268
+ const renderedTokens = Array.isArray(body?.renderedTokens)
15269
+ ? body.renderedTokens
15270
+ .slice(0, 5)
15271
+ .map((item) => {
15272
+ const token = cleanText(item?.token || "").slice(0, 80);
15273
+ const kind = cleanText(item?.kind || "").slice(0, 80);
15274
+ const createdAtMs = Math.max(0, Math.min(9_999_999_999_999, Math.round(Number(item?.createdAtMs) || 0)));
15275
+ return token && kind ? `${kind}:${token}:${createdAtMs}` : "";
15276
+ })
15277
+ .filter(Boolean)
15278
+ : [];
15279
+ const deviceId = cleanText(session?.deviceId || "").slice(0, 60) || "unknown";
15280
+ const ua = requestUserAgent(req).slice(0, 90);
15281
+
15282
+ console.log(
15283
+ `[client-event] timeline-render at=${new Date().toISOString()} device=${deviceId} ` +
15284
+ `route=${route} visible=${visible ? 1 : 0} tab=${currentTab} reason=${reason} ` +
15285
+ `latest=${latestKind}:${latestToken} latestCreatedAtMs=${latestCreatedAtMs} ` +
15286
+ `clientAtMs=${clientAtMs} count=${entryCount} build=${appBuildId} ` +
15287
+ `rendered=${renderedTokens.join(",") || "none"} ua=${JSON.stringify(ua)}`
15288
+ );
15289
+ }
15290
+
15291
+ function writeSseEvent(res, eventName, payload) {
15292
+ res.write(`event: ${eventName}\n`);
15293
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
15294
+ }
15295
+
15296
+ function openTimelineStream({ config, runtime, req, res, session }) {
15297
+ if (!config.timelineLiveSync) {
15298
+ return writeJson(res, 404, { error: "timeline-live-sync-disabled" });
15299
+ }
15300
+ res.statusCode = 200;
15301
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
15302
+ res.setHeader("Cache-Control", "no-cache, no-transform");
15303
+ res.setHeader("Connection", "keep-alive");
15304
+ res.setHeader("X-Accel-Buffering", "no");
15305
+ res.flushHeaders?.();
15306
+
15307
+ let closed = false;
15308
+ const deviceId = cleanText(session?.deviceId || "").slice(0, 60) || "unknown";
15309
+ const bus = ensureTimelineBus(runtime);
15310
+ const client = {
15311
+ send(eventName, payload) {
15312
+ if (closed || res.destroyed || res.writableEnded) {
15313
+ throw new Error("timeline-stream-closed");
15314
+ }
15315
+ writeSseEvent(res, eventName, payload);
15316
+ },
15317
+ close() {
15318
+ if (closed) return;
15319
+ closed = true;
15320
+ clearInterval(heartbeat);
15321
+ remove();
15322
+ try {
15323
+ res.end();
15324
+ } catch {}
15325
+ },
15326
+ };
15327
+ const remove = bus.addClient(client);
15328
+ const heartbeat = setInterval(() => {
15329
+ try {
15330
+ client.send("heartbeat", {
15331
+ atMs: Date.now(),
15332
+ revision: Number(runtime.timelineRevision) || 0,
15333
+ });
15334
+ } catch {
15335
+ client.close();
15336
+ }
15337
+ }, 20_000);
15338
+ heartbeat.unref?.();
15339
+
15340
+ req.on("close", () => client.close());
15341
+ req.on("aborted", () => client.close());
15342
+ res.on("error", () => client.close());
15343
+ client.send("hello", {
15344
+ ok: true,
15345
+ revision: Number(runtime.timelineRevision) || 0,
15346
+ appBuildId: WEB_APP_BUILD_ID,
15347
+ deviceId,
15348
+ atMs: Date.now(),
15349
+ });
15350
+ console.log(`[timeline-stream] open device=${deviceId} clients=${bus.clients.size}`);
15351
+ }
15352
+
14083
15353
  function recordRemotePairingAudit(event) {
14084
15354
  appendRemotePairingAuditEvent({
14085
15355
  atMs: Date.now(),
@@ -14173,6 +15443,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
14173
15443
  if (
14174
15444
  url.pathname === "/app.js" ||
14175
15445
  url.pathname === "/app.css" ||
15446
+ url.pathname === "/build-id.js" ||
14176
15447
  url.pathname === "/i18n.js" ||
14177
15448
  url.pathname === "/hazbase-passkey.js" ||
14178
15449
  url.pathname === "/sw.js" ||
@@ -14251,7 +15522,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
14251
15522
  if (action === "approve") {
14252
15523
  // Resolve which executor to use.
14253
15524
  const available = runtime.a2aAvailableExecutors || { codex: false, claude: false };
14254
- const preference = requestedExecutor || state.a2aExecutorPreference || "ask";
15525
+ const preference = requestedExecutor || task.viveworker?.requestedExecutor || state.a2aExecutorPreference || "ask";
14255
15526
  let executor;
14256
15527
  if (preference === "codex" && available.codex) executor = "codex";
14257
15528
  else if (preference === "claude" && available.claude) executor = "claude";
@@ -15160,6 +16431,29 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15160
16431
  return writeJson(res, 200, { ok: true });
15161
16432
  }
15162
16433
 
16434
+ // POST /api/client-events — local-only diagnostics from the PWA.
16435
+ //
16436
+ // The endpoint deliberately accepts only small sanitized metadata, not
16437
+ // message text, commands, file paths, relay tokens, or public keys. It is
16438
+ // used to correlate "bridge accepted" timings with "the PWA rendered the
16439
+ // latest timeline item" timings while debugging LAN/remote freshness.
16440
+ if (url.pathname === "/api/client-events" && req.method === "POST") {
16441
+ const session = requireApiSession(req, res, config, state);
16442
+ if (!session) return;
16443
+ let body;
16444
+ try {
16445
+ body = await parseJsonBody(req);
16446
+ } catch (err) {
16447
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
16448
+ }
16449
+ try {
16450
+ logClientEvent(body, session, req);
16451
+ } catch (err) {
16452
+ console.warn(`[client-event] failed to log event: ${err?.message}`);
16453
+ }
16454
+ return writeJson(res, 200, { ok: true });
16455
+ }
16456
+
15163
16457
  // POST /api/remote-pairing/toggle — flip on/off without restart.
15164
16458
  //
15165
16459
  // Body: { enabled: boolean }
@@ -15748,7 +17042,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15748
17042
  messageText: content,
15749
17043
  createdAtMs: share.createdAtMs,
15750
17044
  readOnly: false,
15751
- provider: "viveworker",
17045
+ provider: sourceTool === "mcp" ? "mcp" : "viveworker",
15752
17046
  },
15753
17047
  });
15754
17048
  await saveState(config.stateFile, state);
@@ -15765,6 +17059,13 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15765
17059
  stableId: `thread_share:${shareId}`,
15766
17060
  title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
15767
17061
  body: String(content).slice(0, 160),
17062
+ buildLocalizedContent: ({ locale }) => localizedThreadSharePushContent(locale, {
17063
+ sourceLabel,
17064
+ sourceTool,
17065
+ targetLabel,
17066
+ targetTool,
17067
+ body: content,
17068
+ }),
15768
17069
  });
15769
17070
  } catch (error) {
15770
17071
  console.error(`[thread-share-push] ${error.message}`);
@@ -15864,6 +17165,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15864
17165
  stableId: `thread_share_fallback:${share.shareId}`,
15865
17166
  title: "Thread Share: target unreachable",
15866
17167
  body: `Codex thread "${share.targetLabel || share.targetConversationId.slice(0, 8)}" is not connected. Content saved to inbox.`,
17168
+ buildLocalizedContent: ({ locale }) => localizedThreadShareFallbackPushContent(
17169
+ locale,
17170
+ share.targetLabel || share.targetConversationId.slice(0, 8)
17171
+ ),
15867
17172
  });
15868
17173
  } catch (error) {
15869
17174
  console.error(`[thread-share-fallback-push] ${error.message}`);
@@ -15956,7 +17261,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15956
17261
  rangeDate = d.toISOString().slice(0, 10);
15957
17262
  }
15958
17263
 
15959
- const relevantKinds = new Set(["file_event", "completion", "plan_ready", "assistant_final"]);
17264
+ const relevantKinds = new Set(["file_event", "command_event", "completion", "plan_ready", "assistant_final"]);
15960
17265
  const entries = (runtime.recentTimelineEntries || [])
15961
17266
  .filter((e) => e.createdAtMs >= rangeStart && e.createdAtMs < rangeEnd && relevantKinds.has(e.kind))
15962
17267
  .map((e) => ({
@@ -16125,7 +17430,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16125
17430
  stableId: pendingApprovalStableId(approval),
16126
17431
  title: approval.title,
16127
17432
  body: approval.messageText,
16128
- buildLocalizedContent: () => ({ title: approval.title, body: approval.messageText }),
17433
+ buildLocalizedContent: ({ locale }) => localizedPaymentApprovalPushContent(locale, approval),
16129
17434
  }).catch((error) => {
16130
17435
  console.error(`[hazbase-wallet-payment-push] ${approval.requestKey} | ${error.message}`);
16131
17436
  });
@@ -16215,10 +17520,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16215
17520
  stableId: pendingApprovalStableId(approval),
16216
17521
  title: approval.title,
16217
17522
  body: approval.messageText,
16218
- buildLocalizedContent: () => ({
16219
- title: approval.title,
16220
- body: approval.messageText,
16221
- }),
17523
+ buildLocalizedContent: ({ locale }) => localizedPaymentApprovalPushContent(locale, approval),
16222
17524
  }).catch((error) => {
16223
17525
  console.error(`[payment-approval-push] ${approval.requestKey} | ${error.message}`);
16224
17526
  });
@@ -16252,6 +17554,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16252
17554
  return writeJson(res, 403, { approved: false, decision: "decline", error: "payment-approval-declined" });
16253
17555
  }
16254
17556
 
17557
+ if (url.pathname === "/api/providers/mcp/events" && req.method === "POST") {
17558
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
17559
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
17560
+ return writeJson(res, 401, { error: "unauthorized" });
17561
+ }
17562
+
17563
+ let body;
17564
+ try {
17565
+ body = await parseJsonBody(req);
17566
+ } catch {
17567
+ return writeJson(res, 400, { error: "invalid-json-body" });
17568
+ }
17569
+
17570
+ return handleMcpProviderEvent({ config, runtime, state, body, res });
17571
+ }
17572
+
16255
17573
  if (url.pathname === "/api/providers/claude/events" && req.method === "POST") {
16256
17574
  const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
16257
17575
  if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
@@ -16494,7 +17812,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16494
17812
  body: approval.messageText,
16495
17813
  buildLocalizedContent: ({ locale }) => ({
16496
17814
  title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
16497
- body: approval.messageText,
17815
+ body: formatNativeApprovalMessage(approval.kind, approval.rawParams, locale),
16498
17816
  }),
16499
17817
  }).catch(() => {});
16500
17818
 
@@ -16623,6 +17941,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16623
17941
  stableId: `moltbook_reply:${item.sourceId}`,
16624
17942
  title: item.title,
16625
17943
  body: item.summary,
17944
+ buildLocalizedContent: ({ locale }) => localizedMoltbookReplyPushContent(locale, item),
16626
17945
  });
16627
17946
  } catch (error) {
16628
17947
  console.error(`[moltbook-push-error] ${error.message}`);
@@ -16782,20 +18101,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16782
18101
  console.error(`[moltbook-draft-timeline-save] ${error.message}`);
16783
18102
  }
16784
18103
  try {
16785
- const pushTitle = (() => {
16786
- if (draftType !== "original_post") {
16787
- return postTitle ? `draft → ${postTitle}` : "Moltbook draft";
16788
- }
16789
- const greetings = {
16790
- morning: { en: "Good morning! Share yesterday's highlights?", ja: "おはようございます!昨日の成果をシェアしませんか?" },
16791
- noon: { en: "Nice progress! Ready to share your morning work?", ja: "午前中お疲れ様でした!進捗をシェアしませんか?" },
16792
- evening: { en: "Great work today! Let's share what you built.", ja: "今日もお疲れ様でした!成果をシェアしませんか?" },
16793
- };
16794
- const locale = config.locale || "en";
16795
- const lang = locale.startsWith("ja") ? "ja" : "en";
16796
- const g = greetings[slot];
16797
- return g ? g[lang] : (postTitle || "Moltbook new post");
16798
- })();
18104
+ const pushTitle = localizedMoltbookDraftPushTitle(config.defaultLocale, { draftType, postTitle, slot });
16799
18105
  await deliverWebPushItem({
16800
18106
  config,
16801
18107
  state,
@@ -16806,6 +18112,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16806
18112
  stableId: `moltbook_draft:${sourceId}`,
16807
18113
  title: pushTitle,
16808
18114
  body: draftType === "original_post" ? (postTitle || String(draftText).slice(0, 160)) : String(draftText).slice(0, 160),
18115
+ buildLocalizedContent: ({ locale }) => ({
18116
+ title: localizedMoltbookDraftPushTitle(locale, { draftType, postTitle, slot }),
18117
+ body: draftType === "original_post" ? (postTitle || pushBodySnippet(draftText)) : pushBodySnippet(draftText),
18118
+ }),
16809
18119
  });
16810
18120
  } catch (error) {
16811
18121
  console.error(`[moltbook-draft-push-error] ${error.message}`);
@@ -16903,6 +18213,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16903
18213
  stableId: `moltbook_draft_failed:${draft.sourceId}`,
16904
18214
  title: "Draft post failed",
16905
18215
  body: String(postError.message || "").slice(0, 160),
18216
+ buildLocalizedContent: ({ locale }) => ({
18217
+ title: t(locale, "server.push.moltbookDraft.failedTitle"),
18218
+ body: pushBodySnippet(postError.message || ""),
18219
+ }),
16906
18220
  });
16907
18221
  } catch { /* ignore push error */ }
16908
18222
  }
@@ -16944,6 +18258,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16944
18258
  return writeJson(res, 200, buildTimelineResponse(runtime, state, config, locale));
16945
18259
  }
16946
18260
 
18261
+ if (url.pathname === "/api/timeline/stream" && req.method === "GET") {
18262
+ const session = requireApiSession(req, res, config, state);
18263
+ if (!session) {
18264
+ return;
18265
+ }
18266
+ return openTimelineStream({ config, runtime, req, res, session });
18267
+ }
18268
+
16947
18269
  const apiTimelineImageMatch = url.pathname.match(/^\/api\/timeline\/([^/]+)\/images\/(\d+)$/u);
16948
18270
  if (apiTimelineImageMatch && req.method === "GET") {
16949
18271
  const token = decodeURIComponent(apiTimelineImageMatch[1]);
@@ -17012,7 +18334,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17012
18334
  const payload = contentType.includes("multipart/form-data")
17013
18335
  ? await stageCompletionReplyImages(config, req)
17014
18336
  : await parseJsonBody(req);
17015
- await handleCompletionReply({
18337
+ const replyResult = await handleCompletionReply({
17016
18338
  config,
17017
18339
  runtime,
17018
18340
  state,
@@ -17026,6 +18348,8 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17026
18348
  ok: true,
17027
18349
  planMode: payload?.planMode === true,
17028
18350
  imageCount: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths.length : 0,
18351
+ alreadyAccepted: replyResult?.alreadyAccepted === true,
18352
+ ackTimeout: replyResult?.ackTimeout === true,
17029
18353
  });
17030
18354
  } catch (error) {
17031
18355
  if (error.message === "completion-reply-empty") {
@@ -17066,6 +18390,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17066
18390
  const decision = apiApprovalDecisionMatch[2];
17067
18391
  const approval = runtime.nativeApprovalsByToken.get(token);
17068
18392
  if (!approval) {
18393
+ const historicalDecision = approvalDecisionFromHistoryItem(historyItemByToken(runtime, "approval", token));
18394
+ if (historicalDecision) {
18395
+ if (historicalDecision === decision) {
18396
+ return writeJson(res, 200, {
18397
+ ok: true,
18398
+ decision,
18399
+ alreadyHandled: true,
18400
+ });
18401
+ }
18402
+ return writeJson(res, 409, {
18403
+ error: "approval-already-handled",
18404
+ decision: historicalDecision,
18405
+ });
18406
+ }
17069
18407
  return writeJson(res, 404, { error: "approval-not-found" });
17070
18408
  }
17071
18409
  if (approval.resolved || approval.resolving) {
@@ -17127,7 +18465,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17127
18465
 
17128
18466
  approval.resolving = true;
17129
18467
  try {
17130
- if (approval.resolveClaudeWaiter) {
18468
+ if (approval.resolveMcpWaiter) {
18469
+ approval.resolveMcpWaiter({
18470
+ approved: true,
18471
+ decision: "answered",
18472
+ answers,
18473
+ answerText: reasonText,
18474
+ });
18475
+ } else if (approval.resolveClaudeWaiter) {
17131
18476
  approval.resolveClaudeWaiter({
17132
18477
  permissionDecision: "deny",
17133
18478
  permissionDecisionReason: reasonText,
@@ -17163,8 +18508,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17163
18508
  if (stateChanged) {
17164
18509
  await saveState(config.stateFile, state);
17165
18510
  }
17166
- console.log(`[claude-question-answer] ${approval.requestKey}`);
17167
- activateClaudeDesktopIfMac(req);
18511
+ console.log(`[${approval.provider === "mcp" ? "mcp" : "claude"}-question-answer] ${approval.requestKey}`);
18512
+ if (approval.provider === "claude") {
18513
+ activateClaudeDesktopIfMac(req);
18514
+ }
17168
18515
  return writeJson(res, 200, { ok: true });
17169
18516
  } catch (error) {
17170
18517
  approval.resolving = false;
@@ -19057,6 +20404,10 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
19057
20404
  stableId: `moltbook_draft_posted:${draft.sourceId}`,
19058
20405
  title: "Moltbook posted",
19059
20406
  body: truncate(singleLine(pushTitle), 160),
20407
+ buildLocalizedContent: ({ locale }) => ({
20408
+ title: t(locale, "server.push.moltbookDraft.postedTitle"),
20409
+ body: localizedMoltbookPostedBody(locale, draft, finalTitle),
20410
+ }),
19060
20411
  });
19061
20412
  } catch { /* ignore push error */ }
19062
20413
 
@@ -19082,6 +20433,10 @@ function buildConfig(cli) {
19082
20433
  a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
19083
20434
  a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
19084
20435
  a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
20436
+ a2aProModel: cleanText(process.env.A2A_PRO_MODEL || ""),
20437
+ a2aProPrice: cleanText(process.env.A2A_PRO_PRICE || ""),
20438
+ a2aProPayTo: cleanText(process.env.A2A_PRO_PAY_TO || ""),
20439
+ a2aProExpiresDays: cleanText(process.env.A2A_PRO_EXPIRES_DAYS || "7"),
19085
20440
  // Remote-pairing relay (PWA off-LAN). Off by default — bridges that
19086
20441
  // never leave the trusted LAN don't need to open an outbound connection
19087
20442
  // to the relay. Toggle with REMOTE_PAIRING_ENABLED=true in
@@ -19115,6 +20470,8 @@ function buildConfig(cli) {
19115
20470
  process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
19116
20471
  ),
19117
20472
  pollIntervalMs: numberEnv("POLL_INTERVAL_MS", 2500),
20473
+ timelineLiveSync: boolEnv("TIMELINE_LIVE_SYNC", true),
20474
+ timelineLiveDebounceMs: numberEnv("TIMELINE_LIVE_DEBOUNCE_MS", 75),
19118
20475
  replaySeconds: numberEnv("REPLAY_SECONDS", 300),
19119
20476
  sessionIndexRefreshMs: numberEnv("SESSION_INDEX_REFRESH_MS", 30000),
19120
20477
  directoryScanIntervalMs: numberEnv("DIRECTORY_SCAN_INTERVAL_MS", 30000),
@@ -19173,6 +20530,10 @@ function buildConfig(cli) {
19173
20530
  ipcSocketPath: resolvePath(process.env.CODEX_IPC_SOCKET_PATH || defaultIpcSocketPath()),
19174
20531
  ipcReconnectMs: numberEnv("IPC_RECONNECT_MS", 1500),
19175
20532
  ipcRequestTimeoutMs: numberEnv("IPC_REQUEST_TIMEOUT_MS", 12000),
20533
+ completionReplyAckTimeoutMs: numberEnv(
20534
+ "COMPLETION_REPLY_ACK_TIMEOUT_MS",
20535
+ DEFAULT_COMPLETION_REPLY_ACK_TIMEOUT_MS
20536
+ ),
19176
20537
  choicePageSize: numberEnv("CHOICE_PAGE_SIZE", 5),
19177
20538
  completionReplyImageMaxBytes: numberEnv(
19178
20539
  "COMPLETION_REPLY_IMAGE_MAX_BYTES",
@@ -20109,16 +21470,20 @@ function extractRolloutMessageText(content) {
20109
21470
  if (!Array.isArray(content)) {
20110
21471
  return "";
20111
21472
  }
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
- );
21473
+ // The per-entry text is already passed through `normalizeTimelineMessageText`
21474
+ // (which preserves newlines for the markdown renderer). The final wrap used
21475
+ // to be `cleanText`, which collapses every \s+ — newlines included — into
21476
+ // a single space, flattening tables / code fences / lists into one
21477
+ // illegible line. Use a plain trim instead so the structure survives.
21478
+ return content
21479
+ .map((entry) =>
21480
+ isPlainObject(entry) && (entry.type === "input_text" || entry.type === "output_text")
21481
+ ? normalizeTimelineMessageText(entry.text ?? "")
21482
+ : ""
21483
+ )
21484
+ .filter(Boolean)
21485
+ .join("\n")
21486
+ .trim();
20122
21487
  }
20123
21488
 
20124
21489
  function extractTitleOnlyJsonTitleFromRolloutContent(content) {
@@ -21091,11 +22456,13 @@ async function main() {
21091
22456
  `notifyPlans=${config.notifyPlans}`,
21092
22457
  `nativeApprovalServer=${config.nativeApprovalPublicBaseUrl}`,
21093
22458
  `pollMs=${config.pollIntervalMs}`,
22459
+ `timelineLive=${config.timelineLiveSync}`,
21094
22460
  `replaySeconds=${config.replaySeconds}`,
21095
22461
  ].join(" | ")
21096
22462
  );
21097
22463
 
21098
22464
  let approvalServer = null;
22465
+ let timelineLiveHandle = null;
21099
22466
 
21100
22467
  process.on("SIGINT", handleSignal);
21101
22468
  process.on("SIGTERM", handleSignal);
@@ -21234,6 +22601,8 @@ async function main() {
21234
22601
  }
21235
22602
  }
21236
22603
 
22604
+ timelineLiveHandle = startTimelineLiveSync({ config, runtime, state });
22605
+
21237
22606
  // --- A2A Relay ---
21238
22607
  if (config.a2aRelayUrl && config.a2aRelayUserId) {
21239
22608
  config.a2aAcceptPublicTasks = state.a2aAcceptPublicTasks === true;
@@ -21366,6 +22735,13 @@ async function main() {
21366
22735
  } finally {
21367
22736
  runtime.stopping = true;
21368
22737
 
22738
+ if (timelineLiveHandle) {
22739
+ try {
22740
+ timelineLiveHandle.close();
22741
+ } catch {}
22742
+ timelineLiveHandle = null;
22743
+ }
22744
+
21369
22745
  stopRelayPolling();
21370
22746
 
21371
22747
  if (runtime.remotePairingHandle) {