viveworker 0.8.2 → 0.8.4

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.
@@ -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", "command_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;
@@ -70,6 +70,7 @@ const COMPLETION_PUSH_CONTENT_DEDUPE_WINDOW_MS = 2 * 60 * 1000;
70
70
  const HAZBASE_METADATA_TIMEOUT_MS = 1500;
71
71
  const NPM_VERSION_CHECK_TIMEOUT_MS = 2500;
72
72
  const NPM_VERSION_CHECK_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
73
+ const TIMELINE_ACTIVITY_TTL_MS = 2 * 60 * 1000;
73
74
 
74
75
  // Shared memo for buildDiffThreadGroups. Each call spawns 3 git subprocesses
75
76
  // per tracked repo (`git diff --name-status`, `git status --porcelain`,
@@ -265,6 +266,7 @@ const runtime = {
265
266
  completionDetailsByToken: new Map(),
266
267
  moltbookItemsByToken: new Map(),
267
268
  moltbookDraftsByToken: new Map(),
269
+ moltbookReplyQuotaQueue: Promise.resolve(),
268
270
  a2aTasksByToken: new Map(),
269
271
  threadSharesByToken: new Map(),
270
272
  threadRegistry: new Map(),
@@ -272,6 +274,7 @@ const runtime = {
272
274
  recentHistoryItems: [],
273
275
  recentTimelineEntries: [],
274
276
  recentCodeEvents: [],
277
+ activeTimelineActivitiesByKey: new Map(),
275
278
  timelineBus: null,
276
279
  timelineRevision: 0,
277
280
  timelineLiveWatchers: [],
@@ -346,6 +349,58 @@ setInterval(async () => {
346
349
  }
347
350
  }
348
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
+ }
349
404
  const initialHistoryItems = normalizeHistoryItems(state.recentHistoryItems ?? [], config.maxHistoryItems);
350
405
  const initialTimelineEntries = normalizeTimelineEntries(state.recentTimelineEntries ?? [], config.maxTimelineEntries);
351
406
  const normalizedHistoryStateChanged =
@@ -514,6 +569,8 @@ function kindTitle(locale, kind) {
514
569
  return t(locale, "server.title.assistantCommentary");
515
570
  case "assistant_final":
516
571
  return t(locale, "server.title.assistantFinal");
572
+ case "activity_status":
573
+ return t(locale, "server.title.activityStatus");
517
574
  case "ambient_suggestions":
518
575
  return t(locale, "server.title.ambientSuggestions");
519
576
  case "approval":
@@ -829,7 +886,7 @@ function normalizeTimelineOutcome(value) {
829
886
 
830
887
  function normalizeTimelineFileEventType(value) {
831
888
  const normalized = cleanText(value || "").toLowerCase();
832
- return ["read", "search", "command", "write", "create", "delete", "rename"].includes(normalized) ? normalized : "";
889
+ return ["read", "search", "git", "command", "write", "create", "delete", "rename"].includes(normalized) ? normalized : "";
833
890
  }
834
891
 
835
892
  function fileEventTitle(locale, fileEventType) {
@@ -838,6 +895,8 @@ function fileEventTitle(locale, fileEventType) {
838
895
  return t(locale, "fileEvent.read");
839
896
  case "search":
840
897
  return t(locale, "fileEvent.search");
898
+ case "git":
899
+ return t(locale, "fileEvent.command");
841
900
  case "command":
842
901
  return t(locale, "fileEvent.command");
843
902
  case "write":
@@ -860,6 +919,8 @@ function fileEventDetailCopy(locale, fileEventType, provider) {
860
919
  return t(locale, "detail.fileEvent.read", vars);
861
920
  case "search":
862
921
  return t(locale, "detail.fileEvent.search", vars);
922
+ case "git":
923
+ return t(locale, "detail.fileEvent.command", vars);
863
924
  case "command":
864
925
  return t(locale, "detail.fileEvent.command", vars);
865
926
  case "write":
@@ -1531,7 +1592,7 @@ function evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot }) {
1531
1592
  return null;
1532
1593
  }
1533
1594
 
1534
- const command = cleanText(tokens[0]);
1595
+ const command = commandBaseName(tokens[0]);
1535
1596
  if (!command || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(command)) {
1536
1597
  return null;
1537
1598
  }
@@ -1606,8 +1667,7 @@ function classifyTimelineCommand(commandText) {
1606
1667
  if (["rg", "grep", "ag", "ack", "fd", "find"].includes(command) || gitSubcommand === "grep") {
1607
1668
  fileEventType = "search";
1608
1669
  } else if (
1609
- ["cat", "sed", "nl", "head", "tail", "wc", "ls", "pwd", "less", "more"].includes(command) ||
1610
- (command === "git" && ["status", "diff", "show", "log", "ls-files", "branch"].includes(gitSubcommand))
1670
+ ["cat", "sed", "nl", "head", "tail", "wc", "ls", "pwd", "less", "more"].includes(command)
1611
1671
  ) {
1612
1672
  fileEventType = "read";
1613
1673
  }
@@ -1668,15 +1728,19 @@ function extractReadFileRefsFromCommand(commandText) {
1668
1728
  return [];
1669
1729
  }
1670
1730
 
1671
- if (command === "cat" || command === "nl") {
1672
- 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"]) }));
1673
1737
  }
1674
1738
 
1675
1739
  if (command === "sed") {
1676
1740
  if (!tokens.includes("-n")) {
1677
1741
  return [];
1678
1742
  }
1679
- return normalizeTimelineFileRefs(tokens.slice(1).filter((token) => !String(token || "").startsWith("-")));
1743
+ return normalizeTimelineFileRefs(sedCommandPathArgs(tokens));
1680
1744
  }
1681
1745
 
1682
1746
  if (command === "rg") {
@@ -1707,6 +1771,57 @@ function extractReadFileRefsFromCommand(commandText) {
1707
1771
  return [];
1708
1772
  }
1709
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
+
1710
1825
  function autoPilotApprovalMessage(locale, commandText) {
1711
1826
  const prefix = t(locale, "server.message.autoPilotTrustedReadApproved");
1712
1827
  const commandBlock = cleanText(commandText || "")
@@ -3377,6 +3492,8 @@ function migrateRecentCodeEventsState({ config, runtime, state }) {
3377
3492
 
3378
3493
  function timelineKindSortPriority(kind) {
3379
3494
  switch (cleanText(kind || "")) {
3495
+ case "activity_status":
3496
+ return 90;
3380
3497
  case "completion":
3381
3498
  return 70;
3382
3499
  case "approval":
@@ -3490,6 +3607,8 @@ function normalizeTimelineEntry(raw) {
3490
3607
  tone: cleanText(raw.tone ?? "") || "secondary",
3491
3608
  cwd: resolvePath(cleanText(raw.cwd || "")),
3492
3609
  provider: normalizeProvider(raw.provider),
3610
+ ...(raw.activityPhase != null ? { activityPhase: cleanText(raw.activityPhase) } : {}),
3611
+ ...(raw.commandText != null ? { commandText: cleanText(raw.commandText) } : {}),
3493
3612
  // Moltbook-specific fields — preserved for detail view fallback after
3494
3613
  // the in-memory draft/item expires.
3495
3614
  ...(raw.draftText != null ? { draftText: cleanText(raw.draftText) } : {}),
@@ -3583,6 +3702,229 @@ function publishTimelineUpdate({ config, runtime, entry, source = "unknown" }) {
3583
3702
  ensureTimelineBus(runtime).emit("timeline:update", payload);
3584
3703
  }
3585
3704
 
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
+
3586
3928
  function recordTimelineEntry({ config, runtime, state, entry, source = "unknown" }) {
3587
3929
  const normalized = normalizeTimelineEntry(entry);
3588
3930
  if (!normalized) {
@@ -4599,6 +4941,17 @@ async function processRolloutFile({ filePath, config, runtime, state, now }) {
4599
4941
  entry: timelineEntry,
4600
4942
  source: "codex-rollout",
4601
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
+ });
4602
4955
  }
4603
4956
 
4604
4957
  const fileTimelineEntries = await buildRolloutFileTimelineEntries({
@@ -5032,6 +5385,23 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
5032
5385
  for (const toolEntry of toolEntries) {
5033
5386
  dirty = recordTimelineEntry({ config, runtime, state, entry: toolEntry, source: "claude-tool" }) || dirty;
5034
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
+ }
5035
5405
  }
5036
5406
 
5037
5407
  let text = "";
@@ -5071,6 +5441,28 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
5071
5441
  : stopReason === "end_turn"
5072
5442
  ? "assistant_final"
5073
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
+ }
5074
5466
  // Use kind-independent stableId so re-scans with corrected classification
5075
5467
  // replace old entries rather than creating duplicates.
5076
5468
  const stableId = `claude_msg:${threadId}:${uuid}`;
@@ -6367,6 +6759,20 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
6367
6759
  return [];
6368
6760
  }
6369
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
+ });
6370
6776
  return [
6371
6777
  buildToolTimelineEntry({
6372
6778
  provider: "codex",
@@ -6384,11 +6790,34 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
6384
6790
 
6385
6791
  if (payloadType === "custom_tool_call") {
6386
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
+ });
6387
6805
  return [];
6388
6806
  }
6389
6807
 
6390
6808
  if (payloadType === "function_call_output") {
6391
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
+ });
6392
6821
  return [];
6393
6822
  }
6394
6823
  const commandText = extractCommandLineFromFunctionOutput(payload.output ?? "");
@@ -6396,6 +6825,17 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
6396
6825
  return [];
6397
6826
  }
6398
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
+ });
6399
6839
  return [
6400
6840
  buildToolTimelineEntry({
6401
6841
  provider: "codex",
@@ -6554,6 +6994,18 @@ async function buildRolloutFileTimelineEntries({ config, record, fileState, runt
6554
6994
  fileState.applyPatchInputsByCallId.delete(callId);
6555
6995
  }
6556
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
+
6557
7009
  return entries.filter(Boolean);
6558
7010
  }
6559
7011
 
@@ -6610,8 +7062,24 @@ async function processScannedEvent({ config, runtime, state, event }) {
6610
7062
  let dirty = false;
6611
7063
 
6612
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
+ });
6613
7073
  attachCompletionDetails({ config, runtime, event });
6614
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
+ });
6615
7083
  attachPlanDetails({ config, runtime, event });
6616
7084
  }
6617
7085
 
@@ -9163,6 +9631,15 @@ function isStartTurnAckTimeout(errorValue) {
9163
9631
  message === "turn/start-timeout";
9164
9632
  }
9165
9633
 
9634
+ function isIpcNoClientFoundError(errorValue) {
9635
+ const message = normalizeIpcErrorMessage(errorValue).toLowerCase();
9636
+ return message === "no-client-found" ||
9637
+ message === "client-not-found" ||
9638
+ message === "target-client-not-found" ||
9639
+ message.includes("no client found") ||
9640
+ message.includes("client not found");
9641
+ }
9642
+
9166
9643
  function buildDefaultCollaborationMode(threadState) {
9167
9644
  // Fallback turns must leave Plan mode unless the caller explicitly opts in.
9168
9645
  return buildRequestedCollaborationMode(threadState, "default");
@@ -9281,14 +9758,36 @@ class NativeIpcClient {
9281
9758
  );
9282
9759
  }
9283
9760
 
9284
- sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null, options = {}) {
9285
- return this.sendRequest(method, params, {
9286
- targetClientId:
9287
- ownerClientId ??
9288
- this.runtime.threadOwnerClientIds.get(conversationId) ??
9289
- null,
9290
- ...options,
9291
- });
9761
+ async sendThreadFollowerRequest(method, params, conversationId, ownerClientId = null, options = {}) {
9762
+ const targetClientId =
9763
+ ownerClientId ??
9764
+ this.runtime.threadOwnerClientIds.get(conversationId) ??
9765
+ null;
9766
+
9767
+ try {
9768
+ return await this.sendRequest(method, params, {
9769
+ targetClientId,
9770
+ ...options,
9771
+ });
9772
+ } catch (error) {
9773
+ if (!targetClientId || !isIpcNoClientFoundError(error)) {
9774
+ throw error;
9775
+ }
9776
+
9777
+ const mappedOwner = this.runtime.threadOwnerClientIds.get(conversationId) ?? null;
9778
+ if (mappedOwner === targetClientId) {
9779
+ this.runtime.threadOwnerClientIds.delete(conversationId);
9780
+ }
9781
+ console.warn(
9782
+ `[ipc] stale owner client for thread=${cleanText(conversationId || "") || "unknown"} ` +
9783
+ `target=${cleanText(targetClientId || "") || "unknown"} method=${cleanText(method || "") || "unknown"}; retrying without target`
9784
+ );
9785
+
9786
+ return this.sendRequest(method, params, {
9787
+ ...options,
9788
+ targetClientId: null,
9789
+ });
9790
+ }
9292
9791
  }
9293
9792
 
9294
9793
  sendRequest(method, params, options = {}) {
@@ -10170,9 +10669,10 @@ function formatNativeApprovalMessage(kind, params, locale = config?.defaultLocal
10170
10669
  }
10171
10670
 
10172
10671
  function formatCommandApprovalMessage(params, locale = config?.defaultLocale || DEFAULT_LOCALE) {
10672
+ const safeParams = isPlainObject(params) ? params : {};
10173
10673
  const parts = [];
10174
- const reason = truncate(cleanText(params.reason ?? params.justification ?? ""), 220);
10175
- const command = truncate(cleanText(params.command ?? params.cmd ?? ""), 1200);
10674
+ const reason = truncate(cleanText(safeParams.reason ?? safeParams.justification ?? ""), 220);
10675
+ const command = truncate(cleanText(safeParams.command ?? safeParams.cmd ?? ""), 1200);
10176
10676
  if (reason) {
10177
10677
  parts.push(reason);
10178
10678
  } else {
@@ -10185,21 +10685,41 @@ function formatCommandApprovalMessage(params, locale = config?.defaultLocale ||
10185
10685
  }
10186
10686
 
10187
10687
  function formatFileApprovalMessage(params, locale = config?.defaultLocale || DEFAULT_LOCALE) {
10688
+ const safeParams = isPlainObject(params) ? params : {};
10188
10689
  const parts = [];
10189
- const reason = truncate(cleanText(params.reason ?? ""), 220);
10690
+ const reason = truncate(cleanText(safeParams.reason ?? ""), 220);
10190
10691
  if (reason) {
10191
10692
  parts.push(reason);
10192
10693
  } else {
10193
10694
  parts.push(t(locale, "server.message.fileApprovalNeeded"));
10194
10695
  }
10195
- if (params.grantRoot) {
10196
- parts.push(t(locale, "server.message.pathPrefix", { path: compactPath(params.grantRoot) }));
10197
- } else if (params.cwd) {
10198
- parts.push(t(locale, "server.message.pathPrefix", { path: compactPath(params.cwd) }));
10696
+ if (safeParams.grantRoot) {
10697
+ parts.push(t(locale, "server.message.pathPrefix", { path: compactPath(safeParams.grantRoot) }));
10698
+ } else if (safeParams.cwd) {
10699
+ parts.push(t(locale, "server.message.pathPrefix", { path: compactPath(safeParams.cwd) }));
10199
10700
  }
10200
10701
  return truncate(parts.join("\n"), 1024);
10201
10702
  }
10202
10703
 
10704
+ function claudeApprovalRawParams(body, kind) {
10705
+ const toolInput = isPlainObject(body?.toolInput) ? cloneJson(body.toolInput) : {};
10706
+ const rawParams = {
10707
+ ...toolInput,
10708
+ ...(body?.cwd ? { cwd: String(body.cwd) } : {}),
10709
+ };
10710
+ const messageText = cleanText(body?.messageText || "");
10711
+ if (messageText && !cleanText(rawParams.reason ?? rawParams.justification ?? "")) {
10712
+ rawParams.reason = messageText;
10713
+ }
10714
+ if (kind === "command" && !cleanText(rawParams.command ?? rawParams.cmd ?? "")) {
10715
+ const commandText = cleanText(toolInput.command ?? toolInput.cmd ?? "");
10716
+ if (commandText) {
10717
+ rawParams.command = commandText;
10718
+ }
10719
+ }
10720
+ return rawParams;
10721
+ }
10722
+
10203
10723
  function extractApprovalFileRefs(params) {
10204
10724
  if (!isPlainObject(params)) {
10205
10725
  return [];
@@ -12621,6 +13141,26 @@ function buildTimelineThreads(entries, config) {
12621
13141
  .slice(0, config.maxTimelineThreads);
12622
13142
  }
12623
13143
 
13144
+ function buildActiveTimelineActivityEntries(runtime, locale) {
13145
+ if (!(runtime?.activeTimelineActivitiesByKey instanceof Map)) {
13146
+ return [];
13147
+ }
13148
+ const now = Date.now();
13149
+ const entries = [];
13150
+ for (const [key, activity] of [...runtime.activeTimelineActivitiesByKey.entries()]) {
13151
+ const updatedAtMs = Number(activity?.updatedAtMs) || 0;
13152
+ if (!updatedAtMs || now - updatedAtMs > TIMELINE_ACTIVITY_TTL_MS) {
13153
+ runtime.activeTimelineActivitiesByKey.delete(key);
13154
+ continue;
13155
+ }
13156
+ const entry = buildTimelineActivityEntry(activity, locale);
13157
+ if (entry) {
13158
+ entries.push(entry);
13159
+ }
13160
+ }
13161
+ return entries;
13162
+ }
13163
+
12624
13164
  function buildTimelineResponse(runtime, state, config, locale) {
12625
13165
  const messageEntries = normalizeTimelineEntries(
12626
13166
  state.recentTimelineEntries ?? runtime.recentTimelineEntries,
@@ -12628,7 +13168,11 @@ function buildTimelineResponse(runtime, state, config, locale) {
12628
13168
  );
12629
13169
  runtime.recentTimelineEntries = messageEntries;
12630
13170
  const entries = normalizeTimelineEntries(
12631
- [...messageEntries, ...buildOperationalTimelineEntries(runtime, state, config, locale)],
13171
+ [
13172
+ ...buildActiveTimelineActivityEntries(runtime, locale),
13173
+ ...messageEntries,
13174
+ ...buildOperationalTimelineEntries(runtime, state, config, locale),
13175
+ ],
12632
13176
  config.maxTimelineEntries
12633
13177
  ).map((entry) => ({
12634
13178
  kind: entry.kind,
@@ -12646,6 +13190,9 @@ function buildTimelineResponse(runtime, state, config, locale) {
12646
13190
  outcome: entry.outcome || "",
12647
13191
  createdAtMs: entry.createdAtMs,
12648
13192
  provider: normalizeProvider(entry.provider),
13193
+ ...(entry.activityPhase != null ? { activityPhase: entry.activityPhase } : {}),
13194
+ ...(entry.commandText != null ? { commandText: entry.commandText } : {}),
13195
+ ...timelineAutoPilotProjection(entry),
12649
13196
  ...(entry.draftType != null ? { draftType: entry.draftType } : {}),
12650
13197
  }));
12651
13198
 
@@ -12655,6 +13202,24 @@ function buildTimelineResponse(runtime, state, config, locale) {
12655
13202
  };
12656
13203
  }
12657
13204
 
13205
+ function timelineAutoPilotProjection(entry) {
13206
+ if (cleanText(entry?.kind || "") !== "approval" || cleanText(entry?.outcome || "") !== "approved") {
13207
+ return {};
13208
+ }
13209
+ const stableId = cleanText(entry?.stableId || "");
13210
+ if (stableId.includes(":autopilot-write")) {
13211
+ const lane = cleanText(stableId.match(/:autopilot-write:([a-z_-]+)$/u)?.[1] || "");
13212
+ return {
13213
+ autoPilotMode: "write",
13214
+ ...(lane ? { autoPilotWriteLane: lane } : {}),
13215
+ };
13216
+ }
13217
+ if (stableId.endsWith(":autopilot")) {
13218
+ return { autoPilotMode: "read" };
13219
+ }
13220
+ return {};
13221
+ }
13222
+
12658
13223
  function buildPendingApprovalDetail(runtime, state, approval, locale) {
12659
13224
  const previousContext = buildPreviousApprovalContext(runtime, approval);
12660
13225
  const approvalKind = cleanText(approval.kind || "");
@@ -13272,7 +13837,7 @@ function buildAmbientSuggestionsDetail(entry, locale) {
13272
13837
 
13273
13838
  function buildTimelineFileEventDetail(entry, locale) {
13274
13839
  const fileEventType = normalizeTimelineFileEventType(entry?.fileEventType ?? "");
13275
- const commandText = ["read", "search"].includes(fileEventType)
13840
+ const commandText = ["read", "search", "git"].includes(fileEventType)
13276
13841
  ? firstMarkdownCodeFenceText(entry?.messageText ?? "")
13277
13842
  : "";
13278
13843
  const detailText = [
@@ -14525,9 +15090,26 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
14525
15090
  });
14526
15091
  } else if (approval.resolveClaudeWaiter) {
14527
15092
  if (approval.provider === "claude" && approval.kind === "plan") {
14528
- // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
14529
- // (Claude still shows the native PC plan dialog). Instead, deny the tool
14530
- // call with a reason that tells Claude the user already decided on mobile.
15093
+ // ExitPlanMode CANNOT actually be auto-bypassed via the PreToolUse
15094
+ // hook. We tested both "allow" and "deny" verdicts on Claude Desktop:
15095
+ //
15096
+ // - permissionDecision: "allow"
15097
+ // Claude Desktop ignores the verdict and re-shows its native
15098
+ // plan dialog on the Mac, asking for approval a second time
15099
+ // even though the user already tapped on mobile.
15100
+ // - permissionDecision: "deny"
15101
+ // The native dialog is suppressed, but Claude Desktop also
15102
+ // does not exit plan mode internally, so Edit/Write stay
15103
+ // blocked — the session cannot make progress.
15104
+ //
15105
+ // Neither verdict gives us "phone tap → plan mode exits → editing
15106
+ // resumes" by itself. Until we can drive the Mac dialog through an
15107
+ // external mechanism (AppleScript / Accessibility API), the chosen
15108
+ // workaround is "deny + reason": Claude reads the reason as
15109
+ // instructions ("user already approved on mobile, proceed without
15110
+ // calling ExitPlanMode again"). That at least lets the desktop user
15111
+ // finish the flow with one extra Mac click on the native dialog
15112
+ // instead of getting a duplicate dialog from a stale "allow".
14531
15113
  approval.resolveClaudeWaiter({
14532
15114
  permissionDecision: "deny",
14533
15115
  permissionDecisionReason:
@@ -17592,6 +18174,13 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17592
18174
  const threadId = String(body.threadId || body.sessionId || "");
17593
18175
  console.log(`[claude-stop] threadId=${threadId} pendingTotal=${runtime.nativeApprovalsByToken.size}`);
17594
18176
  if (threadId) {
18177
+ clearTimelineActivity({
18178
+ config,
18179
+ runtime,
18180
+ provider: "claude",
18181
+ threadId,
18182
+ source: "claude-stop",
18183
+ });
17595
18184
  const pending = [];
17596
18185
  for (const approval of runtime.nativeApprovalsByToken.values()) {
17597
18186
  if (!approval.resolved && approval.conversationId === threadId) {
@@ -17662,6 +18251,21 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17662
18251
  // for this session/tool as accepted (so iPhone moves it to completed).
17663
18252
  const threadId = String(body.threadId || body.sessionId || "");
17664
18253
  const toolName = String(body.toolName || "");
18254
+ const eventCwd = String(body.cwd || "");
18255
+ const threadLabel =
18256
+ runtime.claudeSessionTitles.get(threadId) ||
18257
+ (eventCwd ? path.basename(eventCwd) : "") ||
18258
+ threadId.slice(0, 40);
18259
+ upsertTimelineActivity({
18260
+ config,
18261
+ runtime,
18262
+ provider: "claude",
18263
+ threadId,
18264
+ threadLabel,
18265
+ phase: "thinking",
18266
+ cwd: eventCwd,
18267
+ source: "claude-post-tool",
18268
+ });
17665
18269
  const fingerprint = claudeToolFingerprint(toolName, body.toolInput);
17666
18270
  console.log(`[claude-post-tool] threadId=${threadId} tool=${toolName} fp=${fingerprint.slice(0, 80)} pendingTotal=${runtime.nativeApprovalsByToken.size}`);
17667
18271
  const match = findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint);
@@ -17697,6 +18301,31 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17697
18301
  }
17698
18302
  }
17699
18303
 
18304
+ if (eventType === "PreToolUse") {
18305
+ const threadId = String(body.threadId || body.sessionId || "");
18306
+ const eventCwd = String(body.cwd || "");
18307
+ const toolName = String(body.toolName || "");
18308
+ const toolActivity = timelineActivityFromClaudeTool(toolName, isPlainObject(body.toolInput) ? body.toolInput : {});
18309
+ const threadLabel =
18310
+ runtime.claudeSessionTitles.get(threadId) ||
18311
+ (eventCwd ? path.basename(eventCwd) : "") ||
18312
+ threadId.slice(0, 40);
18313
+ upsertTimelineActivity({
18314
+ config,
18315
+ runtime,
18316
+ provider: "claude",
18317
+ threadId,
18318
+ threadLabel,
18319
+ phase: toolActivity.phase,
18320
+ summary: toolActivity.summary,
18321
+ commandText: toolActivity.commandText,
18322
+ fileRefs: toolActivity.fileRefs,
18323
+ cwd: eventCwd,
18324
+ source: "claude-pre-tool",
18325
+ });
18326
+ return writeJson(res, 200, {});
18327
+ }
18328
+
17700
18329
  if (eventType !== "approval_request") {
17701
18330
  // Non-approval events (Notification, Stop, etc.) — acknowledge immediately
17702
18331
  return writeJson(res, 200, {});
@@ -17708,6 +18337,8 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17708
18337
  const threadId = String(body.threadId || "");
17709
18338
  const requestId = String(body.requestId || crypto.randomUUID());
17710
18339
  const requestKey = `${threadId}:${requestId}`;
18340
+ const approvalKind = String(body.approvalKind || "command");
18341
+ const rawParams = claudeApprovalRawParams(body, approvalKind);
17711
18342
 
17712
18343
  const approval = {
17713
18344
  token,
@@ -17715,13 +18346,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17715
18346
  conversationId: threadId,
17716
18347
  requestId,
17717
18348
  ownerClientId: null,
17718
- kind: String(body.approvalKind || "command"),
18349
+ kind: approvalKind,
17719
18350
  threadLabel:
17720
18351
  runtime.claudeSessionTitles.get(threadId) ||
17721
18352
  (body.cwd ? path.basename(String(body.cwd)) : "") ||
17722
18353
  String(body.threadId || "").slice(0, 40),
17723
18354
  title: config.approvalTitle,
17724
18355
  messageText: String(body.messageText || ""),
18356
+ rawParams,
17725
18357
  fileRefs: Array.isArray(body.fileRefs) ? body.fileRefs : [],
17726
18358
  previousFileRefs: [],
17727
18359
  diffText: String(body.diffText || ""),
@@ -17744,6 +18376,20 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17744
18376
  readOnly: body.notifyOnly === true,
17745
18377
  };
17746
18378
 
18379
+ upsertTimelineActivity({
18380
+ config,
18381
+ runtime,
18382
+ provider: "claude",
18383
+ threadId,
18384
+ threadLabel: approval.threadLabel,
18385
+ phase: "awaiting_approval",
18386
+ summary: approval.messageText,
18387
+ fileRefs: approval.fileRefs,
18388
+ cwd: approval.cwd,
18389
+ source: "claude-approval-request",
18390
+ atMs: approval.createdAtMs,
18391
+ });
18392
+
17747
18393
  const claudeAutoPilotMatch =
17748
18394
  approval.kind === "command"
17749
18395
  ? maybeAutoApproveTrustedRead({
@@ -17800,21 +18446,28 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
17800
18446
  runtime.nativeApprovalsByToken.set(token, approval);
17801
18447
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
17802
18448
 
17803
- deliverWebPushItem({
17804
- config,
17805
- state,
17806
- kind: "approval",
17807
- tab: "inbox",
17808
- subtab: "pending",
17809
- token: approval.token,
17810
- stableId: pendingApprovalStableId(approval),
17811
- title: approval.title,
17812
- body: approval.messageText,
17813
- buildLocalizedContent: ({ locale }) => ({
17814
- title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
17815
- body: formatNativeApprovalMessage(approval.kind, approval.rawParams, locale),
17816
- }),
17817
- }).catch(() => {});
18449
+ try {
18450
+ const pushChanged = await deliverWebPushItem({
18451
+ config,
18452
+ state,
18453
+ kind: "approval",
18454
+ tab: "inbox",
18455
+ subtab: "pending",
18456
+ token: approval.token,
18457
+ stableId: pendingApprovalStableId(approval),
18458
+ title: approval.title,
18459
+ body: approval.messageText,
18460
+ buildLocalizedContent: ({ locale }) => ({
18461
+ title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
18462
+ body: formatNativeApprovalMessage(approval.kind, approval.rawParams, locale),
18463
+ }),
18464
+ });
18465
+ if (pushChanged) {
18466
+ await saveState(config.stateFile, state);
18467
+ }
18468
+ } catch (error) {
18469
+ console.error(`[claude-approval-push] ${approval.requestKey} | ${error.message}`);
18470
+ }
17818
18471
 
17819
18472
  // Notify-only path: phone gets a read-only entry + push, but the
17820
18473
  // hook does not block on a decision. The PostToolUse handler will
@@ -18037,6 +18690,22 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18037
18690
  if (draftType === "reply" && !postId) {
18038
18691
  return writeJson(res, 400, { error: "missing-postId-for-reply" });
18039
18692
  }
18693
+ if (draftType !== "original_post") {
18694
+ const scoutState = rollScoutDayIfNeeded(await readScoutState());
18695
+ const quota = await getMoltbookReplyQuotaState({ state: scoutState, maxDaily: 5 });
18696
+ if (quota.quotaReached) {
18697
+ await writeScoutState(scoutState);
18698
+ return writeJson(res, 409, {
18699
+ error: "moltbook-reply-quota-reached",
18700
+ sentToday: quota.sentToday,
18701
+ pendingToday: quota.pendingToday,
18702
+ usedToday: quota.usedToday,
18703
+ maxDaily: quota.maxDaily,
18704
+ day: quota.day,
18705
+ });
18706
+ }
18707
+ await writeScoutState(scoutState);
18708
+ }
18040
18709
  const token = historyToken(`moltbook_draft:${sourceId}`);
18041
18710
  const postTitle = cleanText(body.postTitle || "");
18042
18711
  const postUrl = cleanText(body.postUrl || `https://www.moltbook.com/post/${postId}`);
@@ -18172,6 +18841,17 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18172
18841
  const action = String(body.action || "") === "approve" ? "approve" : "deny";
18173
18842
  const editedText = cleanText(body.editedText || "");
18174
18843
  const editedTitle = cleanText(body.editedTitle || "");
18844
+ if (action === "approve" && draft.draftType !== "original_post") {
18845
+ const quota = await reserveMoltbookReplyQuotaSlot(draft, 5);
18846
+ if (quota.quotaReached) {
18847
+ return writeJson(res, 409, {
18848
+ error: "moltbook-reply-quota-reached",
18849
+ sentToday: quota.sentToday,
18850
+ maxDaily: quota.maxDaily,
18851
+ day: quota.day,
18852
+ });
18853
+ }
18854
+ }
18175
18855
  const decision = {
18176
18856
  action,
18177
18857
  text: action === "approve" ? editedText || draft.draftText : "",
@@ -18205,6 +18885,15 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18205
18885
  await executeMoltbookDraftPost(draft, config, runtime, state);
18206
18886
  } catch (postError) {
18207
18887
  console.error(`[moltbook-draft-post-error] ${postError.message}`);
18888
+ await releaseMoltbookReplyQuotaSlot(draft).catch((error) => console.error(`[moltbook-quota-release] ${error.message}`));
18889
+ if (draft.parentCommentId) {
18890
+ await updateInboxStatus(draft.parentCommentId, "pending", {
18891
+ source: "mobile-draft-approve",
18892
+ draftStatus: "failed",
18893
+ draftToken: draft.token,
18894
+ draftError: String(postError.message || "").slice(0, 500),
18895
+ }).catch((error) => console.error(`[moltbook-inbox-post-failed] ${error.message}`));
18896
+ }
18208
18897
  try {
18209
18898
  await deliverWebPushItem({
18210
18899
  config, state, kind: "moltbook_draft", token: draft.token,
@@ -18224,6 +18913,14 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18224
18913
  setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120_000).unref?.();
18225
18914
  })();
18226
18915
  } else {
18916
+ if (draft.parentCommentId) {
18917
+ await updateInboxStatus(draft.parentCommentId, "skipped", {
18918
+ source: "mobile-draft-deny",
18919
+ draftStatus: "denied",
18920
+ draftToken: draft.token,
18921
+ skippedAt: new Date().toISOString(),
18922
+ }).catch((error) => console.error(`[moltbook-inbox-denied] ${error.message}`));
18923
+ }
18227
18924
  // Deny — clean up.
18228
18925
  await deleteDraft(token).catch(() => {});
18229
18926
  setTimeout(() => runtime.moltbookDraftsByToken.delete(token), 120_000).unref?.();
@@ -18375,6 +19072,9 @@ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
18375
19072
  if (error.message === "codex-ipc-not-connected") {
18376
19073
  return writeJson(res, 503, { error: error.message });
18377
19074
  }
19075
+ if (isIpcNoClientFoundError(error)) {
19076
+ return writeJson(res, 503, { error: "codex-client-not-found" });
19077
+ }
18378
19078
  return writeJson(res, 500, { error: error.message });
18379
19079
  }
18380
19080
  }
@@ -20306,9 +21006,21 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
20306
21006
  createdPostId = draft.postId || null;
20307
21007
  createdCommentId = comment?.id || null;
20308
21008
  console.log(`[moltbook-draft-post] Posted reply (commentId=${comment?.id}) to post ${draft.postId}`);
21009
+ if (draft.parentCommentId) {
21010
+ await updateInboxStatus(draft.parentCommentId, "replied", {
21011
+ source: "mobile-draft-approve",
21012
+ replyText: finalText,
21013
+ replyCommentId: createdCommentId || "",
21014
+ replyVerification: verification || null,
21015
+ draftStatus: "posted",
21016
+ draftToken: draft.token,
21017
+ }).catch((error) => console.error(`[moltbook-inbox-replied] ${error.message}`));
21018
+ }
20309
21019
 
20310
21020
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
20311
- scoutState.sentToday += 1;
21021
+ if (!draft.replyQuotaReserved) {
21022
+ scoutState.sentToday += 1;
21023
+ }
20312
21024
  markPostSeen(scoutState, draft.postId, "published");
20313
21025
  // Append the reply to `recentComposeTitles` so it shows up in the
20314
21026
  // "最近の投稿" list with its reply badge. `recordComposeAttempt` knows