viveworker 0.8.1 → 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.
@@ -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";
@@ -55,7 +55,7 @@ const sessionCookieName = "viveworker_session";
55
55
  const deviceCookieName = "viveworker_device";
56
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"]);
57
57
  const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
58
- 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"]);
59
59
  const SQLITE_COMPLETION_BATCH_SIZE = 200;
60
60
  const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
61
61
  const MAX_PAIRED_DEVICES = 200;
@@ -64,7 +64,9 @@ const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
64
64
  const REMOTE_PAIRING_RELAY_TOKEN_ROTATION_MS = 30 * 24 * 60 * 60 * 1000;
65
65
  const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
66
66
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
67
+ const DEFAULT_COMPLETION_REPLY_ACK_TIMEOUT_MS = 1200;
67
68
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
69
+ const COMPLETION_PUSH_CONTENT_DEDUPE_WINDOW_MS = 2 * 60 * 1000;
68
70
  const HAZBASE_METADATA_TIMEOUT_MS = 1500;
69
71
  const NPM_VERSION_CHECK_TIMEOUT_MS = 2500;
70
72
  const NPM_VERSION_CHECK_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
@@ -270,6 +272,15 @@ const runtime = {
270
272
  recentHistoryItems: [],
271
273
  recentTimelineEntries: [],
272
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(),
273
284
  pairingAttemptsByRemoteAddress: new Map(),
274
285
  ipcClient: null,
275
286
  remotePairingHandle: null,
@@ -517,6 +528,8 @@ function kindTitle(locale, kind) {
517
528
  return t(locale, "server.title.complete");
518
529
  case "file_event":
519
530
  return t(locale, "common.fileEvent");
531
+ case "command_event":
532
+ return t(locale, "common.commandEvent");
520
533
  case "diff_thread":
521
534
  return t(locale, "common.diff");
522
535
  case "a2a_task":
@@ -672,6 +685,118 @@ function formatLocalizedTitle(locale, baseKeyOrTitle, threadLabel) {
672
685
  return formatTitle(baseTitle, threadLabel);
673
686
  }
674
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
+
675
800
  function notificationIconPrefix(kind) {
676
801
  switch (kind) {
677
802
  case "approval":
@@ -704,13 +829,17 @@ function normalizeTimelineOutcome(value) {
704
829
 
705
830
  function normalizeTimelineFileEventType(value) {
706
831
  const normalized = cleanText(value || "").toLowerCase();
707
- return ["read", "write", "create", "delete", "rename"].includes(normalized) ? normalized : "";
832
+ return ["read", "search", "command", "write", "create", "delete", "rename"].includes(normalized) ? normalized : "";
708
833
  }
709
834
 
710
835
  function fileEventTitle(locale, fileEventType) {
711
836
  switch (normalizeTimelineFileEventType(fileEventType)) {
712
837
  case "read":
713
838
  return t(locale, "fileEvent.read");
839
+ case "search":
840
+ return t(locale, "fileEvent.search");
841
+ case "command":
842
+ return t(locale, "fileEvent.command");
714
843
  case "write":
715
844
  return t(locale, "fileEvent.write");
716
845
  case "create":
@@ -729,6 +858,10 @@ function fileEventDetailCopy(locale, fileEventType, provider) {
729
858
  switch (normalizeTimelineFileEventType(fileEventType)) {
730
859
  case "read":
731
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);
732
865
  case "write":
733
866
  return t(locale, "detail.fileEvent.write", vars);
734
867
  case "create":
@@ -1436,6 +1569,87 @@ function extractCommandLineFromFunctionOutput(outputText) {
1436
1569
  return unwrapShellCommand(match?.[1] || "");
1437
1570
  }
1438
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
+
1439
1653
  function extractReadFileRefsFromCommand(commandText) {
1440
1654
  const normalizedCommand = unwrapShellCommand(commandText);
1441
1655
  const tokens = tokenizeShellWords(normalizedCommand);
@@ -1664,6 +1878,36 @@ function rememberApplyPatchInput(fileState, payload, createdAtMs = Date.now()) {
1664
1878
  }
1665
1879
  }
1666
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
+
1667
1911
  async function findStoredApplyPatchInput({ fileState, callId, rolloutFilePath }) {
1668
1912
  const normalizedCallId = cleanText(callId || "");
1669
1913
  if (!normalizedCallId) {
@@ -2068,6 +2312,7 @@ async function ensureRolloutFileState(runtime, threadId, rolloutFilePath) {
2068
2312
  threadId: cleanText(threadId || ""),
2069
2313
  cwd: await findRolloutThreadCwd(runtime, threadId || ""),
2070
2314
  applyPatchInputsByCallId: new Map(),
2315
+ toolEventInputsByCallId: new Map(),
2071
2316
  startupCutoffMs: 0,
2072
2317
  skipPartialLine: false,
2073
2318
  };
@@ -3141,6 +3386,8 @@ function timelineKindSortPriority(kind) {
3141
3386
  return 60;
3142
3387
  case "ambient_suggestions":
3143
3388
  return 52;
3389
+ case "command_event":
3390
+ return 46;
3144
3391
  case "file_event":
3145
3392
  return 45;
3146
3393
  case "assistant_final":
@@ -3163,7 +3410,9 @@ function normalizeTimelineEntry(raw) {
3163
3410
  }
3164
3411
 
3165
3412
  const stableId = cleanText(raw.stableId ?? raw.id ?? "");
3166
- 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;
3167
3416
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
3168
3417
  if (!stableId || !timelineKinds.has(kind)) {
3169
3418
  return null;
@@ -3172,7 +3421,6 @@ function normalizeTimelineEntry(raw) {
3172
3421
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
3173
3422
  const rawMessageText = raw.messageText ?? "";
3174
3423
  const messageText = normalizeTimelineMessageText(rawMessageText);
3175
- const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
3176
3424
  const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
3177
3425
  const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
3178
3426
  const diffCounts = diffLineCounts(diffText);
@@ -3264,7 +3512,78 @@ function normalizeTimelineEntry(raw) {
3264
3512
  return shouldHideInternalTimelineItem(normalized) ? null : normalized;
3265
3513
  }
3266
3514
 
3267
- 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" }) {
3268
3587
  const normalized = normalizeTimelineEntry(entry);
3269
3588
  if (!normalized) {
3270
3589
  return false;
@@ -3281,6 +3600,10 @@ function recordTimelineEntry({ config, runtime, state, entry }) {
3281
3600
  const changed = timelineProjectionChanged(nextItems, runtime.recentTimelineEntries, [
3282
3601
  "stableId",
3283
3602
  "title",
3603
+ "summary",
3604
+ "messageText",
3605
+ "fileEventType",
3606
+ "fileRefs",
3284
3607
  "createdAtMs",
3285
3608
  "diffAvailable",
3286
3609
  "diffSource",
@@ -3291,6 +3614,9 @@ function recordTimelineEntry({ config, runtime, state, entry }) {
3291
3614
  ]);
3292
3615
  runtime.recentTimelineEntries = nextItems;
3293
3616
  state.recentTimelineEntries = nextItems;
3617
+ if (changed) {
3618
+ publishTimelineUpdate({ config, runtime, entry: normalized, source });
3619
+ }
3294
3620
  return changed;
3295
3621
  }
3296
3622
 
@@ -3411,6 +3737,18 @@ function historyItemFromEvent(event) {
3411
3737
  });
3412
3738
  }
3413
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
+
3414
3752
  function recordHistoryItem({ config, runtime, state, item }) {
3415
3753
  const normalized = normalizeHistoryItem(item);
3416
3754
  if (!normalized) {
@@ -3573,6 +3911,7 @@ function recordActionHistoryItem({
3573
3911
  runtime,
3574
3912
  state,
3575
3913
  entry: item,
3914
+ source: "event",
3576
3915
  });
3577
3916
  return historyChanged || timelineChanged;
3578
3917
  }
@@ -3732,7 +4071,20 @@ function pushDeliveryKey(deviceId, stableId) {
3732
4071
  return `${cleanText(deviceId || "")}:${cleanText(stableId || "")}`;
3733
4072
  }
3734
4073
 
3735
- 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
+ }) {
3736
4088
  if (!config.webPushEnabled || config.dryRun) {
3737
4089
  return false;
3738
4090
  }
@@ -3749,8 +4101,18 @@ async function deliverWebPushItem({ config, state, kind, token, stableId, title,
3749
4101
  let changed = false;
3750
4102
 
3751
4103
  for (const subscription of subscriptions) {
3752
- const deliveryKey = pushDeliveryKey(subscription.deviceId, stableId);
3753
- 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
+ ) {
3754
4116
  continue;
3755
4117
  }
3756
4118
 
@@ -3778,8 +4140,10 @@ async function deliverWebPushItem({ config, state, kind, token, stableId, title,
3778
4140
  },
3779
4141
  payload
3780
4142
  );
3781
- const now = Date.now();
3782
- state.pushDeliveries[deliveryKey] = now;
4143
+ state.pushDeliveries[stableDeliveryKey] = now;
4144
+ if (dedupeDeliveryKey !== stableDeliveryKey) {
4145
+ state.pushDeliveries[dedupeDeliveryKey] = now;
4146
+ }
3783
4147
  trimSeenEvents(state.pushDeliveries, config.maxSeenEvents * 4);
3784
4148
  const stored = normalizePushSubscriptionRecord(state.pushSubscriptions?.[subscription.id]);
3785
4149
  if (stored) {
@@ -3931,6 +4295,207 @@ async function scanOnce({ config, runtime, state }) {
3931
4295
  return dirty;
3932
4296
  }
3933
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
+
3934
4499
  async function processRolloutFile({ filePath, config, runtime, state, now }) {
3935
4500
  let stat;
3936
4501
  try {
@@ -3954,6 +4519,7 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
3954
4519
  threadId: extractThreadIdFromRolloutPath(filePath),
3955
4520
  cwd: null,
3956
4521
  applyPatchInputsByCallId: new Map(),
4522
+ toolEventInputsByCallId: new Map(),
3957
4523
  startupCutoffMs:
3958
4524
  typeof restoredOffset === "number" ? 0 : now - config.replaySeconds * 1000,
3959
4525
  skipPartialLine:
@@ -4031,6 +4597,7 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
4031
4597
  runtime,
4032
4598
  state,
4033
4599
  entry: timelineEntry,
4600
+ source: "codex-rollout",
4034
4601
  }) || dirty;
4035
4602
  }
4036
4603
 
@@ -4048,6 +4615,7 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
4048
4615
  runtime,
4049
4616
  state,
4050
4617
  entry: fileTimelineEntry,
4618
+ source: "codex-rollout",
4051
4619
  }) || dirty;
4052
4620
  dirty =
4053
4621
  recordCodeEvent({
@@ -4234,6 +4802,109 @@ async function listClaudeTranscriptFiles(claudeProjectsDir, maxAgeMs = 0) {
4234
4802
  }
4235
4803
  }
4236
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
+
4237
4908
  async function processClaudeTranscriptFile({ filePath, config, runtime, state, now }) {
4238
4909
  let fileState = runtime.claudeFileStates.get(filePath);
4239
4910
  if (!fileState) {
@@ -4349,6 +5020,20 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4349
5020
  const claudeTitle = threadId ? runtime.claudeSessionTitles.get(threadId) || "" : "";
4350
5021
  const threadLabel = claudeTitle || fileState.threadLabel || "";
4351
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
+
4352
5037
  let text = "";
4353
5038
  if (typeof content === "string") {
4354
5039
  text = content;
@@ -4404,7 +5089,7 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4404
5089
  provider: "claude",
4405
5090
  });
4406
5091
  if (entry) {
4407
- dirty = recordTimelineEntry({ config, runtime, state, entry }) || dirty;
5092
+ dirty = recordTimelineEntry({ config, runtime, state, entry, source: "claude-transcript" }) || dirty;
4408
5093
 
4409
5094
  // Also record Claude assistant_final as a history item for the completed list
4410
5095
  if (kind === "assistant_final") {
@@ -4713,6 +5398,7 @@ async function processSqliteTimelineLog({ config, runtime, state, now }) {
4713
5398
  runtime,
4714
5399
  state,
4715
5400
  entry,
5401
+ source: "codex-sqlite",
4716
5402
  }) || dirty;
4717
5403
 
4718
5404
  // Also record Codex assistant_final as a history item so it appears
@@ -4864,6 +5550,7 @@ async function processHistoryTimelineFile({ config, runtime, state, now }) {
4864
5550
  runtime,
4865
5551
  state,
4866
5552
  entry,
5553
+ source: "codex-history",
4867
5554
  }) || dirty;
4868
5555
  }
4869
5556
 
@@ -5038,6 +5725,7 @@ async function backfillRecentTimelineEntryDiffs({ config, runtime, state }) {
5038
5725
  threadId: cleanText(entry?.threadId || ""),
5039
5726
  cwd: await findRolloutThreadCwd(runtime, entry?.threadId || ""),
5040
5727
  applyPatchInputsByCallId: new Map(),
5728
+ toolEventInputsByCallId: new Map(),
5041
5729
  startupCutoffMs: 0,
5042
5730
  skipPartialLine: false,
5043
5731
  };
@@ -5610,6 +6298,47 @@ function buildRolloutUserTimelineEntry({ record, fileState, runtime }) {
5610
6298
  });
5611
6299
  }
5612
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
+
5613
6342
  async function buildRolloutFileTimelineEntries({ config, record, fileState, runtime, rolloutFilePath = "" }) {
5614
6343
  if (!isPlainObject(record) || cleanText(record.type) !== "response_item") {
5615
6344
  return [];
@@ -5630,30 +6359,54 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
5630
6359
  cwd: fileState.cwd || "",
5631
6360
  });
5632
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
+
5633
6385
  if (payloadType === "custom_tool_call") {
5634
6386
  rememberApplyPatchInput(fileState, payload, createdAtMs);
5635
6387
  return [];
5636
6388
  }
5637
6389
 
5638
6390
  if (payloadType === "function_call_output") {
6391
+ if (findStoredToolEventInput(fileState, callId)) {
6392
+ return [];
6393
+ }
5639
6394
  const commandText = extractCommandLineFromFunctionOutput(payload.output ?? "");
5640
- const fileRefs = extractReadFileRefsFromCommand(commandText);
5641
- if (fileRefs.length === 0) {
6395
+ if (!commandText) {
5642
6396
  return [];
5643
6397
  }
6398
+ const classified = classifyTimelineCommand(commandText);
5644
6399
  return [
5645
- normalizeTimelineEntry({
5646
- stableId: `file_event:read:${threadId}:${callId || historyToken(`${threadId}:${createdAtMs}:${fileRefs.join("|")}`)}`,
5647
- token: historyToken(`file_event:read:${threadId}:${callId || createdAtMs}`),
5648
- kind: "file_event",
5649
- fileEventType: "read",
6400
+ buildToolTimelineEntry({
6401
+ provider: "codex",
5650
6402
  threadId,
5651
6403
  threadLabel,
5652
- title: fileEventTitle(DEFAULT_LOCALE, "read"),
5653
- summary: "",
5654
- fileRefs,
6404
+ callId,
5655
6405
  createdAtMs,
5656
- readOnly: true,
6406
+ fileEventType: classified.fileEventType,
6407
+ commandText: classified.commandText || commandText,
6408
+ fileRefs: classified.fileRefs,
6409
+ cwd: fileState.cwd || "",
5657
6410
  }),
5658
6411
  ].filter(Boolean);
5659
6412
  }
@@ -5884,6 +6637,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
5884
6637
  subtab: "completed",
5885
6638
  token: historyToken(event.id),
5886
6639
  stableId: event.id,
6640
+ dedupeId: completionPushContentDedupeId(event),
6641
+ dedupeWindowMs: COMPLETION_PUSH_CONTENT_DEDUPE_WINDOW_MS,
5887
6642
  title: event.title,
5888
6643
  body: event.detailText || event.message,
5889
6644
  buildLocalizedContent: ({ locale }) => ({
@@ -6102,6 +6857,8 @@ function buildRolloutEvent({ record, filePath, fileState, sessionIndex, config,
6102
6857
  threadLabel: context.threadLabel,
6103
6858
  message,
6104
6859
  detailText,
6860
+ threadId,
6861
+ turnId,
6105
6862
  priority: config.completePriority,
6106
6863
  tags: config.completeTags,
6107
6864
  clickUrl: config.clickUrl,
@@ -6335,7 +7092,7 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
6335
7092
  body: approval.messageText,
6336
7093
  buildLocalizedContent: ({ locale }) => ({
6337
7094
  title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
6338
- body: approval.messageText,
7095
+ body: formatNativeApprovalMessage(approval.kind, approval.rawParams, locale),
6339
7096
  }),
6340
7097
  }) || stateChanged;
6341
7098
  }
@@ -6671,7 +7428,11 @@ async function syncGenericUserInputRequests({
6671
7428
  userInputRequest.supported ? "server.title.choice" : "server.title.choiceReadOnly",
6672
7429
  userInputRequest.threadLabel
6673
7430
  ),
6674
- body: userInputRequest.notificationText || userInputRequest.messageText,
7431
+ body: buildUserInputNotificationText(
7432
+ userInputRequest.questions,
7433
+ userInputRequest.supported,
7434
+ locale
7435
+ ),
6675
7436
  }),
6676
7437
  }) || stateChanged;
6677
7438
  }
@@ -8469,7 +9230,7 @@ class NativeIpcClient {
8469
9230
  );
8470
9231
  }
8471
9232
 
8472
- async startTurn(conversationId, turnStartParams, ownerClientId = null) {
9233
+ async startTurn(conversationId, turnStartParams, ownerClientId = null, options = {}) {
8473
9234
  return this.sendThreadFollowerRequest(
8474
9235
  "thread-follower-start-turn",
8475
9236
  {
@@ -8477,11 +9238,12 @@ class NativeIpcClient {
8477
9238
  turnStartParams,
8478
9239
  },
8479
9240
  conversationId,
8480
- ownerClientId
9241
+ ownerClientId,
9242
+ options
8481
9243
  );
8482
9244
  }
8483
9245
 
8484
- async startTurnDirect(conversationId, turnStartParams, ownerClientId = null) {
9246
+ async startTurnDirect(conversationId, turnStartParams, ownerClientId = null, options = {}) {
8485
9247
  const targetClientId =
8486
9248
  ownerClientId ??
8487
9249
  this.runtime.threadOwnerClientIds.get(conversationId) ??
@@ -8489,7 +9251,7 @@ class NativeIpcClient {
8489
9251
  return this.sendRequest(
8490
9252
  "turn/start",
8491
9253
  buildDirectTurnStartPayload(conversationId, turnStartParams),
8492
- { targetClientId }
9254
+ { targetClientId, ...options }
8493
9255
  );
8494
9256
  }
8495
9257
 
@@ -8519,12 +9281,13 @@ class NativeIpcClient {
8519
9281
  );
8520
9282
  }
8521
9283
 
8522
- sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null) {
9284
+ sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null, options = {}) {
8523
9285
  return this.sendRequest(method, params, {
8524
9286
  targetClientId:
8525
9287
  ownerClientId ??
8526
9288
  this.runtime.threadOwnerClientIds.get(conversationId) ??
8527
9289
  null,
9290
+ ...options,
8528
9291
  });
8529
9292
  }
8530
9293
 
@@ -10866,6 +11629,10 @@ async function handleMcpProviderEvent({ config, runtime, state, body, res }) {
10866
11629
  stableId,
10867
11630
  title,
10868
11631
  body: messageText,
11632
+ buildLocalizedContent: ({ locale }) => ({
11633
+ title: localizedMcpNotifyTitle(locale, title),
11634
+ body: pushBodySnippet(messageText),
11635
+ }),
10869
11636
  }).catch((error) => {
10870
11637
  console.error(`[mcp-notify-push] ${error.message}`);
10871
11638
  return false;
@@ -10897,10 +11664,7 @@ async function handleMcpProviderEvent({ config, runtime, state, body, res }) {
10897
11664
  stableId: pendingApprovalStableId(approval),
10898
11665
  title: approval.title || "MCP",
10899
11666
  body: approval.messageText,
10900
- buildLocalizedContent: () => ({
10901
- title: approval.title || "MCP",
10902
- body: approval.messageText,
10903
- }),
11667
+ buildLocalizedContent: ({ locale }) => localizedMcpApprovalPushContent(locale, approval),
10904
11668
  }).catch((error) => {
10905
11669
  console.error(`[mcp-approval-push] ${approval.requestKey} | ${error.message}`);
10906
11670
  });
@@ -11265,6 +12029,17 @@ function historyItemByToken(runtime, kind, token) {
11265
12029
  ) ?? null;
11266
12030
  }
11267
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
+
11268
12043
  function listLatestPersistedUserInputRequests({ config, runtime, state }) {
11269
12044
  const byToken = new Map();
11270
12045
  for (const userInputRequest of runtime.userInputRequestsByToken.values()) {
@@ -12338,6 +13113,48 @@ function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
12338
13113
  .sort((left, right) => Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0))[0] || null;
12339
13114
  }
12340
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
+
12341
13158
  function buildHistoryDetail(item, locale, runtime = null) {
12342
13159
  const threadId = historyItemThreadId(item);
12343
13160
  const replyEnabled =
@@ -12455,6 +13272,13 @@ function buildAmbientSuggestionsDetail(entry, locale) {
12455
13272
 
12456
13273
  function buildTimelineFileEventDetail(entry, locale) {
12457
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");
12458
13282
  return {
12459
13283
  kind: "file_event",
12460
13284
  token: entry.token,
@@ -12463,9 +13287,10 @@ function buildTimelineFileEventDetail(entry, locale) {
12463
13287
  threadLabel: entry.threadLabel || "",
12464
13288
  fileEventType,
12465
13289
  createdAtMs: Number(entry.createdAtMs) || 0,
12466
- 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>`),
12467
13291
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
12468
13292
  previousFileRefs: normalizeTimelineFileRefs(entry.previousFileRefs ?? []),
13293
+ ...(commandText ? { commandText } : {}),
12469
13294
  diffAvailable: Boolean(entry.diffAvailable),
12470
13295
  diffText: normalizeTimelineDiffText(entry.diffText ?? ""),
12471
13296
  diffSource: normalizeTimelineDiffSource(entry.diffSource ?? ""),
@@ -12476,6 +13301,25 @@ function buildTimelineFileEventDetail(entry, locale) {
12476
13301
  };
12477
13302
  }
12478
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
+
12479
13323
  function buildDiffThreadDetail(group, locale) {
12480
13324
  const changedFileCount = Math.max(0, Number(group.changedFileCount) || 0);
12481
13325
  return {
@@ -12849,6 +13693,25 @@ async function handleCompletionReply({
12849
13693
  if (!conversationId) {
12850
13694
  throw new Error("completion-reply-unavailable");
12851
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
+
12852
13715
  // For assistant_final, check against timeline entries (avoids maxHistoryItems eviction).
12853
13716
  // For legacy completion, check against history items.
12854
13717
  const itemKind = cleanText(completionItem?.kind || "");
@@ -12939,30 +13802,32 @@ async function handleCompletionReply({
12939
13802
  await runtime.ipcClient.startTurnDirect(
12940
13803
  conversationId,
12941
13804
  candidate.turnStartParams,
12942
- ownerClientId
13805
+ ownerClientId,
13806
+ { timeoutMs: config.completionReplyAckTimeoutMs }
12943
13807
  );
12944
13808
  } else {
12945
13809
  await runtime.ipcClient.startTurn(
12946
13810
  conversationId,
12947
13811
  candidate.turnStartParams,
12948
- ownerClientId
13812
+ ownerClientId,
13813
+ { timeoutMs: config.completionReplyAckTimeoutMs }
12949
13814
  );
12950
13815
  }
12951
13816
  console.log(
12952
- `[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")}`
12953
13818
  );
12954
13819
  await finalizeReplyAccepted();
12955
- return;
13820
+ return { alreadyAccepted: false };
12956
13821
  } catch (error) {
12957
13822
  if (isStartTurnAckTimeout(error)) {
12958
13823
  // Codex can create the turn but fail to send the bridge an ACK before
12959
13824
  // its internal follower timeout fires. Retrying risks duplicate user
12960
13825
  // turns, so treat this as accepted and let the timeline catch up.
12961
13826
  console.log(
12962
- `[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)}`
12963
13828
  );
12964
13829
  await finalizeReplyAccepted();
12965
- return;
13830
+ return { alreadyAccepted: false, ackTimeout: true };
12966
13831
  }
12967
13832
  lastError = error;
12968
13833
  console.log(
@@ -13718,6 +14583,10 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13718
14583
  const entry = timelineEntryByToken(runtime, token, kind);
13719
14584
  return entry ? buildTimelineFileEventDetail(entry, locale) : null;
13720
14585
  }
14586
+ if (kind === "command_event") {
14587
+ const entry = timelineEntryByToken(runtime, token, kind);
14588
+ return entry ? buildTimelineCommandEventDetail(entry, locale) : null;
14589
+ }
13721
14590
  if (kind === "ambient_suggestions") {
13722
14591
  const entry = timelineEntryByToken(runtime, token, kind);
13723
14592
  if (entry) {
@@ -13899,6 +14768,8 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13899
14768
  provider: "a2a",
13900
14769
  instruction,
13901
14770
  messageText: responseText,
14771
+ viveworker: task?.viveworker || entry?.viveworker || {},
14772
+ paidDeliverable: task?.paidDeliverable || entry?.paidDeliverable || null,
13902
14773
  taskId: task?.id || "",
13903
14774
  taskStatus: task?.status || entry?.taskStatus || (kind === "a2a_task_result" ? "completed" : "submitted"),
13904
14775
  callerInfo: task?.callerInfo || {},
@@ -14168,7 +15039,7 @@ function buildWebAppHtml({ pairToken }) {
14168
15039
  <link rel="manifest" href="${escapeHtml(manifestHref)}">
14169
15040
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
14170
15041
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/viveworker-icon-192.png">
14171
- <link rel="stylesheet" href="/app.css">
15042
+ <link rel="stylesheet" href="/app.css?v=${escapeHtml(WEB_APP_BUILD_ID)}">
14172
15043
  <style>
14173
15044
  .boot-splash {
14174
15045
  position: fixed;
@@ -14378,6 +15249,107 @@ function logRemotePairingBootTrace(body, session, req) {
14378
15249
  }
14379
15250
  }
14380
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
+
14381
15353
  function recordRemotePairingAudit(event) {
14382
15354
  appendRemotePairingAuditEvent({
14383
15355
  atMs: Date.now(),
@@ -14550,7 +15522,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
14550
15522
  if (action === "approve") {
14551
15523
  // Resolve which executor to use.
14552
15524
  const available = runtime.a2aAvailableExecutors || { codex: false, claude: false };
14553
- const preference = requestedExecutor || state.a2aExecutorPreference || "ask";
15525
+ const preference = requestedExecutor || task.viveworker?.requestedExecutor || state.a2aExecutorPreference || "ask";
14554
15526
  let executor;
14555
15527
  if (preference === "codex" && available.codex) executor = "codex";
14556
15528
  else if (preference === "claude" && available.claude) executor = "claude";
@@ -15459,6 +16431,29 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15459
16431
  return writeJson(res, 200, { ok: true });
15460
16432
  }
15461
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
+
15462
16457
  // POST /api/remote-pairing/toggle — flip on/off without restart.
15463
16458
  //
15464
16459
  // Body: { enabled: boolean }
@@ -16064,6 +17059,13 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16064
17059
  stableId: `thread_share:${shareId}`,
16065
17060
  title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
16066
17061
  body: String(content).slice(0, 160),
17062
+ buildLocalizedContent: ({ locale }) => localizedThreadSharePushContent(locale, {
17063
+ sourceLabel,
17064
+ sourceTool,
17065
+ targetLabel,
17066
+ targetTool,
17067
+ body: content,
17068
+ }),
16067
17069
  });
16068
17070
  } catch (error) {
16069
17071
  console.error(`[thread-share-push] ${error.message}`);
@@ -16163,6 +17165,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16163
17165
  stableId: `thread_share_fallback:${share.shareId}`,
16164
17166
  title: "Thread Share: target unreachable",
16165
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
+ ),
16166
17172
  });
16167
17173
  } catch (error) {
16168
17174
  console.error(`[thread-share-fallback-push] ${error.message}`);
@@ -16255,7 +17261,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16255
17261
  rangeDate = d.toISOString().slice(0, 10);
16256
17262
  }
16257
17263
 
16258
- 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"]);
16259
17265
  const entries = (runtime.recentTimelineEntries || [])
16260
17266
  .filter((e) => e.createdAtMs >= rangeStart && e.createdAtMs < rangeEnd && relevantKinds.has(e.kind))
16261
17267
  .map((e) => ({
@@ -16424,7 +17430,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16424
17430
  stableId: pendingApprovalStableId(approval),
16425
17431
  title: approval.title,
16426
17432
  body: approval.messageText,
16427
- buildLocalizedContent: () => ({ title: approval.title, body: approval.messageText }),
17433
+ buildLocalizedContent: ({ locale }) => localizedPaymentApprovalPushContent(locale, approval),
16428
17434
  }).catch((error) => {
16429
17435
  console.error(`[hazbase-wallet-payment-push] ${approval.requestKey} | ${error.message}`);
16430
17436
  });
@@ -16514,10 +17520,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16514
17520
  stableId: pendingApprovalStableId(approval),
16515
17521
  title: approval.title,
16516
17522
  body: approval.messageText,
16517
- buildLocalizedContent: () => ({
16518
- title: approval.title,
16519
- body: approval.messageText,
16520
- }),
17523
+ buildLocalizedContent: ({ locale }) => localizedPaymentApprovalPushContent(locale, approval),
16521
17524
  }).catch((error) => {
16522
17525
  console.error(`[payment-approval-push] ${approval.requestKey} | ${error.message}`);
16523
17526
  });
@@ -16809,7 +17812,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16809
17812
  body: approval.messageText,
16810
17813
  buildLocalizedContent: ({ locale }) => ({
16811
17814
  title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
16812
- body: approval.messageText,
17815
+ body: formatNativeApprovalMessage(approval.kind, approval.rawParams, locale),
16813
17816
  }),
16814
17817
  }).catch(() => {});
16815
17818
 
@@ -16938,6 +17941,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16938
17941
  stableId: `moltbook_reply:${item.sourceId}`,
16939
17942
  title: item.title,
16940
17943
  body: item.summary,
17944
+ buildLocalizedContent: ({ locale }) => localizedMoltbookReplyPushContent(locale, item),
16941
17945
  });
16942
17946
  } catch (error) {
16943
17947
  console.error(`[moltbook-push-error] ${error.message}`);
@@ -17097,20 +18101,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17097
18101
  console.error(`[moltbook-draft-timeline-save] ${error.message}`);
17098
18102
  }
17099
18103
  try {
17100
- const pushTitle = (() => {
17101
- if (draftType !== "original_post") {
17102
- return postTitle ? `draft → ${postTitle}` : "Moltbook draft";
17103
- }
17104
- const greetings = {
17105
- morning: { en: "Good morning! Share yesterday's highlights?", ja: "おはようございます!昨日の成果をシェアしませんか?" },
17106
- noon: { en: "Nice progress! Ready to share your morning work?", ja: "午前中お疲れ様でした!進捗をシェアしませんか?" },
17107
- evening: { en: "Great work today! Let's share what you built.", ja: "今日もお疲れ様でした!成果をシェアしませんか?" },
17108
- };
17109
- const locale = config.locale || "en";
17110
- const lang = locale.startsWith("ja") ? "ja" : "en";
17111
- const g = greetings[slot];
17112
- return g ? g[lang] : (postTitle || "Moltbook new post");
17113
- })();
18104
+ const pushTitle = localizedMoltbookDraftPushTitle(config.defaultLocale, { draftType, postTitle, slot });
17114
18105
  await deliverWebPushItem({
17115
18106
  config,
17116
18107
  state,
@@ -17121,6 +18112,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17121
18112
  stableId: `moltbook_draft:${sourceId}`,
17122
18113
  title: pushTitle,
17123
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
+ }),
17124
18119
  });
17125
18120
  } catch (error) {
17126
18121
  console.error(`[moltbook-draft-push-error] ${error.message}`);
@@ -17218,6 +18213,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17218
18213
  stableId: `moltbook_draft_failed:${draft.sourceId}`,
17219
18214
  title: "Draft post failed",
17220
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
+ }),
17221
18220
  });
17222
18221
  } catch { /* ignore push error */ }
17223
18222
  }
@@ -17259,6 +18258,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17259
18258
  return writeJson(res, 200, buildTimelineResponse(runtime, state, config, locale));
17260
18259
  }
17261
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
+
17262
18269
  const apiTimelineImageMatch = url.pathname.match(/^\/api\/timeline\/([^/]+)\/images\/(\d+)$/u);
17263
18270
  if (apiTimelineImageMatch && req.method === "GET") {
17264
18271
  const token = decodeURIComponent(apiTimelineImageMatch[1]);
@@ -17327,7 +18334,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17327
18334
  const payload = contentType.includes("multipart/form-data")
17328
18335
  ? await stageCompletionReplyImages(config, req)
17329
18336
  : await parseJsonBody(req);
17330
- await handleCompletionReply({
18337
+ const replyResult = await handleCompletionReply({
17331
18338
  config,
17332
18339
  runtime,
17333
18340
  state,
@@ -17341,6 +18348,8 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17341
18348
  ok: true,
17342
18349
  planMode: payload?.planMode === true,
17343
18350
  imageCount: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths.length : 0,
18351
+ alreadyAccepted: replyResult?.alreadyAccepted === true,
18352
+ ackTimeout: replyResult?.ackTimeout === true,
17344
18353
  });
17345
18354
  } catch (error) {
17346
18355
  if (error.message === "completion-reply-empty") {
@@ -17381,6 +18390,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17381
18390
  const decision = apiApprovalDecisionMatch[2];
17382
18391
  const approval = runtime.nativeApprovalsByToken.get(token);
17383
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
+ }
17384
18407
  return writeJson(res, 404, { error: "approval-not-found" });
17385
18408
  }
17386
18409
  if (approval.resolved || approval.resolving) {
@@ -19381,6 +20404,10 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
19381
20404
  stableId: `moltbook_draft_posted:${draft.sourceId}`,
19382
20405
  title: "Moltbook posted",
19383
20406
  body: truncate(singleLine(pushTitle), 160),
20407
+ buildLocalizedContent: ({ locale }) => ({
20408
+ title: t(locale, "server.push.moltbookDraft.postedTitle"),
20409
+ body: localizedMoltbookPostedBody(locale, draft, finalTitle),
20410
+ }),
19384
20411
  });
19385
20412
  } catch { /* ignore push error */ }
19386
20413
 
@@ -19406,6 +20433,10 @@ function buildConfig(cli) {
19406
20433
  a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
19407
20434
  a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
19408
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"),
19409
20440
  // Remote-pairing relay (PWA off-LAN). Off by default — bridges that
19410
20441
  // never leave the trusted LAN don't need to open an outbound connection
19411
20442
  // to the relay. Toggle with REMOTE_PAIRING_ENABLED=true in
@@ -19439,6 +20470,8 @@ function buildConfig(cli) {
19439
20470
  process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
19440
20471
  ),
19441
20472
  pollIntervalMs: numberEnv("POLL_INTERVAL_MS", 2500),
20473
+ timelineLiveSync: boolEnv("TIMELINE_LIVE_SYNC", true),
20474
+ timelineLiveDebounceMs: numberEnv("TIMELINE_LIVE_DEBOUNCE_MS", 75),
19442
20475
  replaySeconds: numberEnv("REPLAY_SECONDS", 300),
19443
20476
  sessionIndexRefreshMs: numberEnv("SESSION_INDEX_REFRESH_MS", 30000),
19444
20477
  directoryScanIntervalMs: numberEnv("DIRECTORY_SCAN_INTERVAL_MS", 30000),
@@ -19497,6 +20530,10 @@ function buildConfig(cli) {
19497
20530
  ipcSocketPath: resolvePath(process.env.CODEX_IPC_SOCKET_PATH || defaultIpcSocketPath()),
19498
20531
  ipcReconnectMs: numberEnv("IPC_RECONNECT_MS", 1500),
19499
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
+ ),
19500
20537
  choicePageSize: numberEnv("CHOICE_PAGE_SIZE", 5),
19501
20538
  completionReplyImageMaxBytes: numberEnv(
19502
20539
  "COMPLETION_REPLY_IMAGE_MAX_BYTES",
@@ -21419,11 +22456,13 @@ async function main() {
21419
22456
  `notifyPlans=${config.notifyPlans}`,
21420
22457
  `nativeApprovalServer=${config.nativeApprovalPublicBaseUrl}`,
21421
22458
  `pollMs=${config.pollIntervalMs}`,
22459
+ `timelineLive=${config.timelineLiveSync}`,
21422
22460
  `replaySeconds=${config.replaySeconds}`,
21423
22461
  ].join(" | ")
21424
22462
  );
21425
22463
 
21426
22464
  let approvalServer = null;
22465
+ let timelineLiveHandle = null;
21427
22466
 
21428
22467
  process.on("SIGINT", handleSignal);
21429
22468
  process.on("SIGTERM", handleSignal);
@@ -21562,6 +22601,8 @@ async function main() {
21562
22601
  }
21563
22602
  }
21564
22603
 
22604
+ timelineLiveHandle = startTimelineLiveSync({ config, runtime, state });
22605
+
21565
22606
  // --- A2A Relay ---
21566
22607
  if (config.a2aRelayUrl && config.a2aRelayUserId) {
21567
22608
  config.a2aAcceptPublicTasks = state.a2aAcceptPublicTasks === true;
@@ -21694,6 +22735,13 @@ async function main() {
21694
22735
  } finally {
21695
22736
  runtime.stopping = true;
21696
22737
 
22738
+ if (timelineLiveHandle) {
22739
+ try {
22740
+ timelineLiveHandle.close();
22741
+ } catch {}
22742
+ timelineLiveHandle = null;
22743
+ }
22744
+
21697
22745
  stopRelayPolling();
21698
22746
 
21699
22747
  if (runtime.remotePairingHandle) {