viveworker 0.8.1 → 0.8.3

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";
@@ -21,7 +21,7 @@ import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "
21
21
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
22
22
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
23
23
  import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
24
- import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, recordPuzzleAttempt, listInboxItems } from "./moltbook-api.mjs";
24
+ import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, recordPuzzleAttempt, listInboxItems, updateInboxStatus, getMoltbookReplyQuotaState } from "./moltbook-api.mjs";
25
25
  import { startRemotePairingRelay, DEFAULT_RELAY_URL } from "./lib/remote-pairing/orchestrator.mjs";
26
26
  import { restartRemotePairingRelay, persistRemotePairingEnv, getRemotePairingStatus } from "./lib/remote-pairing/control.mjs";
27
27
  import { appendRemotePairingAuditEvent, readRemotePairingAuditEvents } from "./lib/remote-pairing/audit.mjs";
@@ -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, "activity_status", "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,10 +64,13 @@ 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;
73
+ const TIMELINE_ACTIVITY_TTL_MS = 2 * 60 * 1000;
71
74
 
72
75
  // Shared memo for buildDiffThreadGroups. Each call spawns 3 git subprocesses
73
76
  // per tracked repo (`git diff --name-status`, `git status --porcelain`,
@@ -263,6 +266,7 @@ const runtime = {
263
266
  completionDetailsByToken: new Map(),
264
267
  moltbookItemsByToken: new Map(),
265
268
  moltbookDraftsByToken: new Map(),
269
+ moltbookReplyQuotaQueue: Promise.resolve(),
266
270
  a2aTasksByToken: new Map(),
267
271
  threadSharesByToken: new Map(),
268
272
  threadRegistry: new Map(),
@@ -270,6 +274,16 @@ const runtime = {
270
274
  recentHistoryItems: [],
271
275
  recentTimelineEntries: [],
272
276
  recentCodeEvents: [],
277
+ activeTimelineActivitiesByKey: new Map(),
278
+ timelineBus: null,
279
+ timelineRevision: 0,
280
+ timelineLiveWatchers: [],
281
+ timelineLiveScanTimer: null,
282
+ timelineLiveScanInFlight: false,
283
+ timelineLiveScanReschedule: false,
284
+ timelineLiveScanReasons: new Set(),
285
+ timelineLiveClaudeFiles: new Set(),
286
+ timelineLiveRolloutFiles: new Set(),
273
287
  pairingAttemptsByRemoteAddress: new Map(),
274
288
  ipcClient: null,
275
289
  remotePairingHandle: null,
@@ -335,6 +349,58 @@ setInterval(async () => {
335
349
  }
336
350
  }
337
351
  }, 60_000).unref?.();
352
+
353
+ async function withMoltbookReplyQuotaLock(fn) {
354
+ const previous = runtime.moltbookReplyQuotaQueue || Promise.resolve();
355
+ let release;
356
+ const next = new Promise((resolve) => { release = resolve; });
357
+ runtime.moltbookReplyQuotaQueue = previous.then(() => next, () => next);
358
+ await previous.catch(() => {});
359
+ try {
360
+ return await fn();
361
+ } finally {
362
+ release();
363
+ }
364
+ }
365
+
366
+ async function reserveMoltbookReplyQuotaSlot(draft, maxDaily = 5) {
367
+ if (!draft || draft.draftType === "original_post") return { reserved: false };
368
+ return withMoltbookReplyQuotaLock(async () => {
369
+ const scoutState = rollScoutDayIfNeeded(await readScoutState());
370
+ if ((Number(scoutState.sentToday) || 0) >= maxDaily) {
371
+ return {
372
+ reserved: false,
373
+ quotaReached: true,
374
+ day: scoutState.day,
375
+ sentToday: Number(scoutState.sentToday) || 0,
376
+ maxDaily,
377
+ };
378
+ }
379
+ scoutState.sentToday = (Number(scoutState.sentToday) || 0) + 1;
380
+ markPostSeen(scoutState, draft.postId, "reserved");
381
+ await writeScoutState(scoutState);
382
+ draft.replyQuotaReserved = true;
383
+ draft.replyQuotaReservedDay = scoutState.day;
384
+ draft.replyQuotaReservedAtMs = Date.now();
385
+ return {
386
+ reserved: true,
387
+ day: scoutState.day,
388
+ sentToday: scoutState.sentToday,
389
+ maxDaily,
390
+ };
391
+ });
392
+ }
393
+
394
+ async function releaseMoltbookReplyQuotaSlot(draft) {
395
+ if (!draft?.replyQuotaReserved || draft.draftType === "original_post") return;
396
+ await withMoltbookReplyQuotaLock(async () => {
397
+ const scoutState = rollScoutDayIfNeeded(await readScoutState());
398
+ if (draft.replyQuotaReservedDay && scoutState.day !== draft.replyQuotaReservedDay) return;
399
+ scoutState.sentToday = Math.max(0, (Number(scoutState.sentToday) || 0) - 1);
400
+ await writeScoutState(scoutState);
401
+ });
402
+ draft.replyQuotaReserved = false;
403
+ }
338
404
  const initialHistoryItems = normalizeHistoryItems(state.recentHistoryItems ?? [], config.maxHistoryItems);
339
405
  const initialTimelineEntries = normalizeTimelineEntries(state.recentTimelineEntries ?? [], config.maxTimelineEntries);
340
406
  const normalizedHistoryStateChanged =
@@ -503,6 +569,8 @@ function kindTitle(locale, kind) {
503
569
  return t(locale, "server.title.assistantCommentary");
504
570
  case "assistant_final":
505
571
  return t(locale, "server.title.assistantFinal");
572
+ case "activity_status":
573
+ return t(locale, "server.title.activityStatus");
506
574
  case "ambient_suggestions":
507
575
  return t(locale, "server.title.ambientSuggestions");
508
576
  case "approval":
@@ -517,6 +585,8 @@ function kindTitle(locale, kind) {
517
585
  return t(locale, "server.title.complete");
518
586
  case "file_event":
519
587
  return t(locale, "common.fileEvent");
588
+ case "command_event":
589
+ return t(locale, "common.commandEvent");
520
590
  case "diff_thread":
521
591
  return t(locale, "common.diff");
522
592
  case "a2a_task":
@@ -672,6 +742,118 @@ function formatLocalizedTitle(locale, baseKeyOrTitle, threadLabel) {
672
742
  return formatTitle(baseTitle, threadLabel);
673
743
  }
674
744
 
745
+ function localizedThreadSharePushContent(locale, { sourceLabel = "", sourceTool = "", targetLabel = "", targetTool = "", body = "" } = {}) {
746
+ const source = cleanText(sourceLabel || sourceTool || "agent");
747
+ const target = cleanText(targetLabel || targetTool || "target");
748
+ return {
749
+ title: t(locale, "server.push.threadShare.title", { source, target }),
750
+ body: pushBodySnippet(body),
751
+ };
752
+ }
753
+
754
+ function localizedThreadShareFallbackPushContent(locale, target) {
755
+ const normalizedTarget = cleanText(target || "");
756
+ return {
757
+ title: t(locale, "server.push.threadShareFallback.title"),
758
+ body: t(locale, "server.push.threadShareFallback.body", { target: normalizedTarget }),
759
+ };
760
+ }
761
+
762
+ function localizedMcpNotifyTitle(locale, title) {
763
+ const normalizedTitle = cleanText(title || "");
764
+ if (!normalizedTitle || normalizedTitle === "MCP") {
765
+ return t(locale, "server.push.mcpNotify.title");
766
+ }
767
+ return normalizedTitle;
768
+ }
769
+
770
+ function localizedMcpApprovalPushContent(locale, approval) {
771
+ const kind = cleanText(approval?.kind || "mcp");
772
+ const title = cleanText(approval?.title || "");
773
+ const body = cleanText(approval?.messageText || "");
774
+ if (kind === "question") {
775
+ return {
776
+ title: t(locale, "server.push.mcpQuestion.title"),
777
+ body: pushBodySnippet(body),
778
+ };
779
+ }
780
+ if (kind === "file_share") {
781
+ const filePath = compactPath(normalizeTimelineFileRefs(approval?.fileRefs ?? [])[0] || t(locale, "common.fileEvent"));
782
+ const sizeMatch = body.match(/^\s*Size:\s*([^\n]+)$/imu);
783
+ return {
784
+ title: t(locale, "server.push.mcpShareFile.title"),
785
+ body: t(locale, "server.push.mcpShareFile.body", {
786
+ path: filePath,
787
+ size: cleanText(sizeMatch?.[1] || ""),
788
+ }),
789
+ };
790
+ }
791
+ if (kind === "a2a_task") {
792
+ const target = cleanText(title.match(/^Send A2A task to\s+(.+)$/iu)?.[1] || "A2A target");
793
+ return {
794
+ title: t(locale, "server.push.mcpA2aTask.title", { target }),
795
+ body: t(locale, "server.push.mcpA2aTask.body", { target }),
796
+ };
797
+ }
798
+ if (!title || title === "MCP approval") {
799
+ return {
800
+ title: t(locale, "server.push.mcpApproval.title"),
801
+ body: pushBodySnippet(body),
802
+ };
803
+ }
804
+ return { title, body: pushBodySnippet(body) };
805
+ }
806
+
807
+ function localizedPaymentApprovalPushContent(locale, approval) {
808
+ const payment = isPlainObject(approval?.rawParams) ? approval.rawParams : {};
809
+ const amount = cleanText(payment.amountUsdc || payment.amountAtomic || "");
810
+ const network = cleanText(payment.network || "");
811
+ const resource = cleanText(payment.resource || payment.url || "");
812
+ const titleKey = cleanText(approval?.kind || "") === "hazbase_wallet_payment"
813
+ ? "server.push.hazbasePayment.title"
814
+ : "server.push.paymentApproval.title";
815
+ return {
816
+ title: t(locale, titleKey, { amount }),
817
+ body: t(locale, "server.push.paymentApproval.body", { amount, network, resource }),
818
+ };
819
+ }
820
+
821
+ function localizedMoltbookReplyPushContent(locale, item) {
822
+ const title = cleanText(item?.title || "");
823
+ return {
824
+ title: !title || title === "Moltbook reply" ? t(locale, "server.push.moltbookReply.title") : title,
825
+ body: cleanText(item?.summary || "") || t(locale, "server.push.moltbookReply.body"),
826
+ };
827
+ }
828
+
829
+ function localizedMoltbookDraftPushTitle(locale, { draftType = "", postTitle = "", slot = "" } = {}) {
830
+ const normalizedPostTitle = cleanText(postTitle || "");
831
+ if (draftType !== "original_post") {
832
+ return t(locale, "server.push.moltbookDraft.replyTitle", { postTitle: normalizedPostTitle });
833
+ }
834
+ switch (cleanText(slot || "")) {
835
+ case "morning":
836
+ return t(locale, "server.push.moltbookDraft.originalMorning");
837
+ case "noon":
838
+ return t(locale, "server.push.moltbookDraft.originalNoon");
839
+ case "evening":
840
+ return t(locale, "server.push.moltbookDraft.originalEvening");
841
+ default:
842
+ return normalizedPostTitle || t(locale, "server.push.moltbookDraft.originalTitle");
843
+ }
844
+ }
845
+
846
+ function localizedMoltbookPostedBody(locale, draft, finalTitle) {
847
+ if (cleanText(draft?.draftType || "") === "original_post") {
848
+ return t(locale, "server.push.moltbookDraft.postedOriginalBody", { title: cleanText(finalTitle || draft?.postTitle || "") });
849
+ }
850
+ return t(locale, "server.push.moltbookDraft.postedReplyBody", { postTitle: cleanText(draft?.postTitle || "") });
851
+ }
852
+
853
+ function pushBodySnippet(value, maxChars = 160) {
854
+ return truncate(singleLine(value || ""), maxChars);
855
+ }
856
+
675
857
  function notificationIconPrefix(kind) {
676
858
  switch (kind) {
677
859
  case "approval":
@@ -704,13 +886,19 @@ function normalizeTimelineOutcome(value) {
704
886
 
705
887
  function normalizeTimelineFileEventType(value) {
706
888
  const normalized = cleanText(value || "").toLowerCase();
707
- return ["read", "write", "create", "delete", "rename"].includes(normalized) ? normalized : "";
889
+ return ["read", "search", "git", "command", "write", "create", "delete", "rename"].includes(normalized) ? normalized : "";
708
890
  }
709
891
 
710
892
  function fileEventTitle(locale, fileEventType) {
711
893
  switch (normalizeTimelineFileEventType(fileEventType)) {
712
894
  case "read":
713
895
  return t(locale, "fileEvent.read");
896
+ case "search":
897
+ return t(locale, "fileEvent.search");
898
+ case "git":
899
+ return t(locale, "fileEvent.command");
900
+ case "command":
901
+ return t(locale, "fileEvent.command");
714
902
  case "write":
715
903
  return t(locale, "fileEvent.write");
716
904
  case "create":
@@ -729,6 +917,12 @@ function fileEventDetailCopy(locale, fileEventType, provider) {
729
917
  switch (normalizeTimelineFileEventType(fileEventType)) {
730
918
  case "read":
731
919
  return t(locale, "detail.fileEvent.read", vars);
920
+ case "search":
921
+ return t(locale, "detail.fileEvent.search", vars);
922
+ case "git":
923
+ return t(locale, "detail.fileEvent.command", vars);
924
+ case "command":
925
+ return t(locale, "detail.fileEvent.command", vars);
732
926
  case "write":
733
927
  return t(locale, "detail.fileEvent.write", vars);
734
928
  case "create":
@@ -1398,7 +1592,7 @@ function evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot }) {
1398
1592
  return null;
1399
1593
  }
1400
1594
 
1401
- const command = cleanText(tokens[0]);
1595
+ const command = commandBaseName(tokens[0]);
1402
1596
  if (!command || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(command)) {
1403
1597
  return null;
1404
1598
  }
@@ -1436,6 +1630,86 @@ function extractCommandLineFromFunctionOutput(outputText) {
1436
1630
  return unwrapShellCommand(match?.[1] || "");
1437
1631
  }
1438
1632
 
1633
+ function parseToolArgumentsJson(value) {
1634
+ if (isPlainObject(value)) {
1635
+ return value;
1636
+ }
1637
+ if (typeof value !== "string" || !value.trim()) {
1638
+ return null;
1639
+ }
1640
+ const parsed = safeJsonParse(value);
1641
+ return isPlainObject(parsed) ? parsed : null;
1642
+ }
1643
+
1644
+ function commandBaseName(command) {
1645
+ const normalized = cleanText(command || "");
1646
+ if (!normalized) {
1647
+ return "";
1648
+ }
1649
+ return path.basename(normalized);
1650
+ }
1651
+
1652
+ function classifyTimelineCommand(commandText) {
1653
+ const normalizedCommand = unwrapShellCommand(commandText);
1654
+ const tokens = tokenizeShellWords(normalizedCommand);
1655
+ if (tokens.length === 0) {
1656
+ return {
1657
+ fileEventType: "command",
1658
+ command: "",
1659
+ commandText: normalizedCommand,
1660
+ fileRefs: [],
1661
+ };
1662
+ }
1663
+
1664
+ const command = commandBaseName(tokens[0]);
1665
+ const gitSubcommand = command === "git" ? cleanText(tokens[1] || "") : "";
1666
+ let fileEventType = "command";
1667
+ if (["rg", "grep", "ag", "ack", "fd", "find"].includes(command) || gitSubcommand === "grep") {
1668
+ fileEventType = "search";
1669
+ } else if (
1670
+ ["cat", "sed", "nl", "head", "tail", "wc", "ls", "pwd", "less", "more"].includes(command)
1671
+ ) {
1672
+ fileEventType = "read";
1673
+ }
1674
+
1675
+ return {
1676
+ fileEventType,
1677
+ command,
1678
+ commandText: normalizedCommand,
1679
+ fileRefs: extractReadFileRefsFromCommand(normalizedCommand),
1680
+ };
1681
+ }
1682
+
1683
+ function redactTimelineCommandText(commandText) {
1684
+ return cleanText(commandText || "")
1685
+ .replace(/((?:--)?(?:api[-_]?key|token|secret|password|credential)(?:=|\s+))(["']?)[^\s"']+\2/giu, "$1[redacted]")
1686
+ .replace(/\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*=)(["']?)[^\s"']+\2/giu, "$1[redacted]");
1687
+ }
1688
+
1689
+ function timelineCommandMessage(locale, { commandText = "", toolName = "", fileRefs = [] } = {}) {
1690
+ const commandBlock = commandText
1691
+ ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${redactTimelineCommandText(commandText)}\n\`\`\``
1692
+ : "";
1693
+ const toolBlock = !commandBlock && toolName
1694
+ ? `${t(locale, "server.message.toolLabel")}\n\`\`\`text\n${cleanText(toolName)}\n\`\`\``
1695
+ : "";
1696
+ const files = normalizeTimelineFileRefs(fileRefs);
1697
+ const filesBlock = files.length
1698
+ ? `${t(locale, "server.message.filesLabel")}\n\`\`\`text\n${files.join("\n")}\n\`\`\``
1699
+ : "";
1700
+ return [commandBlock || toolBlock, filesBlock].filter(Boolean).join("\n\n");
1701
+ }
1702
+
1703
+ function timelineCommandSummary(commandText, fallback = "") {
1704
+ const redacted = redactTimelineCommandText(commandText);
1705
+ return truncate(singleLine(redacted || fallback || ""), 180);
1706
+ }
1707
+
1708
+ function firstMarkdownCodeFenceText(text) {
1709
+ const match = String(text || "").match(/```(?:\w+)?\n([\s\S]*?)\n```/u);
1710
+ return cleanText(match?.[1] || "");
1711
+ }
1712
+
1439
1713
  function extractReadFileRefsFromCommand(commandText) {
1440
1714
  const normalizedCommand = unwrapShellCommand(commandText);
1441
1715
  const tokens = tokenizeShellWords(normalizedCommand);
@@ -1454,15 +1728,19 @@ function extractReadFileRefsFromCommand(commandText) {
1454
1728
  return [];
1455
1729
  }
1456
1730
 
1457
- if (command === "cat" || command === "nl") {
1458
- return normalizeTimelineFileRefs(tokens.slice(1).filter((token) => !String(token || "").startsWith("-")));
1731
+ if (command === "cat" || command === "nl" || command === "wc" || command === "less" || command === "more" || command === "ls") {
1732
+ return normalizeTimelineFileRefs(readCommandPathArgs(tokens));
1733
+ }
1734
+
1735
+ if (command === "head" || command === "tail") {
1736
+ return normalizeTimelineFileRefs(readCommandPathArgs(tokens, { valueOptions: new Set(["-n", "--lines", "-c", "--bytes"]) }));
1459
1737
  }
1460
1738
 
1461
1739
  if (command === "sed") {
1462
1740
  if (!tokens.includes("-n")) {
1463
1741
  return [];
1464
1742
  }
1465
- return normalizeTimelineFileRefs(tokens.slice(1).filter((token) => !String(token || "").startsWith("-")));
1743
+ return normalizeTimelineFileRefs(sedCommandPathArgs(tokens));
1466
1744
  }
1467
1745
 
1468
1746
  if (command === "rg") {
@@ -1493,6 +1771,57 @@ function extractReadFileRefsFromCommand(commandText) {
1493
1771
  return [];
1494
1772
  }
1495
1773
 
1774
+ function readCommandPathArgs(tokens, { valueOptions = new Set() } = {}) {
1775
+ const args = [];
1776
+ for (let index = 1; index < tokens.length; index += 1) {
1777
+ const token = cleanText(tokens[index] || "");
1778
+ if (!token) {
1779
+ continue;
1780
+ }
1781
+ if (token === "--") {
1782
+ args.push(...tokens.slice(index + 1));
1783
+ break;
1784
+ }
1785
+ if (token.startsWith("-")) {
1786
+ const optionName = token.includes("=") ? token.slice(0, token.indexOf("=")) : token;
1787
+ if (valueOptions.has(optionName) && !token.includes("=")) {
1788
+ index += 1;
1789
+ }
1790
+ continue;
1791
+ }
1792
+ args.push(token);
1793
+ }
1794
+ return args;
1795
+ }
1796
+
1797
+ function sedCommandPathArgs(tokens) {
1798
+ const args = [];
1799
+ let scriptSeen = false;
1800
+ for (let index = 1; index < tokens.length; index += 1) {
1801
+ const token = cleanText(tokens[index] || "");
1802
+ if (!token) {
1803
+ continue;
1804
+ }
1805
+ if (token === "--") {
1806
+ args.push(...tokens.slice(index + 1));
1807
+ break;
1808
+ }
1809
+ if (token.startsWith("-")) {
1810
+ if (token === "-e" || token === "-f") {
1811
+ index += 1;
1812
+ scriptSeen = true;
1813
+ }
1814
+ continue;
1815
+ }
1816
+ if (!scriptSeen) {
1817
+ scriptSeen = true;
1818
+ continue;
1819
+ }
1820
+ args.push(token);
1821
+ }
1822
+ return args;
1823
+ }
1824
+
1496
1825
  function autoPilotApprovalMessage(locale, commandText) {
1497
1826
  const prefix = t(locale, "server.message.autoPilotTrustedReadApproved");
1498
1827
  const commandBlock = cleanText(commandText || "")
@@ -1664,6 +1993,36 @@ function rememberApplyPatchInput(fileState, payload, createdAtMs = Date.now()) {
1664
1993
  }
1665
1994
  }
1666
1995
 
1996
+ function rememberToolEventInput(fileState, payload, createdAtMs = Date.now()) {
1997
+ const callId = cleanText(payload?.call_id || payload?.callId || payload?.id || "");
1998
+ if (!callId) {
1999
+ return null;
2000
+ }
2001
+ if (!(fileState.toolEventInputsByCallId instanceof Map)) {
2002
+ fileState.toolEventInputsByCallId = new Map();
2003
+ }
2004
+ const stored = {
2005
+ callId,
2006
+ name: cleanText(payload?.name || ""),
2007
+ arguments: parseToolArgumentsJson(payload?.arguments),
2008
+ createdAtMs: Number(createdAtMs) || Date.now(),
2009
+ };
2010
+ fileState.toolEventInputsByCallId.set(callId, stored);
2011
+ while (fileState.toolEventInputsByCallId.size > 128) {
2012
+ const oldestKey = fileState.toolEventInputsByCallId.keys().next().value;
2013
+ fileState.toolEventInputsByCallId.delete(oldestKey);
2014
+ }
2015
+ return stored;
2016
+ }
2017
+
2018
+ function findStoredToolEventInput(fileState, callId) {
2019
+ const normalizedCallId = cleanText(callId || "");
2020
+ if (!normalizedCallId || !(fileState?.toolEventInputsByCallId instanceof Map)) {
2021
+ return null;
2022
+ }
2023
+ return fileState.toolEventInputsByCallId.get(normalizedCallId) || null;
2024
+ }
2025
+
1667
2026
  async function findStoredApplyPatchInput({ fileState, callId, rolloutFilePath }) {
1668
2027
  const normalizedCallId = cleanText(callId || "");
1669
2028
  if (!normalizedCallId) {
@@ -2068,6 +2427,7 @@ async function ensureRolloutFileState(runtime, threadId, rolloutFilePath) {
2068
2427
  threadId: cleanText(threadId || ""),
2069
2428
  cwd: await findRolloutThreadCwd(runtime, threadId || ""),
2070
2429
  applyPatchInputsByCallId: new Map(),
2430
+ toolEventInputsByCallId: new Map(),
2071
2431
  startupCutoffMs: 0,
2072
2432
  skipPartialLine: false,
2073
2433
  };
@@ -3132,6 +3492,8 @@ function migrateRecentCodeEventsState({ config, runtime, state }) {
3132
3492
 
3133
3493
  function timelineKindSortPriority(kind) {
3134
3494
  switch (cleanText(kind || "")) {
3495
+ case "activity_status":
3496
+ return 90;
3135
3497
  case "completion":
3136
3498
  return 70;
3137
3499
  case "approval":
@@ -3141,6 +3503,8 @@ function timelineKindSortPriority(kind) {
3141
3503
  return 60;
3142
3504
  case "ambient_suggestions":
3143
3505
  return 52;
3506
+ case "command_event":
3507
+ return 46;
3144
3508
  case "file_event":
3145
3509
  return 45;
3146
3510
  case "assistant_final":
@@ -3163,7 +3527,9 @@ function normalizeTimelineEntry(raw) {
3163
3527
  }
3164
3528
 
3165
3529
  const stableId = cleanText(raw.stableId ?? raw.id ?? "");
3166
- const kind = cleanText(raw.kind ?? "");
3530
+ const rawKind = cleanText(raw.kind ?? "");
3531
+ const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
3532
+ const kind = rawKind === "file_event" && fileEventType === "command" ? "command_event" : rawKind;
3167
3533
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
3168
3534
  if (!stableId || !timelineKinds.has(kind)) {
3169
3535
  return null;
@@ -3172,7 +3538,6 @@ function normalizeTimelineEntry(raw) {
3172
3538
  const threadId = cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? "");
3173
3539
  const rawMessageText = raw.messageText ?? "";
3174
3540
  const messageText = normalizeTimelineMessageText(rawMessageText);
3175
- const fileEventType = normalizeTimelineFileEventType(raw.fileEventType ?? "");
3176
3541
  const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
3177
3542
  const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
3178
3543
  const diffCounts = diffLineCounts(diffText);
@@ -3242,6 +3607,8 @@ function normalizeTimelineEntry(raw) {
3242
3607
  tone: cleanText(raw.tone ?? "") || "secondary",
3243
3608
  cwd: resolvePath(cleanText(raw.cwd || "")),
3244
3609
  provider: normalizeProvider(raw.provider),
3610
+ ...(raw.activityPhase != null ? { activityPhase: cleanText(raw.activityPhase) } : {}),
3611
+ ...(raw.commandText != null ? { commandText: cleanText(raw.commandText) } : {}),
3245
3612
  // Moltbook-specific fields — preserved for detail view fallback after
3246
3613
  // the in-memory draft/item expires.
3247
3614
  ...(raw.draftText != null ? { draftText: cleanText(raw.draftText) } : {}),
@@ -3264,107 +3631,408 @@ function normalizeTimelineEntry(raw) {
3264
3631
  return shouldHideInternalTimelineItem(normalized) ? null : normalized;
3265
3632
  }
3266
3633
 
3267
- function recordTimelineEntry({ config, runtime, state, entry }) {
3268
- const normalized = normalizeTimelineEntry(entry);
3269
- if (!normalized) {
3270
- return false;
3634
+ class TimelineBus {
3635
+ constructor() {
3636
+ this.clients = new Set();
3271
3637
  }
3272
3638
 
3273
- const nextItems = normalizeTimelineEntries(
3274
- [normalized, ...runtime.recentTimelineEntries.filter((item) => item.stableId !== normalized.stableId)],
3275
- config.maxTimelineEntries
3276
- );
3277
- // Note: diffText is intentionally omitted from the projection — the
3278
- // line-count fields (`diffAddedLines` / `diffRemovedLines`) are a sufficient
3279
- // proxy for "diff content changed" and avoid materialising a megabyte-class
3280
- // intermediate string on every record call.
3281
- const changed = timelineProjectionChanged(nextItems, runtime.recentTimelineEntries, [
3282
- "stableId",
3283
- "title",
3284
- "createdAtMs",
3285
- "diffAvailable",
3286
- "diffSource",
3287
- "diffAddedLines",
3288
- "diffRemovedLines",
3289
- "previousFileRefs",
3290
- "cwd",
3291
- ]);
3292
- runtime.recentTimelineEntries = nextItems;
3293
- state.recentTimelineEntries = nextItems;
3294
- return changed;
3295
- }
3296
-
3297
- function recordCodeEvent({ config, runtime, state, entry }) {
3298
- if (!isCodeEventEntry(entry)) {
3299
- return false;
3639
+ addClient(client) {
3640
+ this.clients.add(client);
3641
+ return () => {
3642
+ this.clients.delete(client);
3643
+ };
3300
3644
  }
3301
- const normalized = normalizeTimelineEntry(entry);
3302
- if (!normalized) {
3303
- return false;
3645
+
3646
+ emit(eventName, payload) {
3647
+ for (const client of [...this.clients]) {
3648
+ try {
3649
+ client.send(eventName, payload);
3650
+ } catch {
3651
+ try {
3652
+ client.close?.();
3653
+ } catch {}
3654
+ this.clients.delete(client);
3655
+ }
3656
+ }
3304
3657
  }
3305
3658
 
3306
- const nextItems = normalizeCodeEvents(
3307
- [normalized, ...runtime.recentCodeEvents.filter((item) => item.stableId !== normalized.stableId)],
3308
- config.maxCodeEvents
3309
- );
3310
- // See `recordTimelineEntry` for the rationale on dropping `diffText` from
3311
- // the projection — same megabyte-stringify hot path; same line-count proxy.
3312
- const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
3313
- "stableId",
3314
- "title",
3315
- "createdAtMs",
3316
- "diffAvailable",
3317
- "diffSource",
3318
- "diffAddedLines",
3319
- "diffRemovedLines",
3320
- "previousFileRefs",
3321
- "cwd",
3322
- ]);
3323
- runtime.recentCodeEvents = nextItems;
3324
- state.recentCodeEvents = nextItems;
3325
- if (changed) {
3326
- invalidateDiffThreadGroupsCache();
3659
+ closeAll() {
3660
+ for (const client of [...this.clients]) {
3661
+ try {
3662
+ client.close?.();
3663
+ } catch {}
3664
+ }
3665
+ this.clients.clear();
3327
3666
  }
3328
- return changed;
3329
3667
  }
3330
3668
 
3331
- function syncRecentCodeEventsFromTimeline({ config, runtime, state }) {
3332
- const timelineCodeEvents = normalizeCodeEvents(runtime.recentTimelineEntries, config.maxCodeEvents);
3333
- if (timelineCodeEvents.length === 0 && runtime.recentCodeEvents.length === 0) {
3334
- return false;
3669
+ function ensureTimelineBus(runtime) {
3670
+ if (!runtime.timelineBus) {
3671
+ runtime.timelineBus = new TimelineBus();
3335
3672
  }
3673
+ return runtime.timelineBus;
3674
+ }
3336
3675
 
3337
- const nextItems = normalizeCodeEvents(
3338
- [
3339
- ...timelineCodeEvents,
3340
- ...runtime.recentCodeEvents.filter(
3341
- (entry) => !timelineCodeEvents.some((timelineEntry) => timelineEntry.stableId === entry.stableId)
3342
- ),
3343
- ],
3344
- config.maxCodeEvents
3345
- );
3346
- // See `recordTimelineEntry` for the rationale on dropping `diffText`.
3347
- const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
3348
- "stableId",
3349
- "title",
3350
- "createdAtMs",
3351
- "threadLabel",
3352
- "diffAvailable",
3353
- "diffSource",
3354
- "diffAddedLines",
3355
- "diffRemovedLines",
3356
- "previousFileRefs",
3357
- "cwd",
3358
- ]);
3359
- runtime.recentCodeEvents = nextItems;
3360
- state.recentCodeEvents = nextItems;
3361
- if (changed) {
3362
- invalidateDiffThreadGroupsCache();
3676
+ function buildTimelineUpdatePayload({ runtime, entry, source = "unknown" }) {
3677
+ runtime.timelineRevision = (Number(runtime.timelineRevision) || 0) + 1;
3678
+ return {
3679
+ revision: runtime.timelineRevision,
3680
+ token: cleanText(entry?.token || historyToken(entry?.stableId || entry?.createdAtMs || "")).slice(0, 120),
3681
+ stableId: cleanText(entry?.stableId || "").slice(0, 180),
3682
+ kind: cleanText(entry?.kind || "").slice(0, 80),
3683
+ provider: cleanText(entry?.provider || "").slice(0, 40),
3684
+ threadId: cleanText(entry?.threadId || "").slice(0, 120),
3685
+ createdAtMs: Number(entry?.createdAtMs) || 0,
3686
+ source: cleanText(source || "unknown").slice(0, 80),
3687
+ ingestAtMs: Date.now(),
3688
+ };
3689
+ }
3690
+
3691
+ function publishTimelineUpdate({ config, runtime, entry, source = "unknown" }) {
3692
+ if (!config?.timelineLiveSync || !runtime || !entry) {
3693
+ return;
3363
3694
  }
3364
- return changed;
3695
+ const payload = buildTimelineUpdatePayload({ runtime, entry, source });
3696
+ console.log(
3697
+ `[timeline-ingest] at=${new Date(payload.ingestAtMs).toISOString()} ` +
3698
+ `provider=${payload.provider || "unknown"} kind=${payload.kind || "unknown"} ` +
3699
+ `token=${payload.token || "none"} createdAtMs=${payload.createdAtMs || 0} ` +
3700
+ `source=${payload.source || "unknown"} revision=${payload.revision}`
3701
+ );
3702
+ ensureTimelineBus(runtime).emit("timeline:update", payload);
3365
3703
  }
3366
3704
 
3367
- function timelineEntryByToken(runtime, token, kind = "") {
3705
+ function timelineActivityKey(provider, threadId) {
3706
+ const normalizedProvider = normalizeProvider(provider) || "codex";
3707
+ const normalizedThreadId = cleanText(threadId || "");
3708
+ if (!normalizedThreadId) {
3709
+ return "";
3710
+ }
3711
+ return `${normalizedProvider}:${normalizedThreadId}`;
3712
+ }
3713
+
3714
+ function timelineActivityTitle(locale, phase) {
3715
+ switch (cleanText(phase || "")) {
3716
+ case "thinking":
3717
+ return t(locale, "server.activity.thinking");
3718
+ case "reading":
3719
+ return t(locale, "server.activity.reading");
3720
+ case "searching":
3721
+ return t(locale, "server.activity.searching");
3722
+ case "editing":
3723
+ return t(locale, "server.activity.editing");
3724
+ case "running_command":
3725
+ return t(locale, "server.activity.runningCommand");
3726
+ case "awaiting_approval":
3727
+ return t(locale, "server.activity.awaitingApproval");
3728
+ default:
3729
+ return t(locale, "server.activity.working");
3730
+ }
3731
+ }
3732
+
3733
+ function timelineActivitySummary(activity) {
3734
+ const phase = cleanText(activity?.phase || "");
3735
+ const refs = normalizeTimelineFileRefs(activity?.fileRefs || []);
3736
+ if (refs.length === 1 && ["reading", "searching", "editing"].includes(phase)) {
3737
+ return compactPath(refs[0]);
3738
+ }
3739
+ if (refs.length > 1 && ["reading", "searching", "editing"].includes(phase)) {
3740
+ return `${refs.length} files`;
3741
+ }
3742
+ const commandText = cleanText(activity?.commandText || "");
3743
+ if (commandText && phase !== "running_command") {
3744
+ return timelineCommandSummary(commandText);
3745
+ }
3746
+ return cleanText(activity?.summary || "");
3747
+ }
3748
+
3749
+ function buildTimelineActivityEntry(activity, locale = DEFAULT_LOCALE) {
3750
+ if (!isPlainObject(activity)) {
3751
+ return null;
3752
+ }
3753
+ const provider = normalizeProvider(activity.provider);
3754
+ const threadId = cleanText(activity.threadId || "");
3755
+ const key = cleanText(activity.key || timelineActivityKey(provider, threadId));
3756
+ if (!key || !threadId) {
3757
+ return null;
3758
+ }
3759
+ const phase = cleanText(activity.phase || "working");
3760
+ const updatedAtMs = Number(activity.updatedAtMs) || Date.now();
3761
+ return normalizeTimelineEntry({
3762
+ stableId: `activity_status:${key}`,
3763
+ token: historyToken(`activity_status:${key}`),
3764
+ kind: "activity_status",
3765
+ threadId,
3766
+ threadLabel: cleanText(activity.threadLabel || ""),
3767
+ title: timelineActivityTitle(locale, phase),
3768
+ summary: timelineActivitySummary(activity),
3769
+ messageText: cleanText(activity.commandText || activity.summary || ""),
3770
+ fileRefs: normalizeTimelineFileRefs(activity.fileRefs || []),
3771
+ commandText: cleanText(activity.commandText || ""),
3772
+ activityPhase: phase,
3773
+ createdAtMs: updatedAtMs,
3774
+ cwd: cleanText(activity.cwd || ""),
3775
+ readOnly: true,
3776
+ provider,
3777
+ });
3778
+ }
3779
+
3780
+ function publishTimelineActivityUpdate({ config, runtime, activity, source = "activity-status" }) {
3781
+ const entry = buildTimelineActivityEntry(activity, DEFAULT_LOCALE);
3782
+ if (entry) {
3783
+ publishTimelineUpdate({ config, runtime, entry, source });
3784
+ }
3785
+ }
3786
+
3787
+ function upsertTimelineActivity({
3788
+ config,
3789
+ runtime,
3790
+ provider,
3791
+ threadId,
3792
+ threadLabel = "",
3793
+ phase = "working",
3794
+ summary = "",
3795
+ commandText = "",
3796
+ fileRefs = [],
3797
+ cwd = "",
3798
+ source = "activity-status",
3799
+ atMs = Date.now(),
3800
+ }) {
3801
+ if (!config?.webUiEnabled || !runtime) {
3802
+ return false;
3803
+ }
3804
+ if (!(runtime.activeTimelineActivitiesByKey instanceof Map)) {
3805
+ runtime.activeTimelineActivitiesByKey = new Map();
3806
+ }
3807
+ const normalizedProvider = normalizeProvider(provider) || "codex";
3808
+ const normalizedThreadId = cleanText(threadId || "");
3809
+ const key = timelineActivityKey(normalizedProvider, normalizedThreadId);
3810
+ if (!key) {
3811
+ return false;
3812
+ }
3813
+ const next = {
3814
+ key,
3815
+ provider: normalizedProvider,
3816
+ threadId: normalizedThreadId,
3817
+ threadLabel: cleanText(threadLabel || ""),
3818
+ phase: cleanText(phase || "working"),
3819
+ summary: cleanText(summary || ""),
3820
+ commandText: cleanText(commandText || ""),
3821
+ fileRefs: normalizeTimelineFileRefs(fileRefs),
3822
+ cwd: cleanText(cwd || ""),
3823
+ updatedAtMs: Number(atMs) || Date.now(),
3824
+ };
3825
+ const previous = runtime.activeTimelineActivitiesByKey.get(key);
3826
+ const changed =
3827
+ !previous ||
3828
+ previous.phase !== next.phase ||
3829
+ previous.summary !== next.summary ||
3830
+ previous.commandText !== next.commandText ||
3831
+ previous.threadLabel !== next.threadLabel ||
3832
+ previous.cwd !== next.cwd ||
3833
+ JSON.stringify(previous.fileRefs || []) !== JSON.stringify(next.fileRefs || []);
3834
+ runtime.activeTimelineActivitiesByKey.set(key, next);
3835
+ if (changed) {
3836
+ publishTimelineActivityUpdate({ config, runtime, activity: next, source });
3837
+ }
3838
+ return changed;
3839
+ }
3840
+
3841
+ function clearTimelineActivity({ config, runtime, provider, threadId, source = "activity-clear", atMs = Date.now() }) {
3842
+ if (!runtime?.activeTimelineActivitiesByKey) {
3843
+ return false;
3844
+ }
3845
+ const key = timelineActivityKey(provider, threadId);
3846
+ if (!key || !runtime.activeTimelineActivitiesByKey.has(key)) {
3847
+ return false;
3848
+ }
3849
+ const previous = runtime.activeTimelineActivitiesByKey.get(key);
3850
+ runtime.activeTimelineActivitiesByKey.delete(key);
3851
+ publishTimelineActivityUpdate({
3852
+ config,
3853
+ runtime,
3854
+ activity: {
3855
+ ...(isPlainObject(previous) ? previous : {}),
3856
+ key,
3857
+ provider: normalizeProvider(provider),
3858
+ threadId: cleanText(threadId || ""),
3859
+ phase: "working",
3860
+ updatedAtMs: Number(atMs) || Date.now(),
3861
+ },
3862
+ source,
3863
+ });
3864
+ return true;
3865
+ }
3866
+
3867
+ function timelineActivityFromCommand({ commandText, cwd = "" }) {
3868
+ const classified = classifyTimelineCommand(commandText);
3869
+ const fileEventType = cleanText(classified.fileEventType || "");
3870
+ const phase = fileEventType === "read"
3871
+ ? "reading"
3872
+ : fileEventType === "search"
3873
+ ? "searching"
3874
+ : "running_command";
3875
+ return {
3876
+ phase,
3877
+ commandText: classified.commandText || commandText,
3878
+ fileRefs: classified.fileRefs || [],
3879
+ cwd,
3880
+ };
3881
+ }
3882
+
3883
+ function timelineActivityFromClaudeTool(toolName, input = {}) {
3884
+ const lowerToolName = cleanText(toolName || "").toLowerCase();
3885
+ if (lowerToolName === "bash") {
3886
+ return timelineActivityFromCommand({ commandText: cleanText(input.command || ""), cwd: cleanText(input.cwd || "") });
3887
+ }
3888
+ if (["read", "ls"].includes(lowerToolName)) {
3889
+ return {
3890
+ phase: "reading",
3891
+ summary: cleanText(toolName || ""),
3892
+ fileRefs: normalizeTimelineFileRefs([input.file_path || input.path || input.filePath].filter(Boolean)),
3893
+ };
3894
+ }
3895
+ if (["grep", "glob", "websearch", "webfetch"].includes(lowerToolName)) {
3896
+ return {
3897
+ phase: "searching",
3898
+ summary: cleanText(input.pattern || input.query || input.url || toolName || ""),
3899
+ fileRefs: normalizeTimelineFileRefs([input.path || input.file_path || input.filePath].filter(Boolean)),
3900
+ };
3901
+ }
3902
+ if (["write", "edit", "multiedit", "todowrite"].includes(lowerToolName)) {
3903
+ return {
3904
+ phase: "editing",
3905
+ summary: cleanText(toolName || ""),
3906
+ fileRefs: normalizeTimelineFileRefs([input.file_path || input.path || input.filePath].filter(Boolean)),
3907
+ };
3908
+ }
3909
+ if (["askuserquestion", "exitplanmode"].includes(lowerToolName)) {
3910
+ return { phase: "awaiting_approval", summary: cleanText(toolName || "") };
3911
+ }
3912
+ return { phase: "working", summary: cleanText(toolName || "") };
3913
+ }
3914
+
3915
+ function timelineActivityFromClaudeContent(content) {
3916
+ if (!Array.isArray(content)) {
3917
+ return null;
3918
+ }
3919
+ for (const block of content) {
3920
+ if (!isPlainObject(block) || block.type !== "tool_use") {
3921
+ continue;
3922
+ }
3923
+ return timelineActivityFromClaudeTool(block.name || "", isPlainObject(block.input) ? block.input : {});
3924
+ }
3925
+ return null;
3926
+ }
3927
+
3928
+ function recordTimelineEntry({ config, runtime, state, entry, source = "unknown" }) {
3929
+ const normalized = normalizeTimelineEntry(entry);
3930
+ if (!normalized) {
3931
+ return false;
3932
+ }
3933
+
3934
+ const nextItems = normalizeTimelineEntries(
3935
+ [normalized, ...runtime.recentTimelineEntries.filter((item) => item.stableId !== normalized.stableId)],
3936
+ config.maxTimelineEntries
3937
+ );
3938
+ // Note: diffText is intentionally omitted from the projection — the
3939
+ // line-count fields (`diffAddedLines` / `diffRemovedLines`) are a sufficient
3940
+ // proxy for "diff content changed" and avoid materialising a megabyte-class
3941
+ // intermediate string on every record call.
3942
+ const changed = timelineProjectionChanged(nextItems, runtime.recentTimelineEntries, [
3943
+ "stableId",
3944
+ "title",
3945
+ "summary",
3946
+ "messageText",
3947
+ "fileEventType",
3948
+ "fileRefs",
3949
+ "createdAtMs",
3950
+ "diffAvailable",
3951
+ "diffSource",
3952
+ "diffAddedLines",
3953
+ "diffRemovedLines",
3954
+ "previousFileRefs",
3955
+ "cwd",
3956
+ ]);
3957
+ runtime.recentTimelineEntries = nextItems;
3958
+ state.recentTimelineEntries = nextItems;
3959
+ if (changed) {
3960
+ publishTimelineUpdate({ config, runtime, entry: normalized, source });
3961
+ }
3962
+ return changed;
3963
+ }
3964
+
3965
+ function recordCodeEvent({ config, runtime, state, entry }) {
3966
+ if (!isCodeEventEntry(entry)) {
3967
+ return false;
3968
+ }
3969
+ const normalized = normalizeTimelineEntry(entry);
3970
+ if (!normalized) {
3971
+ return false;
3972
+ }
3973
+
3974
+ const nextItems = normalizeCodeEvents(
3975
+ [normalized, ...runtime.recentCodeEvents.filter((item) => item.stableId !== normalized.stableId)],
3976
+ config.maxCodeEvents
3977
+ );
3978
+ // See `recordTimelineEntry` for the rationale on dropping `diffText` from
3979
+ // the projection — same megabyte-stringify hot path; same line-count proxy.
3980
+ const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
3981
+ "stableId",
3982
+ "title",
3983
+ "createdAtMs",
3984
+ "diffAvailable",
3985
+ "diffSource",
3986
+ "diffAddedLines",
3987
+ "diffRemovedLines",
3988
+ "previousFileRefs",
3989
+ "cwd",
3990
+ ]);
3991
+ runtime.recentCodeEvents = nextItems;
3992
+ state.recentCodeEvents = nextItems;
3993
+ if (changed) {
3994
+ invalidateDiffThreadGroupsCache();
3995
+ }
3996
+ return changed;
3997
+ }
3998
+
3999
+ function syncRecentCodeEventsFromTimeline({ config, runtime, state }) {
4000
+ const timelineCodeEvents = normalizeCodeEvents(runtime.recentTimelineEntries, config.maxCodeEvents);
4001
+ if (timelineCodeEvents.length === 0 && runtime.recentCodeEvents.length === 0) {
4002
+ return false;
4003
+ }
4004
+
4005
+ const nextItems = normalizeCodeEvents(
4006
+ [
4007
+ ...timelineCodeEvents,
4008
+ ...runtime.recentCodeEvents.filter(
4009
+ (entry) => !timelineCodeEvents.some((timelineEntry) => timelineEntry.stableId === entry.stableId)
4010
+ ),
4011
+ ],
4012
+ config.maxCodeEvents
4013
+ );
4014
+ // See `recordTimelineEntry` for the rationale on dropping `diffText`.
4015
+ const changed = timelineProjectionChanged(nextItems, runtime.recentCodeEvents, [
4016
+ "stableId",
4017
+ "title",
4018
+ "createdAtMs",
4019
+ "threadLabel",
4020
+ "diffAvailable",
4021
+ "diffSource",
4022
+ "diffAddedLines",
4023
+ "diffRemovedLines",
4024
+ "previousFileRefs",
4025
+ "cwd",
4026
+ ]);
4027
+ runtime.recentCodeEvents = nextItems;
4028
+ state.recentCodeEvents = nextItems;
4029
+ if (changed) {
4030
+ invalidateDiffThreadGroupsCache();
4031
+ }
4032
+ return changed;
4033
+ }
4034
+
4035
+ function timelineEntryByToken(runtime, token, kind = "") {
3368
4036
  const normalizedToken = cleanText(token ?? "");
3369
4037
  const normalizedKind = cleanText(kind ?? "");
3370
4038
  return runtime.recentTimelineEntries.find(
@@ -3411,6 +4079,18 @@ function historyItemFromEvent(event) {
3411
4079
  });
3412
4080
  }
3413
4081
 
4082
+ function completionPushContentDedupeId(event) {
4083
+ if (!event || event.kind !== "task_complete") {
4084
+ return "";
4085
+ }
4086
+ const threadId = cleanText(event.threadId ?? event.conversationId ?? "unknown") || "unknown";
4087
+ const messageText = normalizeLongText(event.detailText || event.message || "");
4088
+ if (!messageText) {
4089
+ return "";
4090
+ }
4091
+ return `task_complete_content:${threadId}:${historyToken(messageText)}`;
4092
+ }
4093
+
3414
4094
  function recordHistoryItem({ config, runtime, state, item }) {
3415
4095
  const normalized = normalizeHistoryItem(item);
3416
4096
  if (!normalized) {
@@ -3573,6 +4253,7 @@ function recordActionHistoryItem({
3573
4253
  runtime,
3574
4254
  state,
3575
4255
  entry: item,
4256
+ source: "event",
3576
4257
  });
3577
4258
  return historyChanged || timelineChanged;
3578
4259
  }
@@ -3732,7 +4413,20 @@ function pushDeliveryKey(deviceId, stableId) {
3732
4413
  return `${cleanText(deviceId || "")}:${cleanText(stableId || "")}`;
3733
4414
  }
3734
4415
 
3735
- async function deliverWebPushItem({ config, state, kind, token, stableId, title, body, tab = "", subtab = "", buildLocalizedContent = null }) {
4416
+ async function deliverWebPushItem({
4417
+ config,
4418
+ state,
4419
+ kind,
4420
+ token,
4421
+ stableId,
4422
+ title,
4423
+ body,
4424
+ tab = "",
4425
+ subtab = "",
4426
+ buildLocalizedContent = null,
4427
+ dedupeId = "",
4428
+ dedupeWindowMs = 0,
4429
+ }) {
3736
4430
  if (!config.webPushEnabled || config.dryRun) {
3737
4431
  return false;
3738
4432
  }
@@ -3749,8 +4443,18 @@ async function deliverWebPushItem({ config, state, kind, token, stableId, title,
3749
4443
  let changed = false;
3750
4444
 
3751
4445
  for (const subscription of subscriptions) {
3752
- const deliveryKey = pushDeliveryKey(subscription.deviceId, stableId);
3753
- if (state.pushDeliveries[deliveryKey]) {
4446
+ const now = Date.now();
4447
+ const stableDeliveryKey = pushDeliveryKey(subscription.deviceId, stableId);
4448
+ const dedupeDeliveryKey = pushDeliveryKey(subscription.deviceId, dedupeId || stableId);
4449
+ const stableDeliveredAt = Number(state.pushDeliveries[stableDeliveryKey]) || 0;
4450
+ const dedupeDeliveredAt = Number(state.pushDeliveries[dedupeDeliveryKey]) || 0;
4451
+ if (stableDeliveredAt) {
4452
+ continue;
4453
+ }
4454
+ if (
4455
+ dedupeDeliveredAt &&
4456
+ (!dedupeWindowMs || now - dedupeDeliveredAt <= Math.max(0, Number(dedupeWindowMs) || 0))
4457
+ ) {
3754
4458
  continue;
3755
4459
  }
3756
4460
 
@@ -3778,8 +4482,10 @@ async function deliverWebPushItem({ config, state, kind, token, stableId, title,
3778
4482
  },
3779
4483
  payload
3780
4484
  );
3781
- const now = Date.now();
3782
- state.pushDeliveries[deliveryKey] = now;
4485
+ state.pushDeliveries[stableDeliveryKey] = now;
4486
+ if (dedupeDeliveryKey !== stableDeliveryKey) {
4487
+ state.pushDeliveries[dedupeDeliveryKey] = now;
4488
+ }
3783
4489
  trimSeenEvents(state.pushDeliveries, config.maxSeenEvents * 4);
3784
4490
  const stored = normalizePushSubscriptionRecord(state.pushSubscriptions?.[subscription.id]);
3785
4491
  if (stored) {
@@ -3931,6 +4637,207 @@ async function scanOnce({ config, runtime, state }) {
3931
4637
  return dirty;
3932
4638
  }
3933
4639
 
4640
+ function isPathWithin(parentDir, candidatePath) {
4641
+ const parent = path.resolve(parentDir || "");
4642
+ const candidate = path.resolve(candidatePath || "");
4643
+ if (!parent || !candidate) return false;
4644
+ return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
4645
+ }
4646
+
4647
+ function isClaudeTranscriptPath(config, filePath) {
4648
+ return Boolean(
4649
+ filePath &&
4650
+ filePath.endsWith(".jsonl") &&
4651
+ isPathWithin(config.claudeProjectsDir, filePath)
4652
+ );
4653
+ }
4654
+
4655
+ function isRolloutFilePath(filePath) {
4656
+ const base = path.basename(filePath || "");
4657
+ return base.startsWith("rollout-") && base.endsWith(".jsonl");
4658
+ }
4659
+
4660
+ function watchedEventPath(root, filename) {
4661
+ if (!filename) return root;
4662
+ const text = Buffer.isBuffer(filename) ? filename.toString("utf8") : String(filename);
4663
+ if (!text) return root;
4664
+ return path.isAbsolute(text) ? text : path.join(root, text);
4665
+ }
4666
+
4667
+ function addTimelineLiveWatcher({ runtime, label, targetPath, options = {}, onChange }) {
4668
+ if (!targetPath) return;
4669
+ try {
4670
+ const watcher = watchFs(targetPath, options, (_eventType, filename) => {
4671
+ onChange(watchedEventPath(targetPath, filename));
4672
+ });
4673
+ watcher.on?.("error", (err) => {
4674
+ console.warn(`[timeline-live-watch-error] ${label} ${err?.message || err}`);
4675
+ });
4676
+ runtime.timelineLiveWatchers.push(watcher);
4677
+ console.log(`[timeline-live-watch] ${label} ${targetPath}`);
4678
+ } catch (err) {
4679
+ console.warn(`[timeline-live-watch-skip] ${label} ${targetPath} ${err?.message || err}`);
4680
+ }
4681
+ }
4682
+
4683
+ function scheduleTimelineLiveScan({ config, runtime, state, reason = "unknown", filePath = "" }) {
4684
+ if (!config.timelineLiveSync || runtime.stopping) {
4685
+ return;
4686
+ }
4687
+ runtime.timelineLiveScanReasons.add(cleanText(reason || "unknown").slice(0, 80));
4688
+ const normalizedPath = cleanText(filePath || "");
4689
+ if (isClaudeTranscriptPath(config, normalizedPath)) {
4690
+ runtime.timelineLiveClaudeFiles.add(normalizedPath);
4691
+ } else if (isRolloutFilePath(normalizedPath)) {
4692
+ runtime.timelineLiveRolloutFiles.add(normalizedPath);
4693
+ }
4694
+
4695
+ if (runtime.timelineLiveScanTimer) {
4696
+ return;
4697
+ }
4698
+ runtime.timelineLiveScanTimer = setTimeout(() => {
4699
+ runtime.timelineLiveScanTimer = null;
4700
+ runPendingTimelineLiveScan({ config, runtime, state }).catch((err) => {
4701
+ console.warn(`[timeline-live-scan-error] ${err?.message || err}`);
4702
+ });
4703
+ }, Math.max(25, Number(config.timelineLiveDebounceMs) || 150));
4704
+ runtime.timelineLiveScanTimer.unref?.();
4705
+ }
4706
+
4707
+ async function runPendingTimelineLiveScan({ config, runtime, state }) {
4708
+ if (runtime.timelineLiveScanInFlight) {
4709
+ runtime.timelineLiveScanReschedule = true;
4710
+ return;
4711
+ }
4712
+ runtime.timelineLiveScanInFlight = true;
4713
+ const reasons = new Set(runtime.timelineLiveScanReasons);
4714
+ const claudeFiles = [...runtime.timelineLiveClaudeFiles];
4715
+ const rolloutFiles = [...runtime.timelineLiveRolloutFiles];
4716
+ runtime.timelineLiveScanReasons.clear();
4717
+ runtime.timelineLiveClaudeFiles.clear();
4718
+ runtime.timelineLiveRolloutFiles.clear();
4719
+
4720
+ let dirty = false;
4721
+ const now = Date.now();
4722
+ try {
4723
+ if (reasons.has("codex-home") || reasons.has("codex-history") || reasons.has("codex-logs")) {
4724
+ if (!config.codexLogsDbFile) {
4725
+ const latestLogsDbFile = await findLatestCodexLogsDbFile(config.codexHome);
4726
+ if (latestLogsDbFile && latestLogsDbFile !== runtime.logsDbFile) {
4727
+ runtime.logsDbFile = latestLogsDbFile;
4728
+ }
4729
+ }
4730
+ dirty = (await processHistoryTimelineFile({ config, runtime, state, now })) || dirty;
4731
+ if (config.notifyCompletions || config.webUiEnabled) {
4732
+ dirty = (await processSqliteCompletionLog({ config, runtime, state, now })) || dirty;
4733
+ }
4734
+ if (config.webUiEnabled) {
4735
+ dirty = (await processSqliteTimelineLog({ config, runtime, state, now })) || dirty;
4736
+ }
4737
+ }
4738
+
4739
+ for (const filePath of rolloutFiles.slice(0, 12)) {
4740
+ if (!runtime.knownFiles.includes(filePath)) {
4741
+ runtime.knownFiles.push(filePath);
4742
+ runtime.knownFiles.sort();
4743
+ }
4744
+ dirty = (await processRolloutFile({ filePath, config, runtime, state, now })) || dirty;
4745
+ }
4746
+
4747
+ let claudeTranscriptChanged = false;
4748
+ let claudeTargets = claudeFiles;
4749
+ if (reasons.has("claude-dir") && claudeTargets.length === 0) {
4750
+ claudeTargets = await listClaudeTranscriptFiles(config.claudeProjectsDir, config.claudeTranscriptMaxAgeMs);
4751
+ }
4752
+ for (const filePath of claudeTargets.slice(0, 24)) {
4753
+ if (!runtime.claudeKnownFiles.includes(filePath)) {
4754
+ runtime.claudeKnownFiles.push(filePath);
4755
+ }
4756
+ const changed = await processClaudeTranscriptFile({ filePath, config, runtime, state, now });
4757
+ claudeTranscriptChanged = claudeTranscriptChanged || changed;
4758
+ dirty = dirty || changed;
4759
+ }
4760
+ if (claudeTranscriptChanged) {
4761
+ dirty = refreshResolvedThreadLabels({ config, runtime, state }) || dirty;
4762
+ }
4763
+
4764
+ if (dirty) {
4765
+ scheduleSaveState(config, state);
4766
+ }
4767
+ } finally {
4768
+ runtime.timelineLiveScanInFlight = false;
4769
+ if (runtime.timelineLiveScanReschedule || runtime.timelineLiveScanReasons.size > 0) {
4770
+ runtime.timelineLiveScanReschedule = false;
4771
+ scheduleTimelineLiveScan({ config, runtime, state, reason: "reschedule" });
4772
+ }
4773
+ }
4774
+ }
4775
+
4776
+ function startTimelineLiveSync({ config, runtime, state }) {
4777
+ if (config.dryRun || !config.webUiEnabled || !config.timelineLiveSync) {
4778
+ return null;
4779
+ }
4780
+
4781
+ addTimelineLiveWatcher({
4782
+ runtime,
4783
+ label: "codex-home",
4784
+ targetPath: config.codexHome,
4785
+ onChange: (filePath) => {
4786
+ const base = path.basename(filePath || "");
4787
+ if (filePath === config.historyFile || base === path.basename(config.historyFile || "")) {
4788
+ scheduleTimelineLiveScan({ config, runtime, state, reason: "codex-history", filePath });
4789
+ return;
4790
+ }
4791
+ if (/^logs(?:_\d+)?\.sqlite(?:-wal|-shm)?$/u.test(base)) {
4792
+ scheduleTimelineLiveScan({ config, runtime, state, reason: "codex-logs", filePath });
4793
+ }
4794
+ },
4795
+ });
4796
+
4797
+ addTimelineLiveWatcher({
4798
+ runtime,
4799
+ label: "codex-sessions",
4800
+ targetPath: config.sessionsDir,
4801
+ options: { recursive: true },
4802
+ onChange: (filePath) => {
4803
+ if (isRolloutFilePath(filePath)) {
4804
+ scheduleTimelineLiveScan({ config, runtime, state, reason: "codex-rollout", filePath });
4805
+ }
4806
+ },
4807
+ });
4808
+
4809
+ addTimelineLiveWatcher({
4810
+ runtime,
4811
+ label: "claude-projects",
4812
+ targetPath: config.claudeProjectsDir,
4813
+ options: { recursive: true },
4814
+ onChange: (filePath) => {
4815
+ scheduleTimelineLiveScan({
4816
+ config,
4817
+ runtime,
4818
+ state,
4819
+ reason: isClaudeTranscriptPath(config, filePath) ? "claude-file" : "claude-dir",
4820
+ filePath,
4821
+ });
4822
+ },
4823
+ });
4824
+
4825
+ return {
4826
+ close() {
4827
+ if (runtime.timelineLiveScanTimer) {
4828
+ clearTimeout(runtime.timelineLiveScanTimer);
4829
+ runtime.timelineLiveScanTimer = null;
4830
+ }
4831
+ for (const watcher of runtime.timelineLiveWatchers.splice(0)) {
4832
+ try {
4833
+ watcher.close();
4834
+ } catch {}
4835
+ }
4836
+ runtime.timelineBus?.closeAll?.();
4837
+ },
4838
+ };
4839
+ }
4840
+
3934
4841
  async function processRolloutFile({ filePath, config, runtime, state, now }) {
3935
4842
  let stat;
3936
4843
  try {
@@ -3954,6 +4861,7 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
3954
4861
  threadId: extractThreadIdFromRolloutPath(filePath),
3955
4862
  cwd: null,
3956
4863
  applyPatchInputsByCallId: new Map(),
4864
+ toolEventInputsByCallId: new Map(),
3957
4865
  startupCutoffMs:
3958
4866
  typeof restoredOffset === "number" ? 0 : now - config.replaySeconds * 1000,
3959
4867
  skipPartialLine:
@@ -4031,7 +4939,19 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
4031
4939
  runtime,
4032
4940
  state,
4033
4941
  entry: timelineEntry,
4942
+ source: "codex-rollout",
4034
4943
  }) || dirty;
4944
+ upsertTimelineActivity({
4945
+ config,
4946
+ runtime,
4947
+ provider: "codex",
4948
+ threadId: timelineEntry.threadId,
4949
+ threadLabel: timelineEntry.threadLabel,
4950
+ phase: "thinking",
4951
+ cwd: fileState.cwd || "",
4952
+ source: "codex-user-message",
4953
+ atMs: Date.parse(record.timestamp ?? "") || Date.now(),
4954
+ });
4035
4955
  }
4036
4956
 
4037
4957
  const fileTimelineEntries = await buildRolloutFileTimelineEntries({
@@ -4048,6 +4968,7 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
4048
4968
  runtime,
4049
4969
  state,
4050
4970
  entry: fileTimelineEntry,
4971
+ source: "codex-rollout",
4051
4972
  }) || dirty;
4052
4973
  dirty =
4053
4974
  recordCodeEvent({
@@ -4234,6 +5155,109 @@ async function listClaudeTranscriptFiles(claudeProjectsDir, maxAgeMs = 0) {
4234
5155
  }
4235
5156
  }
4236
5157
 
5158
+ function buildClaudeToolTimelineEntries({ content, threadId, threadLabel, uuid, createdAtMs, cwd }) {
5159
+ if (!Array.isArray(content) || !threadId) {
5160
+ return [];
5161
+ }
5162
+
5163
+ const entries = [];
5164
+ for (const block of content) {
5165
+ if (!isPlainObject(block) || block.type !== "tool_use") {
5166
+ continue;
5167
+ }
5168
+ const toolName = cleanText(block.name || "");
5169
+ const input = isPlainObject(block.input) ? block.input : {};
5170
+ const toolId = cleanText(block.id || block.tool_use_id || "") || historyToken(JSON.stringify(block));
5171
+ const callId = `${uuid || createdAtMs}:${toolId}`;
5172
+ const lowerToolName = toolName.toLowerCase();
5173
+
5174
+ if (lowerToolName === "bash") {
5175
+ const commandText = cleanText(input.command || "");
5176
+ if (!commandText) {
5177
+ continue;
5178
+ }
5179
+ const classified = classifyTimelineCommand(commandText);
5180
+ entries.push(
5181
+ buildToolTimelineEntry({
5182
+ provider: "claude",
5183
+ threadId,
5184
+ threadLabel,
5185
+ callId,
5186
+ createdAtMs,
5187
+ fileEventType: classified.fileEventType,
5188
+ commandText: classified.commandText || commandText,
5189
+ fileRefs: classified.fileRefs,
5190
+ cwd,
5191
+ })
5192
+ );
5193
+ continue;
5194
+ }
5195
+
5196
+ if (["write", "edit", "multiedit", "todowrite", "exitplanmode", "askuserquestion"].includes(lowerToolName)) {
5197
+ continue;
5198
+ }
5199
+
5200
+ if (lowerToolName === "read") {
5201
+ const fileRefs = normalizeTimelineFileRefs([input.file_path || input.path || input.filePath].filter(Boolean));
5202
+ entries.push(
5203
+ buildToolTimelineEntry({
5204
+ provider: "claude",
5205
+ threadId,
5206
+ threadLabel,
5207
+ callId,
5208
+ createdAtMs,
5209
+ fileEventType: "read",
5210
+ toolName,
5211
+ summaryText: fileRefs[0] ? `${toolName}: ${fileRefs[0]}` : toolName,
5212
+ fileRefs,
5213
+ cwd,
5214
+ })
5215
+ );
5216
+ continue;
5217
+ }
5218
+
5219
+ if (["grep", "glob", "websearch", "webfetch"].includes(lowerToolName)) {
5220
+ const query = cleanText(input.pattern || input.query || input.url || "");
5221
+ const fileRefs = normalizeTimelineFileRefs([input.path || input.file_path || input.filePath].filter(Boolean));
5222
+ entries.push(
5223
+ buildToolTimelineEntry({
5224
+ provider: "claude",
5225
+ threadId,
5226
+ threadLabel,
5227
+ callId,
5228
+ createdAtMs,
5229
+ fileEventType: "search",
5230
+ toolName,
5231
+ summaryText: query ? `${toolName}: ${query}` : toolName,
5232
+ fileRefs,
5233
+ cwd,
5234
+ })
5235
+ );
5236
+ continue;
5237
+ }
5238
+
5239
+ if (lowerToolName === "ls") {
5240
+ const fileRefs = normalizeTimelineFileRefs([input.path].filter(Boolean));
5241
+ entries.push(
5242
+ buildToolTimelineEntry({
5243
+ provider: "claude",
5244
+ threadId,
5245
+ threadLabel,
5246
+ callId,
5247
+ createdAtMs,
5248
+ fileEventType: "read",
5249
+ toolName,
5250
+ summaryText: fileRefs[0] ? `${toolName}: ${fileRefs[0]}` : toolName,
5251
+ fileRefs,
5252
+ cwd,
5253
+ })
5254
+ );
5255
+ }
5256
+ }
5257
+
5258
+ return entries.filter(Boolean);
5259
+ }
5260
+
4237
5261
  async function processClaudeTranscriptFile({ filePath, config, runtime, state, now }) {
4238
5262
  let fileState = runtime.claudeFileStates.get(filePath);
4239
5263
  if (!fileState) {
@@ -4349,6 +5373,37 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4349
5373
  const claudeTitle = threadId ? runtime.claudeSessionTitles.get(threadId) || "" : "";
4350
5374
  const threadLabel = claudeTitle || fileState.threadLabel || "";
4351
5375
 
5376
+ if (type === "assistant" && Array.isArray(content)) {
5377
+ const toolEntries = buildClaudeToolTimelineEntries({
5378
+ content,
5379
+ threadId,
5380
+ threadLabel,
5381
+ uuid,
5382
+ createdAtMs,
5383
+ cwd: fileState.cwd,
5384
+ });
5385
+ for (const toolEntry of toolEntries) {
5386
+ dirty = recordTimelineEntry({ config, runtime, state, entry: toolEntry, source: "claude-tool" }) || dirty;
5387
+ }
5388
+ const toolActivity = timelineActivityFromClaudeContent(content);
5389
+ if (toolActivity) {
5390
+ upsertTimelineActivity({
5391
+ config,
5392
+ runtime,
5393
+ provider: "claude",
5394
+ threadId,
5395
+ threadLabel,
5396
+ phase: toolActivity.phase,
5397
+ summary: toolActivity.summary,
5398
+ commandText: toolActivity.commandText,
5399
+ fileRefs: toolActivity.fileRefs,
5400
+ cwd: fileState.cwd,
5401
+ source: "claude-tool-start",
5402
+ atMs: createdAtMs,
5403
+ });
5404
+ }
5405
+ }
5406
+
4352
5407
  let text = "";
4353
5408
  if (typeof content === "string") {
4354
5409
  text = content;
@@ -4386,6 +5441,28 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4386
5441
  : stopReason === "end_turn"
4387
5442
  ? "assistant_final"
4388
5443
  : "assistant_commentary";
5444
+ if (kind === "user_message") {
5445
+ upsertTimelineActivity({
5446
+ config,
5447
+ runtime,
5448
+ provider: "claude",
5449
+ threadId,
5450
+ threadLabel,
5451
+ phase: "thinking",
5452
+ cwd: fileState.cwd,
5453
+ source: "claude-user-message",
5454
+ atMs: createdAtMs,
5455
+ });
5456
+ } else if (kind === "assistant_final") {
5457
+ clearTimelineActivity({
5458
+ config,
5459
+ runtime,
5460
+ provider: "claude",
5461
+ threadId,
5462
+ source: "claude-final",
5463
+ atMs: createdAtMs,
5464
+ });
5465
+ }
4389
5466
  // Use kind-independent stableId so re-scans with corrected classification
4390
5467
  // replace old entries rather than creating duplicates.
4391
5468
  const stableId = `claude_msg:${threadId}:${uuid}`;
@@ -4404,7 +5481,7 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
4404
5481
  provider: "claude",
4405
5482
  });
4406
5483
  if (entry) {
4407
- dirty = recordTimelineEntry({ config, runtime, state, entry }) || dirty;
5484
+ dirty = recordTimelineEntry({ config, runtime, state, entry, source: "claude-transcript" }) || dirty;
4408
5485
 
4409
5486
  // Also record Claude assistant_final as a history item for the completed list
4410
5487
  if (kind === "assistant_final") {
@@ -4713,6 +5790,7 @@ async function processSqliteTimelineLog({ config, runtime, state, now }) {
4713
5790
  runtime,
4714
5791
  state,
4715
5792
  entry,
5793
+ source: "codex-sqlite",
4716
5794
  }) || dirty;
4717
5795
 
4718
5796
  // Also record Codex assistant_final as a history item so it appears
@@ -4864,6 +5942,7 @@ async function processHistoryTimelineFile({ config, runtime, state, now }) {
4864
5942
  runtime,
4865
5943
  state,
4866
5944
  entry,
5945
+ source: "codex-history",
4867
5946
  }) || dirty;
4868
5947
  }
4869
5948
 
@@ -5038,6 +6117,7 @@ async function backfillRecentTimelineEntryDiffs({ config, runtime, state }) {
5038
6117
  threadId: cleanText(entry?.threadId || ""),
5039
6118
  cwd: await findRolloutThreadCwd(runtime, entry?.threadId || ""),
5040
6119
  applyPatchInputsByCallId: new Map(),
6120
+ toolEventInputsByCallId: new Map(),
5041
6121
  startupCutoffMs: 0,
5042
6122
  skipPartialLine: false,
5043
6123
  };
@@ -5610,6 +6690,47 @@ function buildRolloutUserTimelineEntry({ record, fileState, runtime }) {
5610
6690
  });
5611
6691
  }
5612
6692
 
6693
+ function buildToolTimelineEntry({
6694
+ provider,
6695
+ threadId,
6696
+ threadLabel,
6697
+ callId,
6698
+ createdAtMs,
6699
+ fileEventType,
6700
+ commandText = "",
6701
+ toolName = "",
6702
+ summaryText = "",
6703
+ fileRefs = [],
6704
+ cwd = "",
6705
+ }) {
6706
+ const normalizedType = normalizeTimelineFileEventType(fileEventType) || "command";
6707
+ const normalizedRefs = normalizeTimelineFileRefs(fileRefs);
6708
+ const kind = normalizedType === "command" ? "command_event" : "file_event";
6709
+ const stableKey = callId || historyToken(`${threadId}:${createdAtMs}:${normalizedType}:${commandText || toolName}:${normalizedRefs.join("|")}`);
6710
+ const messageText = timelineCommandMessage(DEFAULT_LOCALE, {
6711
+ commandText,
6712
+ toolName,
6713
+ fileRefs: normalizedRefs,
6714
+ });
6715
+ const summary = timelineCommandSummary(commandText, summaryText || toolName) || fileEventTitle(DEFAULT_LOCALE, normalizedType);
6716
+ return normalizeTimelineEntry({
6717
+ stableId: `${kind}:${normalizedType}:${threadId}:${stableKey}`,
6718
+ token: historyToken(`${kind}:${normalizedType}:${threadId}:${stableKey}`),
6719
+ kind,
6720
+ fileEventType: normalizedType,
6721
+ threadId,
6722
+ threadLabel,
6723
+ title: kind === "command_event" ? kindTitle(DEFAULT_LOCALE, "command_event") : fileEventTitle(DEFAULT_LOCALE, normalizedType),
6724
+ summary,
6725
+ messageText,
6726
+ fileRefs: normalizedRefs,
6727
+ createdAtMs,
6728
+ cwd,
6729
+ readOnly: true,
6730
+ provider,
6731
+ });
6732
+ }
6733
+
5613
6734
  async function buildRolloutFileTimelineEntries({ config, record, fileState, runtime, rolloutFilePath = "" }) {
5614
6735
  if (!isPlainObject(record) || cleanText(record.type) !== "response_item") {
5615
6736
  return [];
@@ -5630,30 +6751,102 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
5630
6751
  cwd: fileState.cwd || "",
5631
6752
  });
5632
6753
 
6754
+ if (payloadType === "function_call" && cleanText(payload?.name || "") === "exec_command") {
6755
+ const stored = rememberToolEventInput(fileState, payload, createdAtMs);
6756
+ const args = stored?.arguments || {};
6757
+ const commandText = cleanText(args.cmd || args.command || "");
6758
+ if (!commandText) {
6759
+ return [];
6760
+ }
6761
+ const classified = classifyTimelineCommand(commandText);
6762
+ const activity = timelineActivityFromCommand({ commandText, cwd: cleanText(args.workdir || fileState.cwd || "") });
6763
+ upsertTimelineActivity({
6764
+ config,
6765
+ runtime,
6766
+ provider: "codex",
6767
+ threadId,
6768
+ threadLabel,
6769
+ phase: activity.phase,
6770
+ commandText: activity.commandText,
6771
+ fileRefs: activity.fileRefs,
6772
+ cwd: activity.cwd,
6773
+ source: "codex-tool-start",
6774
+ atMs: createdAtMs,
6775
+ });
6776
+ return [
6777
+ buildToolTimelineEntry({
6778
+ provider: "codex",
6779
+ threadId,
6780
+ threadLabel,
6781
+ callId,
6782
+ createdAtMs,
6783
+ fileEventType: classified.fileEventType,
6784
+ commandText: classified.commandText || commandText,
6785
+ fileRefs: classified.fileRefs,
6786
+ cwd: cleanText(args.workdir || fileState.cwd || ""),
6787
+ }),
6788
+ ].filter(Boolean);
6789
+ }
6790
+
5633
6791
  if (payloadType === "custom_tool_call") {
5634
6792
  rememberApplyPatchInput(fileState, payload, createdAtMs);
6793
+ upsertTimelineActivity({
6794
+ config,
6795
+ runtime,
6796
+ provider: "codex",
6797
+ threadId,
6798
+ threadLabel,
6799
+ phase: "editing",
6800
+ summary: cleanText(payload?.name || "apply_patch"),
6801
+ cwd: fileState.cwd || "",
6802
+ source: "codex-tool-start",
6803
+ atMs: createdAtMs,
6804
+ });
5635
6805
  return [];
5636
6806
  }
5637
6807
 
5638
6808
  if (payloadType === "function_call_output") {
6809
+ if (findStoredToolEventInput(fileState, callId)) {
6810
+ upsertTimelineActivity({
6811
+ config,
6812
+ runtime,
6813
+ provider: "codex",
6814
+ threadId,
6815
+ threadLabel,
6816
+ phase: "thinking",
6817
+ cwd: fileState.cwd || "",
6818
+ source: "codex-tool-output",
6819
+ atMs: createdAtMs,
6820
+ });
6821
+ return [];
6822
+ }
5639
6823
  const commandText = extractCommandLineFromFunctionOutput(payload.output ?? "");
5640
- const fileRefs = extractReadFileRefsFromCommand(commandText);
5641
- if (fileRefs.length === 0) {
6824
+ if (!commandText) {
5642
6825
  return [];
5643
6826
  }
6827
+ const classified = classifyTimelineCommand(commandText);
6828
+ upsertTimelineActivity({
6829
+ config,
6830
+ runtime,
6831
+ provider: "codex",
6832
+ threadId,
6833
+ threadLabel,
6834
+ phase: "thinking",
6835
+ cwd: fileState.cwd || "",
6836
+ source: "codex-tool-output",
6837
+ atMs: createdAtMs,
6838
+ });
5644
6839
  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",
6840
+ buildToolTimelineEntry({
6841
+ provider: "codex",
5650
6842
  threadId,
5651
6843
  threadLabel,
5652
- title: fileEventTitle(DEFAULT_LOCALE, "read"),
5653
- summary: "",
5654
- fileRefs,
6844
+ callId,
5655
6845
  createdAtMs,
5656
- readOnly: true,
6846
+ fileEventType: classified.fileEventType,
6847
+ commandText: classified.commandText || commandText,
6848
+ fileRefs: classified.fileRefs,
6849
+ cwd: fileState.cwd || "",
5657
6850
  }),
5658
6851
  ].filter(Boolean);
5659
6852
  }
@@ -5801,6 +6994,18 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
5801
6994
  fileState.applyPatchInputsByCallId.delete(callId);
5802
6995
  }
5803
6996
 
6997
+ upsertTimelineActivity({
6998
+ config,
6999
+ runtime,
7000
+ provider: "codex",
7001
+ threadId,
7002
+ threadLabel,
7003
+ phase: "thinking",
7004
+ cwd: fileState.cwd || "",
7005
+ source: "codex-tool-output",
7006
+ atMs: createdAtMs,
7007
+ });
7008
+
5804
7009
  return entries.filter(Boolean);
5805
7010
  }
5806
7011
 
@@ -5857,8 +7062,24 @@ async function processScannedEvent({ config, runtime, state, event }) {
5857
7062
  let dirty = false;
5858
7063
 
5859
7064
  if (event.kind === "task_complete") {
7065
+ clearTimelineActivity({
7066
+ config,
7067
+ runtime,
7068
+ provider: "codex",
7069
+ threadId: event.threadId ?? event.conversationId ?? "",
7070
+ source: "codex-task-complete",
7071
+ atMs: event.timestampMs || Date.now(),
7072
+ });
5860
7073
  attachCompletionDetails({ config, runtime, event });
5861
7074
  } else if (event.kind === "plan_ready") {
7075
+ clearTimelineActivity({
7076
+ config,
7077
+ runtime,
7078
+ provider: "codex",
7079
+ threadId: event.threadId ?? event.conversationId ?? "",
7080
+ source: "codex-plan-ready",
7081
+ atMs: event.timestampMs || Date.now(),
7082
+ });
5862
7083
  attachPlanDetails({ config, runtime, event });
5863
7084
  }
5864
7085
 
@@ -5884,6 +7105,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
5884
7105
  subtab: "completed",
5885
7106
  token: historyToken(event.id),
5886
7107
  stableId: event.id,
7108
+ dedupeId: completionPushContentDedupeId(event),
7109
+ dedupeWindowMs: COMPLETION_PUSH_CONTENT_DEDUPE_WINDOW_MS,
5887
7110
  title: event.title,
5888
7111
  body: event.detailText || event.message,
5889
7112
  buildLocalizedContent: ({ locale }) => ({
@@ -6102,6 +7325,8 @@ function buildRolloutEvent({ record, filePath, fileState, sessionIndex, config,
6102
7325
  threadLabel: context.threadLabel,
6103
7326
  message,
6104
7327
  detailText,
7328
+ threadId,
7329
+ turnId,
6105
7330
  priority: config.completePriority,
6106
7331
  tags: config.completeTags,
6107
7332
  clickUrl: config.clickUrl,
@@ -6335,7 +7560,7 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
6335
7560
  body: approval.messageText,
6336
7561
  buildLocalizedContent: ({ locale }) => ({
6337
7562
  title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
6338
- body: approval.messageText,
7563
+ body: formatNativeApprovalMessage(approval.kind, approval.rawParams, locale),
6339
7564
  }),
6340
7565
  }) || stateChanged;
6341
7566
  }
@@ -6671,7 +7896,11 @@ async function syncGenericUserInputRequests({
6671
7896
  userInputRequest.supported ? "server.title.choice" : "server.title.choiceReadOnly",
6672
7897
  userInputRequest.threadLabel
6673
7898
  ),
6674
- body: userInputRequest.notificationText || userInputRequest.messageText,
7899
+ body: buildUserInputNotificationText(
7900
+ userInputRequest.questions,
7901
+ userInputRequest.supported,
7902
+ locale
7903
+ ),
6675
7904
  }),
6676
7905
  }) || stateChanged;
6677
7906
  }
@@ -8469,7 +9698,7 @@ class NativeIpcClient {
8469
9698
  );
8470
9699
  }
8471
9700
 
8472
- async startTurn(conversationId, turnStartParams, ownerClientId = null) {
9701
+ async startTurn(conversationId, turnStartParams, ownerClientId = null, options = {}) {
8473
9702
  return this.sendThreadFollowerRequest(
8474
9703
  "thread-follower-start-turn",
8475
9704
  {
@@ -8477,11 +9706,12 @@ class NativeIpcClient {
8477
9706
  turnStartParams,
8478
9707
  },
8479
9708
  conversationId,
8480
- ownerClientId
9709
+ ownerClientId,
9710
+ options
8481
9711
  );
8482
9712
  }
8483
9713
 
8484
- async startTurnDirect(conversationId, turnStartParams, ownerClientId = null) {
9714
+ async startTurnDirect(conversationId, turnStartParams, ownerClientId = null, options = {}) {
8485
9715
  const targetClientId =
8486
9716
  ownerClientId ??
8487
9717
  this.runtime.threadOwnerClientIds.get(conversationId) ??
@@ -8489,7 +9719,7 @@ class NativeIpcClient {
8489
9719
  return this.sendRequest(
8490
9720
  "turn/start",
8491
9721
  buildDirectTurnStartPayload(conversationId, turnStartParams),
8492
- { targetClientId }
9722
+ { targetClientId, ...options }
8493
9723
  );
8494
9724
  }
8495
9725
 
@@ -8519,12 +9749,13 @@ class NativeIpcClient {
8519
9749
  );
8520
9750
  }
8521
9751
 
8522
- sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null) {
9752
+ sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null, options = {}) {
8523
9753
  return this.sendRequest(method, params, {
8524
9754
  targetClientId:
8525
9755
  ownerClientId ??
8526
9756
  this.runtime.threadOwnerClientIds.get(conversationId) ??
8527
9757
  null,
9758
+ ...options,
8528
9759
  });
8529
9760
  }
8530
9761
 
@@ -9407,9 +10638,10 @@ function formatNativeApprovalMessage(kind, params, locale = config?.defaultLocal
9407
10638
  }
9408
10639
 
9409
10640
  function formatCommandApprovalMessage(params, locale = config?.defaultLocale || DEFAULT_LOCALE) {
10641
+ const safeParams = isPlainObject(params) ? params : {};
9410
10642
  const parts = [];
9411
- const reason = truncate(cleanText(params.reason ?? params.justification ?? ""), 220);
9412
- const command = truncate(cleanText(params.command ?? params.cmd ?? ""), 1200);
10643
+ const reason = truncate(cleanText(safeParams.reason ?? safeParams.justification ?? ""), 220);
10644
+ const command = truncate(cleanText(safeParams.command ?? safeParams.cmd ?? ""), 1200);
9413
10645
  if (reason) {
9414
10646
  parts.push(reason);
9415
10647
  } else {
@@ -9422,21 +10654,41 @@ function formatCommandApprovalMessage(params, locale = config?.defaultLocale ||
9422
10654
  }
9423
10655
 
9424
10656
  function formatFileApprovalMessage(params, locale = config?.defaultLocale || DEFAULT_LOCALE) {
10657
+ const safeParams = isPlainObject(params) ? params : {};
9425
10658
  const parts = [];
9426
- const reason = truncate(cleanText(params.reason ?? ""), 220);
10659
+ const reason = truncate(cleanText(safeParams.reason ?? ""), 220);
9427
10660
  if (reason) {
9428
10661
  parts.push(reason);
9429
10662
  } else {
9430
10663
  parts.push(t(locale, "server.message.fileApprovalNeeded"));
9431
10664
  }
9432
- if (params.grantRoot) {
9433
- parts.push(t(locale, "server.message.pathPrefix", { path: compactPath(params.grantRoot) }));
9434
- } else if (params.cwd) {
9435
- parts.push(t(locale, "server.message.pathPrefix", { path: compactPath(params.cwd) }));
10665
+ if (safeParams.grantRoot) {
10666
+ parts.push(t(locale, "server.message.pathPrefix", { path: compactPath(safeParams.grantRoot) }));
10667
+ } else if (safeParams.cwd) {
10668
+ parts.push(t(locale, "server.message.pathPrefix", { path: compactPath(safeParams.cwd) }));
9436
10669
  }
9437
10670
  return truncate(parts.join("\n"), 1024);
9438
10671
  }
9439
10672
 
10673
+ function claudeApprovalRawParams(body, kind) {
10674
+ const toolInput = isPlainObject(body?.toolInput) ? cloneJson(body.toolInput) : {};
10675
+ const rawParams = {
10676
+ ...toolInput,
10677
+ ...(body?.cwd ? { cwd: String(body.cwd) } : {}),
10678
+ };
10679
+ const messageText = cleanText(body?.messageText || "");
10680
+ if (messageText && !cleanText(rawParams.reason ?? rawParams.justification ?? "")) {
10681
+ rawParams.reason = messageText;
10682
+ }
10683
+ if (kind === "command" && !cleanText(rawParams.command ?? rawParams.cmd ?? "")) {
10684
+ const commandText = cleanText(toolInput.command ?? toolInput.cmd ?? "");
10685
+ if (commandText) {
10686
+ rawParams.command = commandText;
10687
+ }
10688
+ }
10689
+ return rawParams;
10690
+ }
10691
+
9440
10692
  function extractApprovalFileRefs(params) {
9441
10693
  if (!isPlainObject(params)) {
9442
10694
  return [];
@@ -10866,6 +12118,10 @@ async function handleMcpProviderEvent({ config, runtime, state, body, res }) {
10866
12118
  stableId,
10867
12119
  title,
10868
12120
  body: messageText,
12121
+ buildLocalizedContent: ({ locale }) => ({
12122
+ title: localizedMcpNotifyTitle(locale, title),
12123
+ body: pushBodySnippet(messageText),
12124
+ }),
10869
12125
  }).catch((error) => {
10870
12126
  console.error(`[mcp-notify-push] ${error.message}`);
10871
12127
  return false;
@@ -10897,10 +12153,7 @@ async function handleMcpProviderEvent({ config, runtime, state, body, res }) {
10897
12153
  stableId: pendingApprovalStableId(approval),
10898
12154
  title: approval.title || "MCP",
10899
12155
  body: approval.messageText,
10900
- buildLocalizedContent: () => ({
10901
- title: approval.title || "MCP",
10902
- body: approval.messageText,
10903
- }),
12156
+ buildLocalizedContent: ({ locale }) => localizedMcpApprovalPushContent(locale, approval),
10904
12157
  }).catch((error) => {
10905
12158
  console.error(`[mcp-approval-push] ${approval.requestKey} | ${error.message}`);
10906
12159
  });
@@ -11265,6 +12518,17 @@ function historyItemByToken(runtime, kind, token) {
11265
12518
  ) ?? null;
11266
12519
  }
11267
12520
 
12521
+ function approvalDecisionFromHistoryItem(item) {
12522
+ const outcome = cleanText(item?.outcome || "");
12523
+ if (outcome === "approved") {
12524
+ return "accept";
12525
+ }
12526
+ if (outcome === "rejected") {
12527
+ return "decline";
12528
+ }
12529
+ return "";
12530
+ }
12531
+
11268
12532
  function listLatestPersistedUserInputRequests({ config, runtime, state }) {
11269
12533
  const byToken = new Map();
11270
12534
  for (const userInputRequest of runtime.userInputRequestsByToken.values()) {
@@ -11846,6 +13110,26 @@ function buildTimelineThreads(entries, config) {
11846
13110
  .slice(0, config.maxTimelineThreads);
11847
13111
  }
11848
13112
 
13113
+ function buildActiveTimelineActivityEntries(runtime, locale) {
13114
+ if (!(runtime?.activeTimelineActivitiesByKey instanceof Map)) {
13115
+ return [];
13116
+ }
13117
+ const now = Date.now();
13118
+ const entries = [];
13119
+ for (const [key, activity] of [...runtime.activeTimelineActivitiesByKey.entries()]) {
13120
+ const updatedAtMs = Number(activity?.updatedAtMs) || 0;
13121
+ if (!updatedAtMs || now - updatedAtMs > TIMELINE_ACTIVITY_TTL_MS) {
13122
+ runtime.activeTimelineActivitiesByKey.delete(key);
13123
+ continue;
13124
+ }
13125
+ const entry = buildTimelineActivityEntry(activity, locale);
13126
+ if (entry) {
13127
+ entries.push(entry);
13128
+ }
13129
+ }
13130
+ return entries;
13131
+ }
13132
+
11849
13133
  function buildTimelineResponse(runtime, state, config, locale) {
11850
13134
  const messageEntries = normalizeTimelineEntries(
11851
13135
  state.recentTimelineEntries ?? runtime.recentTimelineEntries,
@@ -11853,7 +13137,11 @@ function buildTimelineResponse(runtime, state, config, locale) {
11853
13137
  );
11854
13138
  runtime.recentTimelineEntries = messageEntries;
11855
13139
  const entries = normalizeTimelineEntries(
11856
- [...messageEntries, ...buildOperationalTimelineEntries(runtime, state, config, locale)],
13140
+ [
13141
+ ...buildActiveTimelineActivityEntries(runtime, locale),
13142
+ ...messageEntries,
13143
+ ...buildOperationalTimelineEntries(runtime, state, config, locale),
13144
+ ],
11857
13145
  config.maxTimelineEntries
11858
13146
  ).map((entry) => ({
11859
13147
  kind: entry.kind,
@@ -11871,6 +13159,9 @@ function buildTimelineResponse(runtime, state, config, locale) {
11871
13159
  outcome: entry.outcome || "",
11872
13160
  createdAtMs: entry.createdAtMs,
11873
13161
  provider: normalizeProvider(entry.provider),
13162
+ ...(entry.activityPhase != null ? { activityPhase: entry.activityPhase } : {}),
13163
+ ...(entry.commandText != null ? { commandText: entry.commandText } : {}),
13164
+ ...timelineAutoPilotProjection(entry),
11874
13165
  ...(entry.draftType != null ? { draftType: entry.draftType } : {}),
11875
13166
  }));
11876
13167
 
@@ -11880,6 +13171,24 @@ function buildTimelineResponse(runtime, state, config, locale) {
11880
13171
  };
11881
13172
  }
11882
13173
 
13174
+ function timelineAutoPilotProjection(entry) {
13175
+ if (cleanText(entry?.kind || "") !== "approval" || cleanText(entry?.outcome || "") !== "approved") {
13176
+ return {};
13177
+ }
13178
+ const stableId = cleanText(entry?.stableId || "");
13179
+ if (stableId.includes(":autopilot-write")) {
13180
+ const lane = cleanText(stableId.match(/:autopilot-write:([a-z_-]+)$/u)?.[1] || "");
13181
+ return {
13182
+ autoPilotMode: "write",
13183
+ ...(lane ? { autoPilotWriteLane: lane } : {}),
13184
+ };
13185
+ }
13186
+ if (stableId.endsWith(":autopilot")) {
13187
+ return { autoPilotMode: "read" };
13188
+ }
13189
+ return {};
13190
+ }
13191
+
11883
13192
  function buildPendingApprovalDetail(runtime, state, approval, locale) {
11884
13193
  const previousContext = buildPreviousApprovalContext(runtime, approval);
11885
13194
  const approvalKind = cleanText(approval.kind || "");
@@ -12338,6 +13647,48 @@ function findNewerThreadMessageAfterCompletion(runtime, completionItem) {
12338
13647
  .sort((left, right) => Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0))[0] || null;
12339
13648
  }
12340
13649
 
13650
+ function normalizeCompletionReplyComparisonText(value) {
13651
+ return normalizeLongText(value).replace(/\s+/gu, " ").trim();
13652
+ }
13653
+
13654
+ function isMatchingCompletionReplyTimelineMessage(entry, messageText, expectedImageCount = 0) {
13655
+ if (cleanText(entry?.kind || "") !== "user_message") {
13656
+ return false;
13657
+ }
13658
+ const expectedText = normalizeCompletionReplyComparisonText(messageText);
13659
+ const actualText = normalizeCompletionReplyComparisonText(entry?.messageText || entry?.summary || entry?.title || "");
13660
+ if (!expectedText || actualText !== expectedText) {
13661
+ return false;
13662
+ }
13663
+ const entryImageCount = normalizeTimelineImagePaths(entry?.imagePaths || []).length;
13664
+ return expectedImageCount <= 0 || entryImageCount === expectedImageCount;
13665
+ }
13666
+
13667
+ function findMatchingCompletionReplyAfterCompletion(runtime, completionItem, messageText, expectedImageCount = 0) {
13668
+ const itemKind = cleanText(completionItem?.kind || "");
13669
+ if (!runtime || !REPLYABLE_HISTORY_KINDS.has(itemKind)) {
13670
+ return null;
13671
+ }
13672
+
13673
+ const threadId = historyItemThreadId(completionItem);
13674
+ const completionCreatedAtMs = Number(completionItem?.createdAtMs) || 0;
13675
+ if (!threadId || !completionCreatedAtMs) {
13676
+ return null;
13677
+ }
13678
+
13679
+ return runtime.recentTimelineEntries
13680
+ .filter((entry) => {
13681
+ if (cleanText(entry?.threadId || "") !== threadId) {
13682
+ return false;
13683
+ }
13684
+ if (Number(entry?.createdAtMs) <= completionCreatedAtMs) {
13685
+ return false;
13686
+ }
13687
+ return isMatchingCompletionReplyTimelineMessage(entry, messageText, expectedImageCount);
13688
+ })
13689
+ .sort((left, right) => Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0))[0] || null;
13690
+ }
13691
+
12341
13692
  function buildHistoryDetail(item, locale, runtime = null) {
12342
13693
  const threadId = historyItemThreadId(item);
12343
13694
  const replyEnabled =
@@ -12455,6 +13806,13 @@ function buildAmbientSuggestionsDetail(entry, locale) {
12455
13806
 
12456
13807
  function buildTimelineFileEventDetail(entry, locale) {
12457
13808
  const fileEventType = normalizeTimelineFileEventType(entry?.fileEventType ?? "");
13809
+ const commandText = ["read", "search", "git"].includes(fileEventType)
13810
+ ? firstMarkdownCodeFenceText(entry?.messageText ?? "")
13811
+ : "";
13812
+ const detailText = [
13813
+ fileEventDetailCopy(locale, fileEventType, entry?.provider),
13814
+ commandText ? "" : normalizeTimelineMessageText(entry?.messageText ?? "", locale),
13815
+ ].filter(Boolean).join("\n\n");
12458
13816
  return {
12459
13817
  kind: "file_event",
12460
13818
  token: entry.token,
@@ -12463,9 +13821,10 @@ function buildTimelineFileEventDetail(entry, locale) {
12463
13821
  threadLabel: entry.threadLabel || "",
12464
13822
  fileEventType,
12465
13823
  createdAtMs: Number(entry.createdAtMs) || 0,
12466
- messageHtml: renderMessageHtml(fileEventDetailCopy(locale, fileEventType, entry?.provider), `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
13824
+ messageHtml: renderMessageHtml(detailText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
12467
13825
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
12468
13826
  previousFileRefs: normalizeTimelineFileRefs(entry.previousFileRefs ?? []),
13827
+ ...(commandText ? { commandText } : {}),
12469
13828
  diffAvailable: Boolean(entry.diffAvailable),
12470
13829
  diffText: normalizeTimelineDiffText(entry.diffText ?? ""),
12471
13830
  diffSource: normalizeTimelineDiffSource(entry.diffSource ?? ""),
@@ -12476,6 +13835,25 @@ function buildTimelineFileEventDetail(entry, locale) {
12476
13835
  };
12477
13836
  }
12478
13837
 
13838
+ function buildTimelineCommandEventDetail(entry, locale) {
13839
+ const commandText = firstMarkdownCodeFenceText(entry?.messageText ?? "") || cleanText(entry?.summary || "");
13840
+ const detailText = t(locale, "detail.commandEvent.copy", { provider: providerDisplayName(locale, entry?.provider) });
13841
+ return {
13842
+ kind: "command_event",
13843
+ token: entry.token,
13844
+ threadId: cleanText(entry.threadId || ""),
13845
+ title: cleanText(entry.threadLabel || entry.title || "") || kindTitle(locale, "command_event"),
13846
+ threadLabel: entry.threadLabel || "",
13847
+ fileEventType: "command",
13848
+ createdAtMs: Number(entry.createdAtMs) || 0,
13849
+ messageHtml: renderMessageHtml(detailText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
13850
+ commandText,
13851
+ fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
13852
+ readOnly: true,
13853
+ actions: [],
13854
+ };
13855
+ }
13856
+
12479
13857
  function buildDiffThreadDetail(group, locale) {
12480
13858
  const changedFileCount = Math.max(0, Number(group.changedFileCount) || 0);
12481
13859
  return {
@@ -12849,6 +14227,25 @@ async function handleCompletionReply({
12849
14227
  if (!conversationId) {
12850
14228
  throw new Error("completion-reply-unavailable");
12851
14229
  }
14230
+ console.log(
14231
+ `[completion-reply] received at=${new Date().toISOString()} ` +
14232
+ `token=${cleanText(completionItem?.token || "") || "unknown"} thread=${conversationId} ` +
14233
+ `images=${normalizedLocalImagePaths.length} plan=${planMode ? 1 : 0} force=${force ? 1 : 0}`
14234
+ );
14235
+
14236
+ const alreadyAcceptedReply = findMatchingCompletionReplyAfterCompletion(
14237
+ runtime,
14238
+ completionItem,
14239
+ messageText,
14240
+ normalizedLocalImagePaths.length
14241
+ );
14242
+ if (alreadyAcceptedReply && !force) {
14243
+ console.log(
14244
+ `[completion-reply] idempotent token=${cleanText(completionItem?.token || "") || "unknown"} thread=${conversationId} timeline=${cleanText(alreadyAcceptedReply.stableId || alreadyAcceptedReply.token || "") || "unknown"}`
14245
+ );
14246
+ return { alreadyAccepted: true };
14247
+ }
14248
+
12852
14249
  // For assistant_final, check against timeline entries (avoids maxHistoryItems eviction).
12853
14250
  // For legacy completion, check against history items.
12854
14251
  const itemKind = cleanText(completionItem?.kind || "");
@@ -12939,30 +14336,32 @@ async function handleCompletionReply({
12939
14336
  await runtime.ipcClient.startTurnDirect(
12940
14337
  conversationId,
12941
14338
  candidate.turnStartParams,
12942
- ownerClientId
14339
+ ownerClientId,
14340
+ { timeoutMs: config.completionReplyAckTimeoutMs }
12943
14341
  );
12944
14342
  } else {
12945
14343
  await runtime.ipcClient.startTurn(
12946
14344
  conversationId,
12947
14345
  candidate.turnStartParams,
12948
- ownerClientId
14346
+ ownerClientId,
14347
+ { timeoutMs: config.completionReplyAckTimeoutMs }
12949
14348
  );
12950
14349
  }
12951
14350
  console.log(
12952
- `[completion-reply] success candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
14351
+ `[completion-reply] success at=${new Date().toISOString()} candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")}`
12953
14352
  );
12954
14353
  await finalizeReplyAccepted();
12955
- return;
14354
+ return { alreadyAccepted: false };
12956
14355
  } catch (error) {
12957
14356
  if (isStartTurnAckTimeout(error)) {
12958
14357
  // Codex can create the turn but fail to send the bridge an ACK before
12959
14358
  // its internal follower timeout fires. Retrying risks duplicate user
12960
14359
  // turns, so treat this as accepted and let the timeline catch up.
12961
14360
  console.log(
12962
- `[completion-reply] accepted candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} ack-timeout=${normalizeIpcErrorMessage(error)}`
14361
+ `[completion-reply] accepted at=${new Date().toISOString()} candidate=${candidate.name} transport=${cleanText(candidate.transport || "thread-follower")} ack-timeout=${normalizeIpcErrorMessage(error)}`
12963
14362
  );
12964
14363
  await finalizeReplyAccepted();
12965
- return;
14364
+ return { alreadyAccepted: false, ackTimeout: true };
12966
14365
  }
12967
14366
  lastError = error;
12968
14367
  console.log(
@@ -13660,9 +15059,26 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
13660
15059
  });
13661
15060
  } else if (approval.resolveClaudeWaiter) {
13662
15061
  if (approval.provider === "claude" && approval.kind === "plan") {
13663
- // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
13664
- // (Claude still shows the native PC plan dialog). Instead, deny the tool
13665
- // call with a reason that tells Claude the user already decided on mobile.
15062
+ // ExitPlanMode CANNOT actually be auto-bypassed via the PreToolUse
15063
+ // hook. We tested both "allow" and "deny" verdicts on Claude Desktop:
15064
+ //
15065
+ // - permissionDecision: "allow"
15066
+ // Claude Desktop ignores the verdict and re-shows its native
15067
+ // plan dialog on the Mac, asking for approval a second time
15068
+ // even though the user already tapped on mobile.
15069
+ // - permissionDecision: "deny"
15070
+ // The native dialog is suppressed, but Claude Desktop also
15071
+ // does not exit plan mode internally, so Edit/Write stay
15072
+ // blocked — the session cannot make progress.
15073
+ //
15074
+ // Neither verdict gives us "phone tap → plan mode exits → editing
15075
+ // resumes" by itself. Until we can drive the Mac dialog through an
15076
+ // external mechanism (AppleScript / Accessibility API), the chosen
15077
+ // workaround is "deny + reason": Claude reads the reason as
15078
+ // instructions ("user already approved on mobile, proceed without
15079
+ // calling ExitPlanMode again"). That at least lets the desktop user
15080
+ // finish the flow with one extra Mac click on the native dialog
15081
+ // instead of getting a duplicate dialog from a stale "allow".
13666
15082
  approval.resolveClaudeWaiter({
13667
15083
  permissionDecision: "deny",
13668
15084
  permissionDecisionReason:
@@ -13718,6 +15134,10 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13718
15134
  const entry = timelineEntryByToken(runtime, token, kind);
13719
15135
  return entry ? buildTimelineFileEventDetail(entry, locale) : null;
13720
15136
  }
15137
+ if (kind === "command_event") {
15138
+ const entry = timelineEntryByToken(runtime, token, kind);
15139
+ return entry ? buildTimelineCommandEventDetail(entry, locale) : null;
15140
+ }
13721
15141
  if (kind === "ambient_suggestions") {
13722
15142
  const entry = timelineEntryByToken(runtime, token, kind);
13723
15143
  if (entry) {
@@ -13899,6 +15319,8 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
13899
15319
  provider: "a2a",
13900
15320
  instruction,
13901
15321
  messageText: responseText,
15322
+ viveworker: task?.viveworker || entry?.viveworker || {},
15323
+ paidDeliverable: task?.paidDeliverable || entry?.paidDeliverable || null,
13902
15324
  taskId: task?.id || "",
13903
15325
  taskStatus: task?.status || entry?.taskStatus || (kind === "a2a_task_result" ? "completed" : "submitted"),
13904
15326
  callerInfo: task?.callerInfo || {},
@@ -14168,7 +15590,7 @@ function buildWebAppHtml({ pairToken }) {
14168
15590
  <link rel="manifest" href="${escapeHtml(manifestHref)}">
14169
15591
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
14170
15592
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/viveworker-icon-192.png">
14171
- <link rel="stylesheet" href="/app.css">
15593
+ <link rel="stylesheet" href="/app.css?v=${escapeHtml(WEB_APP_BUILD_ID)}">
14172
15594
  <style>
14173
15595
  .boot-splash {
14174
15596
  position: fixed;
@@ -14378,6 +15800,107 @@ function logRemotePairingBootTrace(body, session, req) {
14378
15800
  }
14379
15801
  }
14380
15802
 
15803
+ function logClientEvent(body, session, req) {
15804
+ const type = cleanText(body?.type || "").slice(0, 80);
15805
+ if (type !== "timeline-render") {
15806
+ return;
15807
+ }
15808
+
15809
+ const route = cleanText(body?.route || "").slice(0, 24) || "unknown";
15810
+ const reason = cleanText(body?.reason || "").slice(0, 80) || "unknown";
15811
+ const latestToken = cleanText(body?.latestToken || "").slice(0, 80) || "none";
15812
+ const latestKind = cleanText(body?.latestKind || "").slice(0, 80) || "unknown";
15813
+ const appBuildId = cleanText(body?.appBuildId || "").slice(0, 80) || "unknown";
15814
+ const currentTab = cleanText(body?.currentTab || "").slice(0, 40) || "unknown";
15815
+ const entryCount = Math.max(0, Math.min(10_000, Math.round(Number(body?.entryCount) || 0)));
15816
+ const latestCreatedAtMs = Math.max(0, Math.min(9_999_999_999_999, Math.round(Number(body?.latestCreatedAtMs) || 0)));
15817
+ const clientAtMs = Math.max(0, Math.min(9_999_999_999_999, Math.round(Number(body?.clientAtMs) || 0)));
15818
+ const visible = body?.visible === true;
15819
+ const renderedTokens = Array.isArray(body?.renderedTokens)
15820
+ ? body.renderedTokens
15821
+ .slice(0, 5)
15822
+ .map((item) => {
15823
+ const token = cleanText(item?.token || "").slice(0, 80);
15824
+ const kind = cleanText(item?.kind || "").slice(0, 80);
15825
+ const createdAtMs = Math.max(0, Math.min(9_999_999_999_999, Math.round(Number(item?.createdAtMs) || 0)));
15826
+ return token && kind ? `${kind}:${token}:${createdAtMs}` : "";
15827
+ })
15828
+ .filter(Boolean)
15829
+ : [];
15830
+ const deviceId = cleanText(session?.deviceId || "").slice(0, 60) || "unknown";
15831
+ const ua = requestUserAgent(req).slice(0, 90);
15832
+
15833
+ console.log(
15834
+ `[client-event] timeline-render at=${new Date().toISOString()} device=${deviceId} ` +
15835
+ `route=${route} visible=${visible ? 1 : 0} tab=${currentTab} reason=${reason} ` +
15836
+ `latest=${latestKind}:${latestToken} latestCreatedAtMs=${latestCreatedAtMs} ` +
15837
+ `clientAtMs=${clientAtMs} count=${entryCount} build=${appBuildId} ` +
15838
+ `rendered=${renderedTokens.join(",") || "none"} ua=${JSON.stringify(ua)}`
15839
+ );
15840
+ }
15841
+
15842
+ function writeSseEvent(res, eventName, payload) {
15843
+ res.write(`event: ${eventName}\n`);
15844
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
15845
+ }
15846
+
15847
+ function openTimelineStream({ config, runtime, req, res, session }) {
15848
+ if (!config.timelineLiveSync) {
15849
+ return writeJson(res, 404, { error: "timeline-live-sync-disabled" });
15850
+ }
15851
+ res.statusCode = 200;
15852
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
15853
+ res.setHeader("Cache-Control", "no-cache, no-transform");
15854
+ res.setHeader("Connection", "keep-alive");
15855
+ res.setHeader("X-Accel-Buffering", "no");
15856
+ res.flushHeaders?.();
15857
+
15858
+ let closed = false;
15859
+ const deviceId = cleanText(session?.deviceId || "").slice(0, 60) || "unknown";
15860
+ const bus = ensureTimelineBus(runtime);
15861
+ const client = {
15862
+ send(eventName, payload) {
15863
+ if (closed || res.destroyed || res.writableEnded) {
15864
+ throw new Error("timeline-stream-closed");
15865
+ }
15866
+ writeSseEvent(res, eventName, payload);
15867
+ },
15868
+ close() {
15869
+ if (closed) return;
15870
+ closed = true;
15871
+ clearInterval(heartbeat);
15872
+ remove();
15873
+ try {
15874
+ res.end();
15875
+ } catch {}
15876
+ },
15877
+ };
15878
+ const remove = bus.addClient(client);
15879
+ const heartbeat = setInterval(() => {
15880
+ try {
15881
+ client.send("heartbeat", {
15882
+ atMs: Date.now(),
15883
+ revision: Number(runtime.timelineRevision) || 0,
15884
+ });
15885
+ } catch {
15886
+ client.close();
15887
+ }
15888
+ }, 20_000);
15889
+ heartbeat.unref?.();
15890
+
15891
+ req.on("close", () => client.close());
15892
+ req.on("aborted", () => client.close());
15893
+ res.on("error", () => client.close());
15894
+ client.send("hello", {
15895
+ ok: true,
15896
+ revision: Number(runtime.timelineRevision) || 0,
15897
+ appBuildId: WEB_APP_BUILD_ID,
15898
+ deviceId,
15899
+ atMs: Date.now(),
15900
+ });
15901
+ console.log(`[timeline-stream] open device=${deviceId} clients=${bus.clients.size}`);
15902
+ }
15903
+
14381
15904
  function recordRemotePairingAudit(event) {
14382
15905
  appendRemotePairingAuditEvent({
14383
15906
  atMs: Date.now(),
@@ -14550,7 +16073,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
14550
16073
  if (action === "approve") {
14551
16074
  // Resolve which executor to use.
14552
16075
  const available = runtime.a2aAvailableExecutors || { codex: false, claude: false };
14553
- const preference = requestedExecutor || state.a2aExecutorPreference || "ask";
16076
+ const preference = requestedExecutor || task.viveworker?.requestedExecutor || state.a2aExecutorPreference || "ask";
14554
16077
  let executor;
14555
16078
  if (preference === "codex" && available.codex) executor = "codex";
14556
16079
  else if (preference === "claude" && available.claude) executor = "claude";
@@ -15459,6 +16982,29 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
15459
16982
  return writeJson(res, 200, { ok: true });
15460
16983
  }
15461
16984
 
16985
+ // POST /api/client-events — local-only diagnostics from the PWA.
16986
+ //
16987
+ // The endpoint deliberately accepts only small sanitized metadata, not
16988
+ // message text, commands, file paths, relay tokens, or public keys. It is
16989
+ // used to correlate "bridge accepted" timings with "the PWA rendered the
16990
+ // latest timeline item" timings while debugging LAN/remote freshness.
16991
+ if (url.pathname === "/api/client-events" && req.method === "POST") {
16992
+ const session = requireApiSession(req, res, config, state);
16993
+ if (!session) return;
16994
+ let body;
16995
+ try {
16996
+ body = await parseJsonBody(req);
16997
+ } catch (err) {
16998
+ return writeJson(res, 400, { error: "invalid-json-body", message: err.message });
16999
+ }
17000
+ try {
17001
+ logClientEvent(body, session, req);
17002
+ } catch (err) {
17003
+ console.warn(`[client-event] failed to log event: ${err?.message}`);
17004
+ }
17005
+ return writeJson(res, 200, { ok: true });
17006
+ }
17007
+
15462
17008
  // POST /api/remote-pairing/toggle — flip on/off without restart.
15463
17009
  //
15464
17010
  // Body: { enabled: boolean }
@@ -16064,6 +17610,13 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16064
17610
  stableId: `thread_share:${shareId}`,
16065
17611
  title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
16066
17612
  body: String(content).slice(0, 160),
17613
+ buildLocalizedContent: ({ locale }) => localizedThreadSharePushContent(locale, {
17614
+ sourceLabel,
17615
+ sourceTool,
17616
+ targetLabel,
17617
+ targetTool,
17618
+ body: content,
17619
+ }),
16067
17620
  });
16068
17621
  } catch (error) {
16069
17622
  console.error(`[thread-share-push] ${error.message}`);
@@ -16163,6 +17716,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16163
17716
  stableId: `thread_share_fallback:${share.shareId}`,
16164
17717
  title: "Thread Share: target unreachable",
16165
17718
  body: `Codex thread "${share.targetLabel || share.targetConversationId.slice(0, 8)}" is not connected. Content saved to inbox.`,
17719
+ buildLocalizedContent: ({ locale }) => localizedThreadShareFallbackPushContent(
17720
+ locale,
17721
+ share.targetLabel || share.targetConversationId.slice(0, 8)
17722
+ ),
16166
17723
  });
16167
17724
  } catch (error) {
16168
17725
  console.error(`[thread-share-fallback-push] ${error.message}`);
@@ -16255,7 +17812,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16255
17812
  rangeDate = d.toISOString().slice(0, 10);
16256
17813
  }
16257
17814
 
16258
- const relevantKinds = new Set(["file_event", "completion", "plan_ready", "assistant_final"]);
17815
+ const relevantKinds = new Set(["file_event", "command_event", "completion", "plan_ready", "assistant_final"]);
16259
17816
  const entries = (runtime.recentTimelineEntries || [])
16260
17817
  .filter((e) => e.createdAtMs >= rangeStart && e.createdAtMs < rangeEnd && relevantKinds.has(e.kind))
16261
17818
  .map((e) => ({
@@ -16424,7 +17981,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16424
17981
  stableId: pendingApprovalStableId(approval),
16425
17982
  title: approval.title,
16426
17983
  body: approval.messageText,
16427
- buildLocalizedContent: () => ({ title: approval.title, body: approval.messageText }),
17984
+ buildLocalizedContent: ({ locale }) => localizedPaymentApprovalPushContent(locale, approval),
16428
17985
  }).catch((error) => {
16429
17986
  console.error(`[hazbase-wallet-payment-push] ${approval.requestKey} | ${error.message}`);
16430
17987
  });
@@ -16514,10 +18071,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16514
18071
  stableId: pendingApprovalStableId(approval),
16515
18072
  title: approval.title,
16516
18073
  body: approval.messageText,
16517
- buildLocalizedContent: () => ({
16518
- title: approval.title,
16519
- body: approval.messageText,
16520
- }),
18074
+ buildLocalizedContent: ({ locale }) => localizedPaymentApprovalPushContent(locale, approval),
16521
18075
  }).catch((error) => {
16522
18076
  console.error(`[payment-approval-push] ${approval.requestKey} | ${error.message}`);
16523
18077
  });
@@ -16589,6 +18143,13 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16589
18143
  const threadId = String(body.threadId || body.sessionId || "");
16590
18144
  console.log(`[claude-stop] threadId=${threadId} pendingTotal=${runtime.nativeApprovalsByToken.size}`);
16591
18145
  if (threadId) {
18146
+ clearTimelineActivity({
18147
+ config,
18148
+ runtime,
18149
+ provider: "claude",
18150
+ threadId,
18151
+ source: "claude-stop",
18152
+ });
16592
18153
  const pending = [];
16593
18154
  for (const approval of runtime.nativeApprovalsByToken.values()) {
16594
18155
  if (!approval.resolved && approval.conversationId === threadId) {
@@ -16659,6 +18220,21 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16659
18220
  // for this session/tool as accepted (so iPhone moves it to completed).
16660
18221
  const threadId = String(body.threadId || body.sessionId || "");
16661
18222
  const toolName = String(body.toolName || "");
18223
+ const eventCwd = String(body.cwd || "");
18224
+ const threadLabel =
18225
+ runtime.claudeSessionTitles.get(threadId) ||
18226
+ (eventCwd ? path.basename(eventCwd) : "") ||
18227
+ threadId.slice(0, 40);
18228
+ upsertTimelineActivity({
18229
+ config,
18230
+ runtime,
18231
+ provider: "claude",
18232
+ threadId,
18233
+ threadLabel,
18234
+ phase: "thinking",
18235
+ cwd: eventCwd,
18236
+ source: "claude-post-tool",
18237
+ });
16662
18238
  const fingerprint = claudeToolFingerprint(toolName, body.toolInput);
16663
18239
  console.log(`[claude-post-tool] threadId=${threadId} tool=${toolName} fp=${fingerprint.slice(0, 80)} pendingTotal=${runtime.nativeApprovalsByToken.size}`);
16664
18240
  const match = findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint);
@@ -16694,6 +18270,31 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16694
18270
  }
16695
18271
  }
16696
18272
 
18273
+ if (eventType === "PreToolUse") {
18274
+ const threadId = String(body.threadId || body.sessionId || "");
18275
+ const eventCwd = String(body.cwd || "");
18276
+ const toolName = String(body.toolName || "");
18277
+ const toolActivity = timelineActivityFromClaudeTool(toolName, isPlainObject(body.toolInput) ? body.toolInput : {});
18278
+ const threadLabel =
18279
+ runtime.claudeSessionTitles.get(threadId) ||
18280
+ (eventCwd ? path.basename(eventCwd) : "") ||
18281
+ threadId.slice(0, 40);
18282
+ upsertTimelineActivity({
18283
+ config,
18284
+ runtime,
18285
+ provider: "claude",
18286
+ threadId,
18287
+ threadLabel,
18288
+ phase: toolActivity.phase,
18289
+ summary: toolActivity.summary,
18290
+ commandText: toolActivity.commandText,
18291
+ fileRefs: toolActivity.fileRefs,
18292
+ cwd: eventCwd,
18293
+ source: "claude-pre-tool",
18294
+ });
18295
+ return writeJson(res, 200, {});
18296
+ }
18297
+
16697
18298
  if (eventType !== "approval_request") {
16698
18299
  // Non-approval events (Notification, Stop, etc.) — acknowledge immediately
16699
18300
  return writeJson(res, 200, {});
@@ -16705,6 +18306,8 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16705
18306
  const threadId = String(body.threadId || "");
16706
18307
  const requestId = String(body.requestId || crypto.randomUUID());
16707
18308
  const requestKey = `${threadId}:${requestId}`;
18309
+ const approvalKind = String(body.approvalKind || "command");
18310
+ const rawParams = claudeApprovalRawParams(body, approvalKind);
16708
18311
 
16709
18312
  const approval = {
16710
18313
  token,
@@ -16712,13 +18315,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16712
18315
  conversationId: threadId,
16713
18316
  requestId,
16714
18317
  ownerClientId: null,
16715
- kind: String(body.approvalKind || "command"),
18318
+ kind: approvalKind,
16716
18319
  threadLabel:
16717
18320
  runtime.claudeSessionTitles.get(threadId) ||
16718
18321
  (body.cwd ? path.basename(String(body.cwd)) : "") ||
16719
18322
  String(body.threadId || "").slice(0, 40),
16720
18323
  title: config.approvalTitle,
16721
18324
  messageText: String(body.messageText || ""),
18325
+ rawParams,
16722
18326
  fileRefs: Array.isArray(body.fileRefs) ? body.fileRefs : [],
16723
18327
  previousFileRefs: [],
16724
18328
  diffText: String(body.diffText || ""),
@@ -16741,6 +18345,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16741
18345
  readOnly: body.notifyOnly === true,
16742
18346
  };
16743
18347
 
18348
+ upsertTimelineActivity({
18349
+ config,
18350
+ runtime,
18351
+ provider: "claude",
18352
+ threadId,
18353
+ threadLabel: approval.threadLabel,
18354
+ phase: "awaiting_approval",
18355
+ summary: approval.messageText,
18356
+ fileRefs: approval.fileRefs,
18357
+ cwd: approval.cwd,
18358
+ source: "claude-approval-request",
18359
+ atMs: approval.createdAtMs,
18360
+ });
18361
+
16744
18362
  const claudeAutoPilotMatch =
16745
18363
  approval.kind === "command"
16746
18364
  ? maybeAutoApproveTrustedRead({
@@ -16797,21 +18415,28 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16797
18415
  runtime.nativeApprovalsByToken.set(token, approval);
16798
18416
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
16799
18417
 
16800
- deliverWebPushItem({
16801
- config,
16802
- state,
16803
- kind: "approval",
16804
- tab: "inbox",
16805
- subtab: "pending",
16806
- token: approval.token,
16807
- stableId: pendingApprovalStableId(approval),
16808
- title: approval.title,
16809
- body: approval.messageText,
16810
- buildLocalizedContent: ({ locale }) => ({
16811
- title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
18418
+ try {
18419
+ const pushChanged = await deliverWebPushItem({
18420
+ config,
18421
+ state,
18422
+ kind: "approval",
18423
+ tab: "inbox",
18424
+ subtab: "pending",
18425
+ token: approval.token,
18426
+ stableId: pendingApprovalStableId(approval),
18427
+ title: approval.title,
16812
18428
  body: approval.messageText,
16813
- }),
16814
- }).catch(() => {});
18429
+ buildLocalizedContent: ({ locale }) => ({
18430
+ title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
18431
+ body: formatNativeApprovalMessage(approval.kind, approval.rawParams, locale),
18432
+ }),
18433
+ });
18434
+ if (pushChanged) {
18435
+ await saveState(config.stateFile, state);
18436
+ }
18437
+ } catch (error) {
18438
+ console.error(`[claude-approval-push] ${approval.requestKey} | ${error.message}`);
18439
+ }
16815
18440
 
16816
18441
  // Notify-only path: phone gets a read-only entry + push, but the
16817
18442
  // hook does not block on a decision. The PostToolUse handler will
@@ -16938,6 +18563,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
16938
18563
  stableId: `moltbook_reply:${item.sourceId}`,
16939
18564
  title: item.title,
16940
18565
  body: item.summary,
18566
+ buildLocalizedContent: ({ locale }) => localizedMoltbookReplyPushContent(locale, item),
16941
18567
  });
16942
18568
  } catch (error) {
16943
18569
  console.error(`[moltbook-push-error] ${error.message}`);
@@ -17033,6 +18659,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17033
18659
  if (draftType === "reply" && !postId) {
17034
18660
  return writeJson(res, 400, { error: "missing-postId-for-reply" });
17035
18661
  }
18662
+ if (draftType !== "original_post") {
18663
+ const scoutState = rollScoutDayIfNeeded(await readScoutState());
18664
+ const quota = await getMoltbookReplyQuotaState({ state: scoutState, maxDaily: 5 });
18665
+ if (quota.quotaReached) {
18666
+ await writeScoutState(scoutState);
18667
+ return writeJson(res, 409, {
18668
+ error: "moltbook-reply-quota-reached",
18669
+ sentToday: quota.sentToday,
18670
+ pendingToday: quota.pendingToday,
18671
+ usedToday: quota.usedToday,
18672
+ maxDaily: quota.maxDaily,
18673
+ day: quota.day,
18674
+ });
18675
+ }
18676
+ await writeScoutState(scoutState);
18677
+ }
17036
18678
  const token = historyToken(`moltbook_draft:${sourceId}`);
17037
18679
  const postTitle = cleanText(body.postTitle || "");
17038
18680
  const postUrl = cleanText(body.postUrl || `https://www.moltbook.com/post/${postId}`);
@@ -17097,20 +18739,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17097
18739
  console.error(`[moltbook-draft-timeline-save] ${error.message}`);
17098
18740
  }
17099
18741
  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
- })();
18742
+ const pushTitle = localizedMoltbookDraftPushTitle(config.defaultLocale, { draftType, postTitle, slot });
17114
18743
  await deliverWebPushItem({
17115
18744
  config,
17116
18745
  state,
@@ -17121,6 +18750,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17121
18750
  stableId: `moltbook_draft:${sourceId}`,
17122
18751
  title: pushTitle,
17123
18752
  body: draftType === "original_post" ? (postTitle || String(draftText).slice(0, 160)) : String(draftText).slice(0, 160),
18753
+ buildLocalizedContent: ({ locale }) => ({
18754
+ title: localizedMoltbookDraftPushTitle(locale, { draftType, postTitle, slot }),
18755
+ body: draftType === "original_post" ? (postTitle || pushBodySnippet(draftText)) : pushBodySnippet(draftText),
18756
+ }),
17124
18757
  });
17125
18758
  } catch (error) {
17126
18759
  console.error(`[moltbook-draft-push-error] ${error.message}`);
@@ -17177,6 +18810,17 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17177
18810
  const action = String(body.action || "") === "approve" ? "approve" : "deny";
17178
18811
  const editedText = cleanText(body.editedText || "");
17179
18812
  const editedTitle = cleanText(body.editedTitle || "");
18813
+ if (action === "approve" && draft.draftType !== "original_post") {
18814
+ const quota = await reserveMoltbookReplyQuotaSlot(draft, 5);
18815
+ if (quota.quotaReached) {
18816
+ return writeJson(res, 409, {
18817
+ error: "moltbook-reply-quota-reached",
18818
+ sentToday: quota.sentToday,
18819
+ maxDaily: quota.maxDaily,
18820
+ day: quota.day,
18821
+ });
18822
+ }
18823
+ }
17180
18824
  const decision = {
17181
18825
  action,
17182
18826
  text: action === "approve" ? editedText || draft.draftText : "",
@@ -17210,6 +18854,15 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17210
18854
  await executeMoltbookDraftPost(draft, config, runtime, state);
17211
18855
  } catch (postError) {
17212
18856
  console.error(`[moltbook-draft-post-error] ${postError.message}`);
18857
+ await releaseMoltbookReplyQuotaSlot(draft).catch((error) => console.error(`[moltbook-quota-release] ${error.message}`));
18858
+ if (draft.parentCommentId) {
18859
+ await updateInboxStatus(draft.parentCommentId, "pending", {
18860
+ source: "mobile-draft-approve",
18861
+ draftStatus: "failed",
18862
+ draftToken: draft.token,
18863
+ draftError: String(postError.message || "").slice(0, 500),
18864
+ }).catch((error) => console.error(`[moltbook-inbox-post-failed] ${error.message}`));
18865
+ }
17213
18866
  try {
17214
18867
  await deliverWebPushItem({
17215
18868
  config, state, kind: "moltbook_draft", token: draft.token,
@@ -17218,6 +18871,10 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17218
18871
  stableId: `moltbook_draft_failed:${draft.sourceId}`,
17219
18872
  title: "Draft post failed",
17220
18873
  body: String(postError.message || "").slice(0, 160),
18874
+ buildLocalizedContent: ({ locale }) => ({
18875
+ title: t(locale, "server.push.moltbookDraft.failedTitle"),
18876
+ body: pushBodySnippet(postError.message || ""),
18877
+ }),
17221
18878
  });
17222
18879
  } catch { /* ignore push error */ }
17223
18880
  }
@@ -17225,6 +18882,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17225
18882
  setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120_000).unref?.();
17226
18883
  })();
17227
18884
  } else {
18885
+ if (draft.parentCommentId) {
18886
+ await updateInboxStatus(draft.parentCommentId, "skipped", {
18887
+ source: "mobile-draft-deny",
18888
+ draftStatus: "denied",
18889
+ draftToken: draft.token,
18890
+ skippedAt: new Date().toISOString(),
18891
+ }).catch((error) => console.error(`[moltbook-inbox-denied] ${error.message}`));
18892
+ }
17228
18893
  // Deny — clean up.
17229
18894
  await deleteDraft(token).catch(() => {});
17230
18895
  setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120_000).unref?.();
@@ -17259,6 +18924,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17259
18924
  return writeJson(res, 200, buildTimelineResponse(runtime, state, config, locale));
17260
18925
  }
17261
18926
 
18927
+ if (url.pathname === "/api/timeline/stream" && req.method === "GET") {
18928
+ const session = requireApiSession(req, res, config, state);
18929
+ if (!session) {
18930
+ return;
18931
+ }
18932
+ return openTimelineStream({ config, runtime, req, res, session });
18933
+ }
18934
+
17262
18935
  const apiTimelineImageMatch = url.pathname.match(/^\/api\/timeline\/([^/]+)\/images\/(\d+)$/u);
17263
18936
  if (apiTimelineImageMatch && req.method === "GET") {
17264
18937
  const token = decodeURIComponent(apiTimelineImageMatch[1]);
@@ -17327,7 +19000,7 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17327
19000
  const payload = contentType.includes("multipart/form-data")
17328
19001
  ? await stageCompletionReplyImages(config, req)
17329
19002
  : await parseJsonBody(req);
17330
- await handleCompletionReply({
19003
+ const replyResult = await handleCompletionReply({
17331
19004
  config,
17332
19005
  runtime,
17333
19006
  state,
@@ -17341,6 +19014,8 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17341
19014
  ok: true,
17342
19015
  planMode: payload?.planMode === true,
17343
19016
  imageCount: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths.length : 0,
19017
+ alreadyAccepted: replyResult?.alreadyAccepted === true,
19018
+ ackTimeout: replyResult?.ackTimeout === true,
17344
19019
  });
17345
19020
  } catch (error) {
17346
19021
  if (error.message === "completion-reply-empty") {
@@ -17381,6 +19056,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17381
19056
  const decision = apiApprovalDecisionMatch[2];
17382
19057
  const approval = runtime.nativeApprovalsByToken.get(token);
17383
19058
  if (!approval) {
19059
+ const historicalDecision = approvalDecisionFromHistoryItem(historyItemByToken(runtime, "approval", token));
19060
+ if (historicalDecision) {
19061
+ if (historicalDecision === decision) {
19062
+ return writeJson(res, 200, {
19063
+ ok: true,
19064
+ decision,
19065
+ alreadyHandled: true,
19066
+ });
19067
+ }
19068
+ return writeJson(res, 409, {
19069
+ error: "approval-already-handled",
19070
+ decision: historicalDecision,
19071
+ });
19072
+ }
17384
19073
  return writeJson(res, 404, { error: "approval-not-found" });
17385
19074
  }
17386
19075
  if (approval.resolved || approval.resolving) {
@@ -19283,9 +20972,21 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
19283
20972
  createdPostId = draft.postId || null;
19284
20973
  createdCommentId = comment?.id || null;
19285
20974
  console.log(`[moltbook-draft-post] Posted reply (commentId=${comment?.id}) to post ${draft.postId}`);
20975
+ if (draft.parentCommentId) {
20976
+ await updateInboxStatus(draft.parentCommentId, "replied", {
20977
+ source: "mobile-draft-approve",
20978
+ replyText: finalText,
20979
+ replyCommentId: createdCommentId || "",
20980
+ replyVerification: verification || null,
20981
+ draftStatus: "posted",
20982
+ draftToken: draft.token,
20983
+ }).catch((error) => console.error(`[moltbook-inbox-replied] ${error.message}`));
20984
+ }
19286
20985
 
19287
20986
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
19288
- scoutState.sentToday += 1;
20987
+ if (!draft.replyQuotaReserved) {
20988
+ scoutState.sentToday += 1;
20989
+ }
19289
20990
  markPostSeen(scoutState, draft.postId, "published");
19290
20991
  // Append the reply to `recentComposeTitles` so it shows up in the
19291
20992
  // "最近の投稿" list with its reply badge. `recordComposeAttempt` knows
@@ -19381,6 +21082,10 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
19381
21082
  stableId: `moltbook_draft_posted:${draft.sourceId}`,
19382
21083
  title: "Moltbook posted",
19383
21084
  body: truncate(singleLine(pushTitle), 160),
21085
+ buildLocalizedContent: ({ locale }) => ({
21086
+ title: t(locale, "server.push.moltbookDraft.postedTitle"),
21087
+ body: localizedMoltbookPostedBody(locale, draft, finalTitle),
21088
+ }),
19384
21089
  });
19385
21090
  } catch { /* ignore push error */ }
19386
21091
 
@@ -19406,6 +21111,10 @@ function buildConfig(cli) {
19406
21111
  a2aRelayUserId: cleanText(process.env.A2A_RELAY_USER_ID || ""),
19407
21112
  a2aRelaySecret: cleanText(process.env.A2A_RELAY_SECRET || ""),
19408
21113
  a2aRelayRegisterSecret: cleanText(process.env.A2A_RELAY_REGISTER_SECRET || ""),
21114
+ a2aProModel: cleanText(process.env.A2A_PRO_MODEL || ""),
21115
+ a2aProPrice: cleanText(process.env.A2A_PRO_PRICE || ""),
21116
+ a2aProPayTo: cleanText(process.env.A2A_PRO_PAY_TO || ""),
21117
+ a2aProExpiresDays: cleanText(process.env.A2A_PRO_EXPIRES_DAYS || "7"),
19409
21118
  // Remote-pairing relay (PWA off-LAN). Off by default — bridges that
19410
21119
  // never leave the trusted LAN don't need to open an outbound connection
19411
21120
  // to the relay. Toggle with REMOTE_PAIRING_ENABLED=true in
@@ -19439,6 +21148,8 @@ function buildConfig(cli) {
19439
21148
  process.env.TIMELINE_ATTACHMENTS_DIR || path.join(path.dirname(stateFile), "timeline-attachments")
19440
21149
  ),
19441
21150
  pollIntervalMs: numberEnv("POLL_INTERVAL_MS", 2500),
21151
+ timelineLiveSync: boolEnv("TIMELINE_LIVE_SYNC", true),
21152
+ timelineLiveDebounceMs: numberEnv("TIMELINE_LIVE_DEBOUNCE_MS", 75),
19442
21153
  replaySeconds: numberEnv("REPLAY_SECONDS", 300),
19443
21154
  sessionIndexRefreshMs: numberEnv("SESSION_INDEX_REFRESH_MS", 30000),
19444
21155
  directoryScanIntervalMs: numberEnv("DIRECTORY_SCAN_INTERVAL_MS", 30000),
@@ -19497,6 +21208,10 @@ function buildConfig(cli) {
19497
21208
  ipcSocketPath: resolvePath(process.env.CODEX_IPC_SOCKET_PATH || defaultIpcSocketPath()),
19498
21209
  ipcReconnectMs: numberEnv("IPC_RECONNECT_MS", 1500),
19499
21210
  ipcRequestTimeoutMs: numberEnv("IPC_REQUEST_TIMEOUT_MS", 12000),
21211
+ completionReplyAckTimeoutMs: numberEnv(
21212
+ "COMPLETION_REPLY_ACK_TIMEOUT_MS",
21213
+ DEFAULT_COMPLETION_REPLY_ACK_TIMEOUT_MS
21214
+ ),
19500
21215
  choicePageSize: numberEnv("CHOICE_PAGE_SIZE", 5),
19501
21216
  completionReplyImageMaxBytes: numberEnv(
19502
21217
  "COMPLETION_REPLY_IMAGE_MAX_BYTES",
@@ -21419,11 +23134,13 @@ async function main() {
21419
23134
  `notifyPlans=${config.notifyPlans}`,
21420
23135
  `nativeApprovalServer=${config.nativeApprovalPublicBaseUrl}`,
21421
23136
  `pollMs=${config.pollIntervalMs}`,
23137
+ `timelineLive=${config.timelineLiveSync}`,
21422
23138
  `replaySeconds=${config.replaySeconds}`,
21423
23139
  ].join(" | ")
21424
23140
  );
21425
23141
 
21426
23142
  let approvalServer = null;
23143
+ let timelineLiveHandle = null;
21427
23144
 
21428
23145
  process.on("SIGINT", handleSignal);
21429
23146
  process.on("SIGTERM", handleSignal);
@@ -21562,6 +23279,8 @@ async function main() {
21562
23279
  }
21563
23280
  }
21564
23281
 
23282
+ timelineLiveHandle = startTimelineLiveSync({ config, runtime, state });
23283
+
21565
23284
  // --- A2A Relay ---
21566
23285
  if (config.a2aRelayUrl && config.a2aRelayUserId) {
21567
23286
  config.a2aAcceptPublicTasks = state.a2aAcceptPublicTasks === true;
@@ -21694,6 +23413,13 @@ async function main() {
21694
23413
  } finally {
21695
23414
  runtime.stopping = true;
21696
23415
 
23416
+ if (timelineLiveHandle) {
23417
+ try {
23418
+ timelineLiveHandle.close();
23419
+ } catch {}
23420
+ timelineLiveHandle = null;
23421
+ }
23422
+
21697
23423
  stopRelayPolling();
21698
23424
 
21699
23425
  if (runtime.remotePairingHandle) {