viveworker 0.1.10 → 0.2.0

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.
@@ -64,6 +64,12 @@ const runtime = {
64
64
  threadOwnerClientIds: new Map(),
65
65
  nativeApprovalsByToken: new Map(),
66
66
  nativeApprovalsByRequestKey: new Map(),
67
+ claudeApprovalWaiters: new Map(),
68
+ claudeKnownFiles: [],
69
+ lastClaudeScanAt: 0,
70
+ claudeFileStates: new Map(),
71
+ claudeSessionTitles: new Map(),
72
+ lastClaudeSessionTitleScanAt: 0,
67
73
  fileApprovalDeltasById: new Map(),
68
74
  planRequestsByToken: new Map(),
69
75
  planRequestsByRequestKey: new Map(),
@@ -82,6 +88,7 @@ const runtime = {
82
88
  stopping: false,
83
89
  };
84
90
  const state = await loadState(config.stateFile);
91
+ await syncClaudeAwayModeSentinel(config, state.claudeAwayMode === true);
85
92
  const migratedPairedDevicesStateChanged = migratePairedDevicesState({ config, state });
86
93
  const restoredPendingPlanStateChanged = restorePendingPlanRequests({ config, runtime, state });
87
94
  const restoredPendingUserInputStateChanged = restorePendingUserInputRequests({ config, runtime, state });
@@ -204,6 +211,7 @@ function buildSessionLocalePayload(config, state, deviceId) {
204
211
  defaultLocale: defaultLocale(config),
205
212
  deviceDetectedLocale: resolved.detectedLocale || null,
206
213
  deviceOverrideLocale: resolved.overrideLocale || null,
214
+ claudeAwayMode: state?.claudeAwayMode === true,
207
215
  };
208
216
  }
209
217
 
@@ -311,23 +319,35 @@ function fileEventTitle(locale, fileEventType) {
311
319
  }
312
320
  }
313
321
 
314
- function fileEventDetailCopy(locale, fileEventType) {
322
+ function fileEventDetailCopy(locale, fileEventType, provider) {
323
+ const vars = { provider: providerDisplayName(locale, provider) };
315
324
  switch (normalizeTimelineFileEventType(fileEventType)) {
316
325
  case "read":
317
- return t(locale, "detail.fileEvent.read");
326
+ return t(locale, "detail.fileEvent.read", vars);
318
327
  case "write":
319
- return t(locale, "detail.fileEvent.write");
328
+ return t(locale, "detail.fileEvent.write", vars);
320
329
  case "create":
321
- return t(locale, "detail.fileEvent.create");
330
+ return t(locale, "detail.fileEvent.create", vars);
322
331
  case "delete":
323
- return t(locale, "detail.fileEvent.delete");
332
+ return t(locale, "detail.fileEvent.delete", vars);
324
333
  case "rename":
325
- return t(locale, "detail.fileEvent.rename");
334
+ return t(locale, "detail.fileEvent.rename", vars);
326
335
  default:
327
336
  return t(locale, "detail.detailUnavailable");
328
337
  }
329
338
  }
330
339
 
340
+ function diffThreadDetailCopy(locale, sourceMode, count) {
341
+ switch (cleanText(sourceMode || "")) {
342
+ case "git_current":
343
+ return t(locale, "detail.diffThread.copy.current", { count });
344
+ case "non_git_latest":
345
+ return t(locale, "detail.diffThread.copy.latest", { count });
346
+ default:
347
+ return t(locale, "detail.diffThread.copy", { count });
348
+ }
349
+ }
350
+
331
351
  function inferTimelineOutcome(kind, summary = "", messageText = "") {
332
352
  const normalizedKind = cleanText(kind || "");
333
353
  if (!normalizedKind) {
@@ -1764,10 +1784,218 @@ function resolveCurrentChangeOwner(change, candidates) {
1764
1784
  return null;
1765
1785
  }
1766
1786
 
1787
+ function normalizeCodeFallbackFileRef({ fileRef, cwd = "" }) {
1788
+ const normalizedFileRef = cleanTimelineFileRef(fileRef);
1789
+ if (!normalizedFileRef) {
1790
+ return "";
1791
+ }
1792
+ if (path.isAbsolute(normalizedFileRef)) {
1793
+ return resolvePath(normalizedFileRef);
1794
+ }
1795
+ const normalizedCwd = resolvePath(cleanText(cwd || ""));
1796
+ if (!normalizedCwd) {
1797
+ return normalizedFileRef;
1798
+ }
1799
+ return resolvePath(path.resolve(normalizedCwd, normalizedFileRef));
1800
+ }
1801
+
1802
+ function buildNonGitCodeChangeEntries(item) {
1803
+ const changeType = normalizeTimelineFileEventType(item?.fileEventType || "");
1804
+ if (!["write", "create", "delete", "rename"].includes(changeType)) {
1805
+ return [];
1806
+ }
1807
+
1808
+ const normalizedCwd = cleanText(item?.cwd || "");
1809
+ const normalizedFileRefs = normalizeTimelineFileRefs(item?.fileRefs ?? []).map((fileRef) =>
1810
+ normalizeCodeFallbackFileRef({ fileRef, cwd: normalizedCwd })
1811
+ );
1812
+ const normalizedPreviousFileRefs = normalizeTimelineFileRefs(item?.previousFileRefs ?? []).map((fileRef) =>
1813
+ normalizeCodeFallbackFileRef({ fileRef, cwd: normalizedCwd })
1814
+ );
1815
+ const sections = splitUnifiedDiffTextByFile(item?.diffText ?? "");
1816
+ const rawChanges = [];
1817
+
1818
+ if (changeType === "rename") {
1819
+ const pairCount = Math.max(normalizedFileRefs.length, normalizedPreviousFileRefs.length);
1820
+ for (let index = 0; index < pairCount; index += 1) {
1821
+ const newFileRef = cleanTimelineFileRef(normalizedFileRefs[index] || "");
1822
+ const oldFileRef = cleanTimelineFileRef(normalizedPreviousFileRefs[index] || "");
1823
+ if (!newFileRef && !oldFileRef) {
1824
+ continue;
1825
+ }
1826
+ rawChanges.push({
1827
+ changeType,
1828
+ fileRef: newFileRef || oldFileRef,
1829
+ oldFileRef,
1830
+ newFileRef: newFileRef || oldFileRef,
1831
+ });
1832
+ }
1833
+ } else {
1834
+ const targetRefs =
1835
+ normalizedFileRefs.length > 0
1836
+ ? normalizedFileRefs
1837
+ : changeType === "delete"
1838
+ ? normalizedPreviousFileRefs
1839
+ : [];
1840
+ for (const fileRef of targetRefs) {
1841
+ const normalizedFileRef = cleanTimelineFileRef(fileRef || "");
1842
+ if (!normalizedFileRef) {
1843
+ continue;
1844
+ }
1845
+ rawChanges.push({
1846
+ changeType,
1847
+ fileRef: normalizedFileRef,
1848
+ oldFileRef: changeType === "delete" ? normalizedFileRef : "",
1849
+ newFileRef: changeType === "delete" ? "" : normalizedFileRef,
1850
+ });
1851
+ }
1852
+ }
1853
+
1854
+ const allowWholeItemDiff = sections.length === 0 && rawChanges.length <= 1;
1855
+ const baseDiffSource = normalizeTimelineDiffSource(item?.diffSource ?? "");
1856
+ const baseAddedLines = Math.max(0, Number(item?.diffAddedLines) || 0);
1857
+ const baseRemovedLines = Math.max(0, Number(item?.diffRemovedLines) || 0);
1858
+ const threadId = cleanText(item?.threadId || "");
1859
+ const threadLabel = cleanText(item?.threadLabel || "");
1860
+ const createdAtMs = Number(item?.createdAtMs) || 0;
1861
+
1862
+ return rawChanges.map((change) => {
1863
+ const section = findMatchingCurrentDiffSection(sections, change);
1864
+ const diffText = normalizeTimelineDiffText(section?.diffText || (allowWholeItemDiff ? item?.diffText ?? "" : ""));
1865
+ const counts = diffText
1866
+ ? diffLineCounts(diffText)
1867
+ : {
1868
+ addedLines: allowWholeItemDiff ? baseAddedLines : 0,
1869
+ removedLines: allowWholeItemDiff ? baseRemovedLines : 0,
1870
+ };
1871
+ return {
1872
+ threadId,
1873
+ threadLabel,
1874
+ createdAtMs,
1875
+ changeType,
1876
+ fileRef: cleanTimelineFileRef(change.fileRef || change.newFileRef || change.oldFileRef || ""),
1877
+ oldFileRef: cleanTimelineFileRef(change.oldFileRef || ""),
1878
+ newFileRef: cleanTimelineFileRef(change.newFileRef || change.fileRef || ""),
1879
+ diffText,
1880
+ diffAvailable: item?.diffAvailable === true || Boolean(diffText),
1881
+ diffSource: baseDiffSource,
1882
+ diffAddedLines: counts.addedLines,
1883
+ diffRemovedLines: counts.removedLines,
1884
+ };
1885
+ });
1886
+ }
1887
+
1888
+ function nonGitCodeChangeIdentity(change) {
1889
+ const anchorFileRef = cleanTimelineFileRef(change?.newFileRef || change?.fileRef || change?.oldFileRef || "");
1890
+ return anchorFileRef ? `file:${anchorFileRef}` : "";
1891
+ }
1892
+
1893
+ function mergeDiffThreadSourceMode(currentMode, nextMode) {
1894
+ const normalizedCurrentMode = cleanText(currentMode || "");
1895
+ const normalizedNextMode = cleanText(nextMode || "");
1896
+ if (!normalizedCurrentMode) {
1897
+ return normalizedNextMode;
1898
+ }
1899
+ if (!normalizedNextMode || normalizedCurrentMode === normalizedNextMode) {
1900
+ return normalizedCurrentMode;
1901
+ }
1902
+ return "mixed";
1903
+ }
1904
+
1905
+ function ensureDiffThreadGroup(groupsByThread, { threadId = "", threadLabel = "", fallbackKey = "", sourceMode = "" }) {
1906
+ const normalizedThreadId = cleanText(threadId || "");
1907
+ const normalizedThreadLabel = cleanText(threadLabel || "");
1908
+ const normalizedFallbackKey = cleanText(fallbackKey || "");
1909
+ const threadKey = normalizedThreadId || `unknown:${normalizedThreadLabel || normalizedFallbackKey || "thread"}`;
1910
+ let threadGroup = groupsByThread.get(threadKey);
1911
+ if (!threadGroup) {
1912
+ threadGroup = {
1913
+ kind: "diff_thread",
1914
+ token: diffThreadToken(normalizedThreadId, normalizedThreadLabel),
1915
+ threadId: normalizedThreadId,
1916
+ threadLabel: normalizedThreadLabel,
1917
+ sourceMode: cleanText(sourceMode || ""),
1918
+ filesByRef: new Map(),
1919
+ };
1920
+ groupsByThread.set(threadKey, threadGroup);
1921
+ return threadGroup;
1922
+ }
1923
+
1924
+ if (!threadGroup.threadLabel && normalizedThreadLabel) {
1925
+ threadGroup.threadLabel = normalizedThreadLabel;
1926
+ }
1927
+ threadGroup.sourceMode = mergeDiffThreadSourceMode(threadGroup.sourceMode, sourceMode);
1928
+ return threadGroup;
1929
+ }
1930
+
1931
+ function addChangeToDiffThreadGroup({ groupsByThread, threadId, threadLabel, fallbackKey = "", sourceMode = "", change, createdAtMs }) {
1932
+ const normalizedChangeType = normalizeTimelineFileEventType(change?.changeType || "");
1933
+ const normalizedFileRef = cleanTimelineFileRef(change?.fileRef || change?.newFileRef || change?.oldFileRef || "");
1934
+ const normalizedOldFileRef = cleanTimelineFileRef(change?.oldFileRef || "");
1935
+ const normalizedNewFileRef = cleanTimelineFileRef(change?.newFileRef || normalizedFileRef);
1936
+ if (!normalizedChangeType || !(normalizedFileRef || normalizedOldFileRef || normalizedNewFileRef)) {
1937
+ return;
1938
+ }
1939
+
1940
+ const group = ensureDiffThreadGroup(groupsByThread, {
1941
+ threadId,
1942
+ threadLabel,
1943
+ fallbackKey: normalizedFileRef || normalizedNewFileRef || normalizedOldFileRef || fallbackKey,
1944
+ sourceMode,
1945
+ });
1946
+
1947
+ const fileGroupKey = `file:${normalizedNewFileRef || normalizedFileRef || normalizedOldFileRef}`;
1948
+ if (!fileGroupKey) {
1949
+ return;
1950
+ }
1951
+
1952
+ const latestChangedAtMs = Number(createdAtMs) || 0;
1953
+ const fileGroup = {
1954
+ fileRef: normalizedFileRef || normalizedNewFileRef || normalizedOldFileRef,
1955
+ oldFileRef: normalizedOldFileRef,
1956
+ newFileRef: normalizedNewFileRef || normalizedFileRef,
1957
+ fileLabel:
1958
+ path.basename(
1959
+ normalizedChangeType === "delete"
1960
+ ? normalizedOldFileRef || normalizedFileRef
1961
+ : normalizedNewFileRef || normalizedFileRef || normalizedOldFileRef
1962
+ ) ||
1963
+ normalizedNewFileRef ||
1964
+ normalizedOldFileRef ||
1965
+ normalizedFileRef,
1966
+ changeType: normalizedChangeType,
1967
+ fileEventTypes: [normalizedChangeType],
1968
+ addedLines: Math.max(0, Number(change?.diffAddedLines) || 0),
1969
+ removedLines: Math.max(0, Number(change?.diffRemovedLines) || 0),
1970
+ latestChangedAtMs,
1971
+ sections: [
1972
+ {
1973
+ createdAtMs: latestChangedAtMs,
1974
+ diffText: normalizeTimelineDiffText(change?.diffText ?? ""),
1975
+ diffAvailable: change?.diffAvailable === true || Boolean(change?.diffText),
1976
+ diffSource: normalizeTimelineDiffSource(change?.diffSource ?? ""),
1977
+ addedLines: Math.max(0, Number(change?.diffAddedLines) || 0),
1978
+ removedLines: Math.max(0, Number(change?.diffRemovedLines) || 0),
1979
+ fileEventType: normalizedChangeType,
1980
+ },
1981
+ ],
1982
+ };
1983
+ group.filesByRef.set(fileGroupKey, fileGroup);
1984
+ }
1985
+
1767
1986
  function handleSignal() {
1768
1987
  runtime.stopping = true;
1769
1988
  }
1770
1989
 
1990
+ function normalizeProvider(value) {
1991
+ const normalized = String(value || "").toLowerCase();
1992
+ return normalized === "claude" ? "claude" : "codex";
1993
+ }
1994
+
1995
+ function providerDisplayName(locale, provider) {
1996
+ return t(locale, normalizeProvider(provider) === "claude" ? "common.claude" : "common.codex");
1997
+ }
1998
+
1771
1999
  function normalizeHistoryItems(rawItems, maxItems) {
1772
2000
  if (!Array.isArray(rawItems)) {
1773
2001
  return [];
@@ -1834,6 +2062,7 @@ function normalizeHistoryItem(raw) {
1834
2062
  readOnly: raw.readOnly !== false,
1835
2063
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
1836
2064
  tone: cleanText(raw.tone ?? "") || "secondary",
2065
+ provider: normalizeProvider(raw.provider),
1837
2066
  };
1838
2067
  }
1839
2068
 
@@ -1986,6 +2215,7 @@ function normalizeTimelineEntry(raw) {
1986
2215
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
1987
2216
  tone: cleanText(raw.tone ?? "") || "secondary",
1988
2217
  cwd: resolvePath(cleanText(raw.cwd || "")),
2218
+ provider: normalizeProvider(raw.provider),
1989
2219
  };
1990
2220
  }
1991
2221
 
@@ -2211,6 +2441,7 @@ function recordActionHistoryItem({
2211
2441
  diffAddedLines = 0,
2212
2442
  diffRemovedLines = 0,
2213
2443
  outcome = "",
2444
+ provider = "codex",
2214
2445
  }) {
2215
2446
  const item = {
2216
2447
  stableId,
@@ -2228,6 +2459,7 @@ function recordActionHistoryItem({
2228
2459
  diffAddedLines,
2229
2460
  diffRemovedLines,
2230
2461
  outcome,
2462
+ provider: normalizeProvider(provider),
2231
2463
  createdAtMs: Date.now(),
2232
2464
  readOnly: true,
2233
2465
  primaryLabel: "詳細",
@@ -2513,6 +2745,21 @@ async function scanOnce({ config, runtime, state }) {
2513
2745
  dirty = dirty || changed;
2514
2746
  }
2515
2747
 
2748
+ if (config.webUiEnabled) {
2749
+ if (now - runtime.lastClaudeScanAt >= config.directoryScanIntervalMs) {
2750
+ runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir);
2751
+ runtime.lastClaudeScanAt = now;
2752
+ }
2753
+ if (now - runtime.lastClaudeSessionTitleScanAt >= config.directoryScanIntervalMs) {
2754
+ await refreshClaudeSessionTitles(runtime);
2755
+ runtime.lastClaudeSessionTitleScanAt = now;
2756
+ }
2757
+ for (const filePath of runtime.claudeKnownFiles) {
2758
+ const changed = await processClaudeTranscriptFile({ filePath, config, runtime, state, now });
2759
+ dirty = dirty || changed;
2760
+ }
2761
+ }
2762
+
2516
2763
  if (config.webUiEnabled) {
2517
2764
  const sqliteTimelineChanged = await processSqliteTimelineLog({
2518
2765
  config,
@@ -2735,6 +2982,230 @@ function fileEventCallIdFromStableId(stableId) {
2735
2982
  return match ? cleanText(match[1]) : "";
2736
2983
  }
2737
2984
 
2985
+ async function refreshClaudeSessionTitles(runtime) {
2986
+ // Read ~/Library/Application Support/Claude/claude-code-sessions/*/*/local_*.json
2987
+ // Each file maps cliSessionId → title (auto-generated by Claude Desktop).
2988
+ const baseDir = path.join(
2989
+ os.homedir(),
2990
+ "Library",
2991
+ "Application Support",
2992
+ "Claude",
2993
+ "claude-code-sessions"
2994
+ );
2995
+ try {
2996
+ const topEntries = await fs.readdir(baseDir, { withFileTypes: true });
2997
+ for (const top of topEntries) {
2998
+ if (!top.isDirectory()) continue;
2999
+ const topPath = path.join(baseDir, top.name);
3000
+ let midEntries;
3001
+ try {
3002
+ midEntries = await fs.readdir(topPath, { withFileTypes: true });
3003
+ } catch {
3004
+ continue;
3005
+ }
3006
+ for (const mid of midEntries) {
3007
+ if (!mid.isDirectory()) continue;
3008
+ const midPath = path.join(topPath, mid.name);
3009
+ let files;
3010
+ try {
3011
+ files = await fs.readdir(midPath, { withFileTypes: true });
3012
+ } catch {
3013
+ continue;
3014
+ }
3015
+ for (const file of files) {
3016
+ if (!file.isFile() || !file.name.startsWith("local_") || !file.name.endsWith(".json")) continue;
3017
+ try {
3018
+ const raw = await fs.readFile(path.join(midPath, file.name), "utf8");
3019
+ const data = JSON.parse(raw);
3020
+ const cliSessionId = cleanText(data?.cliSessionId || "");
3021
+ const title = cleanText(data?.title || "");
3022
+ if (cliSessionId && title) {
3023
+ runtime.claudeSessionTitles.set(cliSessionId, title);
3024
+ }
3025
+ } catch {
3026
+ // skip unreadable/invalid
3027
+ }
3028
+ }
3029
+ }
3030
+ }
3031
+ } catch {
3032
+ // base dir missing — Claude Desktop not installed
3033
+ }
3034
+ }
3035
+
3036
+ async function listClaudeTranscriptFiles(claudeProjectsDir) {
3037
+ try {
3038
+ const result = [];
3039
+ const entries = await fs.readdir(claudeProjectsDir, { withFileTypes: true });
3040
+ for (const entry of entries) {
3041
+ if (!entry.isDirectory()) continue;
3042
+ const projectPath = path.join(claudeProjectsDir, entry.name);
3043
+ try {
3044
+ const files = await fs.readdir(projectPath, { withFileTypes: true });
3045
+ for (const file of files) {
3046
+ if (!file.isFile() || !file.name.endsWith(".jsonl")) continue;
3047
+ result.push(path.join(projectPath, file.name));
3048
+ }
3049
+ } catch {
3050
+ // skip unreadable project dirs
3051
+ }
3052
+ }
3053
+ return result;
3054
+ } catch {
3055
+ return [];
3056
+ }
3057
+ }
3058
+
3059
+ async function processClaudeTranscriptFile({ filePath, config, runtime, state, now }) {
3060
+ let fileState = runtime.claudeFileStates.get(filePath);
3061
+ if (!fileState) {
3062
+ // Derive a default threadLabel from the project directory path
3063
+ // e.g. ~/.claude/projects/-Users-y-hoshino-Work-kanade/session.jsonl → "kanade"
3064
+ const projectDirName = path.basename(path.dirname(filePath));
3065
+ const decodedProjectDir = projectDirName.replace(/^-/u, "").replace(/-/gu, "/");
3066
+ const defaultThreadLabel = path.basename(decodedProjectDir) || "";
3067
+
3068
+ // On startup, use the same "recent only" strategy as processRolloutFile to avoid
3069
+ // re-processing old entries that would be immediately discarded by the 250-entry limit.
3070
+ let initialOffset = 0;
3071
+ let startupCutoffMs = 0;
3072
+ try {
3073
+ const stat = await fs.stat(filePath);
3074
+ const shouldReplayRecent = stat.mtimeMs >= now - config.replaySeconds * 1000;
3075
+ if (shouldReplayRecent) {
3076
+ // Recently active transcript: start from near the end and apply a time cutoff
3077
+ initialOffset = Math.max(0, stat.size - config.maxReadBytes);
3078
+ startupCutoffMs = now - config.replaySeconds * 1000;
3079
+ } else {
3080
+ // Inactive transcript: skip to end, only process future appends
3081
+ initialOffset = stat.size;
3082
+ }
3083
+ } catch {
3084
+ // stat failed — start from beginning with no cutoff
3085
+ }
3086
+
3087
+ fileState = { offset: initialOffset, threadId: "", threadLabel: defaultThreadLabel, cwd: "", startupCutoffMs, remainder: "" };
3088
+ runtime.claudeFileStates.set(filePath, fileState);
3089
+ }
3090
+
3091
+ let fileContent;
3092
+ let newOffset;
3093
+ try {
3094
+ const stat = await fs.stat(filePath);
3095
+ if (stat.size <= fileState.offset) return false;
3096
+ if (stat.size - fileState.offset > config.maxReadBytes) {
3097
+ fileState.offset = Math.max(0, stat.size - config.maxReadBytes);
3098
+ fileState.remainder = "";
3099
+ }
3100
+ const readLen = stat.size - fileState.offset;
3101
+ const buf = Buffer.alloc(readLen);
3102
+ const fh = await fs.open(filePath, "r");
3103
+ try {
3104
+ const { bytesRead } = await fh.read(buf, 0, readLen, fileState.offset);
3105
+ fileContent = buf.slice(0, bytesRead).toString("utf8");
3106
+ } finally {
3107
+ await fh.close();
3108
+ }
3109
+ newOffset = stat.size;
3110
+ } catch {
3111
+ return false;
3112
+ }
3113
+
3114
+ const merged = (fileState.remainder || "") + fileContent;
3115
+ const lines = merged.split("\n");
3116
+ // Last element is the in-progress partial line (or "" if file ends with \n).
3117
+ // Stash it as the next remainder so a future scan can complete it.
3118
+ fileState.remainder = lines.pop() ?? "";
3119
+ fileState.offset = newOffset;
3120
+ let dirty = false;
3121
+
3122
+ for (const line of lines) {
3123
+ const trimmed = line.trim();
3124
+ if (!trimmed) continue;
3125
+
3126
+ let record;
3127
+ try {
3128
+ record = JSON.parse(trimmed);
3129
+ } catch {
3130
+ continue;
3131
+ }
3132
+
3133
+ // Apply startup cutoff: skip entries older than replaySeconds on first scan
3134
+ if (fileState.startupCutoffMs && record.timestamp) {
3135
+ const recordMs = Date.parse(record.timestamp);
3136
+ if (recordMs && recordMs < fileState.startupCutoffMs) {
3137
+ // Still update threadId/cwd state so later entries inherit correct context
3138
+ if (record.sessionId) fileState.threadId = record.sessionId;
3139
+ if (record.cwd) {
3140
+ fileState.cwd = record.cwd;
3141
+ fileState.threadLabel = path.basename(record.cwd);
3142
+ }
3143
+ continue;
3144
+ }
3145
+ }
3146
+
3147
+ if (record.sessionId) fileState.threadId = record.sessionId;
3148
+ if (record.cwd) {
3149
+ fileState.cwd = record.cwd;
3150
+ fileState.threadLabel = path.basename(record.cwd);
3151
+ }
3152
+
3153
+ // Only process Claude Desktop sessions
3154
+ if (record.entrypoint !== "claude-desktop") continue;
3155
+
3156
+ const type = record.type;
3157
+ if (type !== "user" && type !== "assistant") continue;
3158
+ // Skip Claude Code's meta records (e.g. Stop hook feedback injected as a
3159
+ // synthetic user message). They are not real user input.
3160
+ if (record.isMeta === true) continue;
3161
+
3162
+ const msg = record.message || {};
3163
+ const content = msg.content;
3164
+ const uuid = cleanText(record.uuid || "");
3165
+ const createdAtMs = record.timestamp ? Date.parse(record.timestamp) : now;
3166
+ const threadId = cleanText(fileState.threadId || record.sessionId || "");
3167
+ // Prefer Claude Desktop's auto-generated session title (e.g. "Sync Codex app from Mac to iPhone")
3168
+ // when available; fall back to the cwd-derived basename (e.g. "viveworker").
3169
+ const claudeTitle = threadId ? runtime.claudeSessionTitles.get(threadId) || "" : "";
3170
+ const threadLabel = claudeTitle || fileState.threadLabel || "";
3171
+
3172
+ let text = "";
3173
+ if (typeof content === "string") {
3174
+ text = content;
3175
+ } else if (Array.isArray(content)) {
3176
+ for (const block of content) {
3177
+ if (block?.type === "text" && block.text) text += block.text;
3178
+ }
3179
+ }
3180
+ text = cleanText(text);
3181
+ if (!text) continue;
3182
+
3183
+ const kind = type === "user" ? "user_message" : "assistant_final";
3184
+ const stableId = `${kind}:${threadId}:${uuid}`;
3185
+ const entry = normalizeTimelineEntry({
3186
+ stableId,
3187
+ token: historyToken(stableId),
3188
+ kind,
3189
+ threadId,
3190
+ threadLabel,
3191
+ title: threadLabel || "Claude",
3192
+ summary: truncate(singleLine(text), 180),
3193
+ messageText: text,
3194
+ createdAtMs,
3195
+ readOnly: true,
3196
+ cwd: fileState.cwd,
3197
+ provider: "claude",
3198
+ });
3199
+ if (entry) {
3200
+ dirty = recordTimelineEntry({ config, runtime, state, entry }) || dirty;
3201
+ }
3202
+ }
3203
+
3204
+ // Clear startup cutoff after first scan so subsequent incremental reads are unfiltered
3205
+ fileState.startupCutoffMs = 0;
3206
+ return dirty;
3207
+ }
3208
+
2738
3209
  async function processSqliteCompletionLog({ config, runtime, state, now }) {
2739
3210
  const logsDbFile = cleanText(runtime.logsDbFile || config.codexLogsDbFile || "");
2740
3211
  if (!logsDbFile) {
@@ -3492,8 +3963,13 @@ async function copyTimelineAttachmentToPersistentDir(config, sourcePath) {
3492
3963
  config.timelineAttachmentsDir,
3493
3964
  `${Date.now()}-${crypto.randomUUID()}${extension}`
3494
3965
  );
3495
- await fs.copyFile(normalizedSourcePath, destinationPath);
3496
- return destinationPath;
3966
+ try {
3967
+ await fs.copyFile(normalizedSourcePath, destinationPath);
3968
+ return destinationPath;
3969
+ } catch (error) {
3970
+ console.warn(`[timeline-image-copy-skipped] ${error?.message || error}`);
3971
+ return "";
3972
+ }
3497
3973
  }
3498
3974
 
3499
3975
  async function normalizePersistedTimelineImagePaths({ config, state, imagePaths = [] }) {
@@ -3536,7 +4012,7 @@ async function normalizePersistedTimelineImagePaths({ config, state, imagePaths
3536
4012
 
3537
4013
  let persistentPath = existingSourcePath;
3538
4014
  if (!existingSourcePath.startsWith(`${config.timelineAttachmentsDir}${path.sep}`)) {
3539
- persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath);
4015
+ persistentPath = await copyTimelineAttachmentToPersistentDir(config, existingSourcePath) || existingSourcePath;
3540
4016
  }
3541
4017
 
3542
4018
  aliases[normalizedPath] = persistentPath;
@@ -4077,7 +4553,7 @@ function buildRolloutEvent({ record, filePath, fileState, sessionIndex, config,
4077
4553
  const message = formatMessage(
4078
4554
  [
4079
4555
  t(config.defaultLocale, "server.message.approveOnMac"),
4080
- justification || t(config.defaultLocale, "server.message.approvalNeededInCodex"),
4556
+ justification || t(config.defaultLocale, "server.message.approvalNeededInCodex", { provider: providerDisplayName(config.defaultLocale, "codex") }),
4081
4557
  cmd ? t(config.defaultLocale, "server.message.commandPrefix", { command: cmd }) : null,
4082
4558
  approval.extraCount > 0 ? t(config.defaultLocale, "server.message.extraApprovals", { count: approval.extraCount }) : null,
4083
4559
  ],
@@ -4594,6 +5070,7 @@ async function createNativeApproval({ config, runtime, conversationId, request,
4594
5070
  createdAtMs: now,
4595
5071
  resolved: false,
4596
5072
  resolving: false,
5073
+ provider: "codex",
4597
5074
  };
4598
5075
  }
4599
5076
 
@@ -6769,7 +7246,91 @@ function getNativeThreadLabel({ runtime, conversationId, cwd }) {
6769
7246
  if (cwd) {
6770
7247
  return truncate(cleanText(path.basename(cwd)), 90) || shortId(normalizedConversationId);
6771
7248
  }
6772
- return shortId(normalizedConversationId) || "Codex task";
7249
+ return shortId(normalizedConversationId) || t(DEFAULT_LOCALE, "server.fallback.codexTask", { provider: providerDisplayName(DEFAULT_LOCALE, "codex") });
7250
+ }
7251
+
7252
+ function threadStateArchiveStatus(threadState) {
7253
+ if (!isPlainObject(threadState)) {
7254
+ return "";
7255
+ }
7256
+
7257
+ const booleanCandidates = [
7258
+ threadState.archived,
7259
+ threadState.isArchived,
7260
+ threadState.hidden,
7261
+ threadState.isHidden,
7262
+ threadState.deleted,
7263
+ threadState.isDeleted,
7264
+ threadState.closed,
7265
+ threadState.isClosed,
7266
+ threadState.thread?.archived,
7267
+ threadState.thread?.isArchived,
7268
+ threadState.thread?.hidden,
7269
+ threadState.thread?.isHidden,
7270
+ threadState.thread?.deleted,
7271
+ threadState.thread?.isDeleted,
7272
+ threadState.metadata?.archived,
7273
+ threadState.metadata?.isArchived,
7274
+ threadState.metadata?.hidden,
7275
+ threadState.metadata?.isHidden,
7276
+ threadState.metadata?.deleted,
7277
+ threadState.metadata?.isDeleted,
7278
+ ];
7279
+ if (booleanCandidates.some((value) => value === true)) {
7280
+ return "archived";
7281
+ }
7282
+
7283
+ const statusCandidates = [
7284
+ threadState.status,
7285
+ threadState.state,
7286
+ threadState.visibility,
7287
+ threadState.lifecycle,
7288
+ threadState.thread?.status,
7289
+ threadState.thread?.state,
7290
+ threadState.thread?.visibility,
7291
+ threadState.metadata?.status,
7292
+ threadState.metadata?.state,
7293
+ threadState.metadata?.visibility,
7294
+ ]
7295
+ .map((value) => cleanText(value || "").toLowerCase())
7296
+ .filter(Boolean);
7297
+
7298
+ for (const status of statusCandidates) {
7299
+ if (["archived", "hidden", "deleted", "closed"].includes(status)) {
7300
+ return status;
7301
+ }
7302
+ }
7303
+
7304
+ return "";
7305
+ }
7306
+
7307
+ function shouldExcludeCodeThread(runtime, threadId) {
7308
+ const normalizedThreadId = cleanText(threadId || "");
7309
+ if (!normalizedThreadId) {
7310
+ return false;
7311
+ }
7312
+
7313
+ const threadState = runtime.threadStates.get(normalizedThreadId) ?? null;
7314
+ if (threadStateArchiveStatus(threadState)) {
7315
+ return true;
7316
+ }
7317
+
7318
+ const currentSessionHasThread = Array.isArray(runtime.knownFiles)
7319
+ ? runtime.knownFiles.some((filePath) => extractThreadIdFromRolloutPath(filePath) === normalizedThreadId)
7320
+ : false;
7321
+ if (currentSessionHasThread) {
7322
+ return false;
7323
+ }
7324
+
7325
+ if (threadState) {
7326
+ return false;
7327
+ }
7328
+
7329
+ if (runtime.sessionIndex instanceof Map && runtime.sessionIndex.size > 0) {
7330
+ return runtime.sessionIndex.has(normalizedThreadId);
7331
+ }
7332
+
7333
+ return false;
6773
7334
  }
6774
7335
 
6775
7336
  async function findRolloutThreadCwd(runtime, conversationId) {
@@ -8373,6 +8934,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
8373
8934
  summary: formatNotificationBody(approval.messageText, 100) || approval.messageText,
8374
8935
  primaryLabel: t(locale, "server.action.review"),
8375
8936
  createdAtMs: Number(approval.createdAtMs) || now,
8937
+ provider: normalizeProvider(approval.provider),
8376
8938
  });
8377
8939
  }
8378
8940
 
@@ -8392,6 +8954,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
8392
8954
  summary: formatNotificationBody(planRequest.messageText, 100) || planRequest.messageText,
8393
8955
  primaryLabel: t(locale, "server.action.review"),
8394
8956
  createdAtMs: Number(planRequest.createdAtMs) || now,
8957
+ provider: "codex",
8395
8958
  });
8396
8959
  }
8397
8960
 
@@ -8415,6 +8978,7 @@ function buildPendingInboxItems(runtime, state, config, locale) {
8415
8978
  summary: userInputRequest.notificationText || formatNotificationBody(userInputRequest.messageText, 100),
8416
8979
  primaryLabel: t(locale, userInputRequest.supported ? "server.action.select" : "server.action.detail"),
8417
8980
  createdAtMs: Number(userInputRequest.createdAtMs) || now,
8981
+ provider: "codex",
8418
8982
  });
8419
8983
  }
8420
8984
 
@@ -8438,6 +9002,7 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
8438
9002
  fileRefs: normalizeTimelineFileRefs(item.fileRefs ?? []),
8439
9003
  primaryLabel: t(locale, "server.action.detail"),
8440
9004
  createdAtMs: item.createdAtMs,
9005
+ provider: normalizeProvider(item.provider),
8441
9006
  }));
8442
9007
  }
8443
9008
 
@@ -8447,6 +9012,7 @@ async function buildDiffInboxItems(runtime, state, config, locale) {
8447
9012
  token: group.token,
8448
9013
  threadId: group.threadId,
8449
9014
  threadLabel: group.threadLabel || "",
9015
+ sourceMode: cleanText(group.sourceMode || ""),
8450
9016
  title: cleanText(group.threadLabel || "") || kindTitle(locale, "diff_thread"),
8451
9017
  summary: t(locale, "diff.threadSummary", { count: group.changedFileCount }),
8452
9018
  changedFileCount: group.changedFileCount,
@@ -8487,10 +9053,12 @@ async function buildDiffThreadGroups(runtime, state, config) {
8487
9053
  .slice()
8488
9054
  .sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0));
8489
9055
  const candidatesByRepoRoot = new Map();
9056
+ const nonGitRelevantItems = [];
8490
9057
 
8491
9058
  for (const item of relevantItems) {
8492
9059
  const repoRoot = await resolveCodeEventRepoRoot(item, repoRootCache);
8493
9060
  if (!repoRoot) {
9061
+ nonGitRelevantItems.push(item);
8494
9062
  continue;
8495
9063
  }
8496
9064
  const candidates = expandCodeEventOwnershipCandidates(item, repoRoot);
@@ -8512,112 +9080,42 @@ async function buildDiffThreadGroups(runtime, state, config) {
8512
9080
  continue;
8513
9081
  }
8514
9082
 
8515
- const threadId = cleanText(owner.threadId || "");
8516
- const threadLabel = cleanText(owner.threadLabel || "");
8517
- const threadKey = threadId || `unknown:${threadLabel || cleanText(change.fileRef || change.newFileRef || change.oldFileRef || "")}`;
8518
- let threadGroup = groupsByThread.get(threadKey);
8519
- if (!threadGroup) {
8520
- threadGroup = {
8521
- kind: "diff_thread",
8522
- token: diffThreadToken(threadId, threadLabel),
8523
- threadId,
8524
- threadLabel,
8525
- changedFileCount: 0,
8526
- latestChangedAtMs: 0,
8527
- latestChangedAtMsForSummary: 0,
8528
- latestChangeType: "",
8529
- latestChangeFileRefs: [],
8530
- diffAddedLines: 0,
8531
- diffRemovedLines: 0,
8532
- filesByRef: new Map(),
8533
- };
8534
- groupsByThread.set(threadKey, threadGroup);
8535
- } else if (!threadGroup.threadLabel && threadLabel) {
8536
- threadGroup.threadLabel = threadLabel;
8537
- }
9083
+ addChangeToDiffThreadGroup({
9084
+ groupsByThread,
9085
+ threadId: cleanText(owner.threadId || ""),
9086
+ threadLabel: cleanText(owner.threadLabel || ""),
9087
+ fallbackKey: cleanText(change.fileRef || change.newFileRef || change.oldFileRef || ""),
9088
+ sourceMode: "git_current",
9089
+ change,
9090
+ createdAtMs: Number(owner.createdAtMs) || 0,
9091
+ });
9092
+ }
9093
+ }
8538
9094
 
8539
- const fileGroupKey =
8540
- normalizeTimelineFileEventType(change.changeType) === "rename"
8541
- ? codeOwnershipKeyForRename(change.oldFileRef || "", change.newFileRef || change.fileRef || "")
8542
- : `${normalizeTimelineFileEventType(change.changeType)}:${cleanTimelineFileRef(change.fileRef || change.newFileRef || change.oldFileRef || "")}`;
8543
- if (!fileGroupKey) {
9095
+ const latestNonGitChangesByIdentity = new Map();
9096
+ for (const item of nonGitRelevantItems) {
9097
+ const changes = buildNonGitCodeChangeEntries(item);
9098
+ for (const change of changes) {
9099
+ const identity = nonGitCodeChangeIdentity(change);
9100
+ if (!identity || latestNonGitChangesByIdentity.has(identity)) {
8544
9101
  continue;
8545
9102
  }
8546
-
8547
- const latestOwnerChangeAtMs = Number(owner.createdAtMs) || 0;
8548
- const normalizedFileRef = cleanTimelineFileRef(change.fileRef || change.newFileRef || change.oldFileRef || "");
8549
- const normalizedOldFileRef = cleanTimelineFileRef(change.oldFileRef || "");
8550
- const normalizedNewFileRef = cleanTimelineFileRef(change.newFileRef || normalizedFileRef);
8551
- let fileGroup = threadGroup.filesByRef.get(fileGroupKey);
8552
- if (!fileGroup) {
8553
- fileGroup = {
8554
- fileRef: normalizedFileRef,
8555
- oldFileRef: normalizedOldFileRef,
8556
- newFileRef: normalizedNewFileRef,
8557
- fileLabel:
8558
- path.basename(
8559
- normalizeTimelineFileEventType(change.changeType) === "delete"
8560
- ? normalizedOldFileRef || normalizedFileRef
8561
- : normalizedNewFileRef || normalizedFileRef
8562
- ) ||
8563
- normalizedNewFileRef ||
8564
- normalizedOldFileRef ||
8565
- normalizedFileRef,
8566
- changeType: normalizeTimelineFileEventType(change.changeType),
8567
- fileEventTypes: new Set(),
8568
- addedLines: 0,
8569
- removedLines: 0,
8570
- latestChangedAtMs: latestOwnerChangeAtMs,
8571
- sections: [],
8572
- };
8573
- threadGroup.filesByRef.set(fileGroupKey, fileGroup);
8574
- }
8575
-
8576
- const changeType = normalizeTimelineFileEventType(change.changeType);
8577
- if (changeType) {
8578
- fileGroup.fileEventTypes.add(changeType);
8579
- }
8580
- fileGroup.changeType = changeType || fileGroup.changeType;
8581
- fileGroup.addedLines = Math.max(0, Number(change.diffAddedLines) || 0);
8582
- fileGroup.removedLines = Math.max(0, Number(change.diffRemovedLines) || 0);
8583
- fileGroup.latestChangedAtMs = latestOwnerChangeAtMs;
8584
- fileGroup.fileRef = normalizedFileRef;
8585
- fileGroup.oldFileRef = normalizedOldFileRef;
8586
- fileGroup.newFileRef = normalizedNewFileRef;
8587
- fileGroup.sections = [
8588
- {
8589
- createdAtMs: latestOwnerChangeAtMs,
8590
- diffText: normalizeTimelineDiffText(change.diffText ?? ""),
8591
- diffAvailable: change.diffAvailable === true || Boolean(change.diffText),
8592
- diffSource: normalizeTimelineDiffSource(change.diffSource ?? ""),
8593
- addedLines: Math.max(0, Number(change.diffAddedLines) || 0),
8594
- removedLines: Math.max(0, Number(change.diffRemovedLines) || 0),
8595
- fileEventType: changeType,
8596
- },
8597
- ];
8598
-
8599
- threadGroup.latestChangedAtMs = Math.max(threadGroup.latestChangedAtMs, latestOwnerChangeAtMs);
8600
- threadGroup.diffAddedLines += Math.max(0, Number(change.diffAddedLines) || 0);
8601
- threadGroup.diffRemovedLines += Math.max(0, Number(change.diffRemovedLines) || 0);
8602
-
8603
- if (latestOwnerChangeAtMs > threadGroup.latestChangedAtMsForSummary) {
8604
- threadGroup.latestChangedAtMsForSummary = latestOwnerChangeAtMs;
8605
- threadGroup.latestChangeType = changeType || "";
8606
- threadGroup.latestChangeFileRefs = [normalizedFileRef || normalizedNewFileRef || normalizedOldFileRef];
8607
- } else if (latestOwnerChangeAtMs === (threadGroup.latestChangedAtMsForSummary || 0)) {
8608
- if (changeType && threadGroup.latestChangeType && threadGroup.latestChangeType !== changeType) {
8609
- threadGroup.latestChangeType = "";
8610
- } else if (!threadGroup.latestChangeType && changeType && threadGroup.latestChangeFileRefs.length === 0) {
8611
- threadGroup.latestChangeType = changeType;
8612
- }
8613
- const latestFileRef = normalizedFileRef || normalizedNewFileRef || normalizedOldFileRef;
8614
- if (latestFileRef && !threadGroup.latestChangeFileRefs.includes(latestFileRef)) {
8615
- threadGroup.latestChangeFileRefs.push(latestFileRef);
8616
- }
8617
- }
9103
+ latestNonGitChangesByIdentity.set(identity, change);
8618
9104
  }
8619
9105
  }
8620
9106
 
9107
+ for (const change of latestNonGitChangesByIdentity.values()) {
9108
+ addChangeToDiffThreadGroup({
9109
+ groupsByThread,
9110
+ threadId: cleanText(change.threadId || ""),
9111
+ threadLabel: cleanText(change.threadLabel || ""),
9112
+ fallbackKey: cleanText(change.fileRef || change.newFileRef || change.oldFileRef || ""),
9113
+ sourceMode: "non_git_latest",
9114
+ change,
9115
+ createdAtMs: Number(change.createdAtMs) || 0,
9116
+ });
9117
+ }
9118
+
8621
9119
  return [...groupsByThread.values()]
8622
9120
  .map((group) => {
8623
9121
  const files = [...group.filesByRef.values()]
@@ -8627,7 +9125,9 @@ async function buildDiffThreadGroups(runtime, state, config) {
8627
9125
  newFileRef: fileGroup.newFileRef,
8628
9126
  fileLabel: fileGroup.fileLabel,
8629
9127
  changeType: normalizeTimelineFileEventType(fileGroup.changeType),
8630
- fileEventTypes: [...fileGroup.fileEventTypes.values()],
9128
+ fileEventTypes: Array.isArray(fileGroup.fileEventTypes)
9129
+ ? fileGroup.fileEventTypes
9130
+ : [...fileGroup.fileEventTypes.values()],
8631
9131
  addedLines: fileGroup.addedLines,
8632
9132
  removedLines: fileGroup.removedLines,
8633
9133
  latestChangedAtMs: fileGroup.latestChangedAtMs,
@@ -8635,20 +9135,33 @@ async function buildDiffThreadGroups(runtime, state, config) {
8635
9135
  }))
8636
9136
  .sort((left, right) => Number(right.latestChangedAtMs ?? 0) - Number(left.latestChangedAtMs ?? 0));
8637
9137
 
9138
+ const latestChangedAtMs = Math.max(0, ...files.map((fileGroup) => Number(fileGroup.latestChangedAtMs) || 0));
9139
+ const latestFiles = files.filter((fileGroup) => Number(fileGroup.latestChangedAtMs || 0) === latestChangedAtMs);
9140
+ const latestChangeTypes = [...new Set(latestFiles.map((fileGroup) => normalizeTimelineFileEventType(fileGroup.changeType)).filter(Boolean))];
9141
+ const latestChangeFileRefs = normalizeTimelineFileRefs(
9142
+ latestFiles
9143
+ .map((fileGroup) => cleanTimelineFileRef(fileGroup.fileRef || fileGroup.newFileRef || fileGroup.oldFileRef || ""))
9144
+ .filter(Boolean)
9145
+ );
9146
+ const diffAddedLines = files.reduce((sum, fileGroup) => sum + Math.max(0, Number(fileGroup.addedLines) || 0), 0);
9147
+ const diffRemovedLines = files.reduce((sum, fileGroup) => sum + Math.max(0, Number(fileGroup.removedLines) || 0), 0);
9148
+
8638
9149
  return {
8639
9150
  kind: "diff_thread",
8640
9151
  token: group.token,
8641
9152
  threadId: group.threadId,
8642
9153
  threadLabel: group.threadLabel,
9154
+ sourceMode: cleanText(group.sourceMode || ""),
8643
9155
  changedFileCount: files.length,
8644
- latestChangedAtMs: group.latestChangedAtMs,
8645
- latestChangeType: group.latestChangeType,
8646
- latestChangeFileRefs: normalizeTimelineFileRefs(group.latestChangeFileRefs),
8647
- diffAddedLines: group.diffAddedLines,
8648
- diffRemovedLines: group.diffRemovedLines,
9156
+ latestChangedAtMs,
9157
+ latestChangeType: latestChangeTypes.length === 1 ? latestChangeTypes[0] : "",
9158
+ latestChangeFileRefs,
9159
+ diffAddedLines,
9160
+ diffRemovedLines,
8649
9161
  files,
8650
9162
  };
8651
9163
  })
9164
+ .filter((group) => !shouldExcludeCodeThread(runtime, group.threadId))
8652
9165
  .filter((group) => group.changedFileCount > 0)
8653
9166
  .sort((left, right) => Number(right.latestChangedAtMs ?? 0) - Number(left.latestChangedAtMs ?? 0));
8654
9167
  }
@@ -8681,6 +9194,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
8681
9194
  messageText: approval.messageText,
8682
9195
  outcome: "pending",
8683
9196
  createdAtMs: Number(approval.createdAtMs) || now,
9197
+ provider: normalizeProvider(approval.provider),
8684
9198
  })
8685
9199
  );
8686
9200
  }
@@ -8704,6 +9218,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
8704
9218
  messageText: planRequest.messageText,
8705
9219
  outcome: "pending",
8706
9220
  createdAtMs: Number(planRequest.createdAtMs) || now,
9221
+ provider: "codex",
8707
9222
  })
8708
9223
  );
8709
9224
  }
@@ -8731,6 +9246,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
8731
9246
  messageText: userInputRequest.messageText,
8732
9247
  outcome: "pending",
8733
9248
  createdAtMs: Number(userInputRequest.createdAtMs) || now,
9249
+ provider: "codex",
8734
9250
  })
8735
9251
  );
8736
9252
  }
@@ -8751,6 +9267,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
8751
9267
  messageText: historyItem.messageText,
8752
9268
  outcome: historyItem.outcome,
8753
9269
  createdAtMs: historyItem.createdAtMs,
9270
+ provider: normalizeProvider(historyItem.provider),
8754
9271
  })
8755
9272
  );
8756
9273
  }
@@ -8789,7 +9306,7 @@ function buildTimelineThreads(entries, config) {
8789
9306
  }
8790
9307
  const preferredLabel =
8791
9308
  sanitizeTimelineThreadFilterLabel(entry.threadLabel || "", threadId) ||
8792
- t(DEFAULT_LOCALE, "server.fallback.codexTask");
9309
+ t(DEFAULT_LOCALE, "server.fallback.codexTask", { provider: providerDisplayName(DEFAULT_LOCALE, entry?.provider) });
8793
9310
  const existing = byThread.get(threadId);
8794
9311
  if (!existing) {
8795
9312
  byThread.set(threadId, {
@@ -8838,6 +9355,7 @@ function buildTimelineResponse(runtime, state, config, locale) {
8838
9355
  diffRemovedLines: Math.max(0, Number(entry.diffRemovedLines) || 0),
8839
9356
  outcome: entry.outcome || "",
8840
9357
  createdAtMs: entry.createdAtMs,
9358
+ provider: normalizeProvider(entry.provider),
8841
9359
  }));
8842
9360
 
8843
9361
  return {
@@ -8848,9 +9366,11 @@ function buildTimelineResponse(runtime, state, config, locale) {
8848
9366
 
8849
9367
  function buildPendingApprovalDetail(runtime, approval, locale) {
8850
9368
  const previousContext = buildPreviousApprovalContext(runtime, approval);
8851
- return {
9369
+ const approvalKind = cleanText(approval.kind || "");
9370
+ const detail = {
8852
9371
  kind: "approval",
8853
- approvalKind: cleanText(approval.kind || ""),
9372
+ approvalKind,
9373
+ provider: normalizeProvider(approval.provider),
8854
9374
  token: approval.token,
8855
9375
  title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
8856
9376
  threadLabel: approval.threadLabel || "",
@@ -8863,12 +9383,28 @@ function buildPendingApprovalDetail(runtime, approval, locale) {
8863
9383
  diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
8864
9384
  diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
8865
9385
  previousContext,
8866
- readOnly: false,
8867
- actions: [
8868
- { label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
8869
- { label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
8870
- ],
9386
+ readOnly: approval.readOnly === true,
9387
+ actions: approval.readOnly === true
9388
+ ? []
9389
+ : [
9390
+ { label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
9391
+ { label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
9392
+ ],
8871
9393
  };
9394
+ if (approvalKind === "plan") {
9395
+ const planText = String(approval.planText || "");
9396
+ detail.planText = planText;
9397
+ detail.planHtml = planText
9398
+ ? renderMessageHtml(planText, `<p>${escapeHtml(t(locale, "detail.planReady"))}</p>`)
9399
+ : "";
9400
+ }
9401
+ if (approvalKind === "question") {
9402
+ detail.questions = Array.isArray(approval.questions) ? approval.questions : [];
9403
+ detail.answerUrl = `/api/items/question/${encodeURIComponent(approval.token)}/answer`;
9404
+ // Question answers are submitted via answerUrl; remove approve/reject actions.
9405
+ detail.actions = [];
9406
+ }
9407
+ return detail;
8872
9408
  }
8873
9409
 
8874
9410
  function buildPreviousApprovalContext(runtime, approval) {
@@ -9151,7 +9687,7 @@ function buildTimelineFileEventDetail(entry, locale) {
9151
9687
  threadLabel: entry.threadLabel || "",
9152
9688
  fileEventType,
9153
9689
  createdAtMs: Number(entry.createdAtMs) || 0,
9154
- messageHtml: renderMessageHtml(fileEventDetailCopy(locale, fileEventType), `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
9690
+ messageHtml: renderMessageHtml(fileEventDetailCopy(locale, fileEventType, entry?.provider), `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
9155
9691
  fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
9156
9692
  previousFileRefs: normalizeTimelineFileRefs(entry.previousFileRefs ?? []),
9157
9693
  diffAvailable: Boolean(entry.diffAvailable),
@@ -9165,18 +9701,20 @@ function buildTimelineFileEventDetail(entry, locale) {
9165
9701
  }
9166
9702
 
9167
9703
  function buildDiffThreadDetail(group, locale) {
9704
+ const changedFileCount = Math.max(0, Number(group.changedFileCount) || 0);
9168
9705
  return {
9169
9706
  kind: "diff_thread",
9170
9707
  token: group.token,
9171
9708
  threadId: cleanText(group.threadId || ""),
9172
9709
  title: cleanText(group.threadLabel || "") || kindTitle(locale, "diff_thread"),
9173
9710
  threadLabel: group.threadLabel || "",
9711
+ sourceMode: cleanText(group.sourceMode || ""),
9174
9712
  createdAtMs: Number(group.latestChangedAtMs) || 0,
9175
- changedFileCount: Math.max(0, Number(group.changedFileCount) || 0),
9713
+ changedFileCount,
9176
9714
  diffAddedLines: Math.max(0, Number(group.diffAddedLines) || 0),
9177
9715
  diffRemovedLines: Math.max(0, Number(group.diffRemovedLines) || 0),
9178
9716
  messageHtml: renderMessageHtml(
9179
- t(locale, "detail.diffThread.copy", { count: Math.max(0, Number(group.changedFileCount) || 0) }),
9717
+ diffThreadDetailCopy(locale, group.sourceMode, changedFileCount),
9180
9718
  `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`
9181
9719
  ),
9182
9720
  files: Array.isArray(group.files)
@@ -9278,7 +9816,7 @@ async function submitGenericUserInputDecision({ config, runtime, state, userInpu
9278
9816
  title: userInputRequest.title,
9279
9817
  messageText: userInputRequest.testRequest
9280
9818
  ? `${t(config.defaultLocale, "server.message.choiceSubmittedTest")}\n\n${buildChoiceHistoryText(userInputRequest, submittedAnswers)}`
9281
- : `${t(config.defaultLocale, "server.message.choiceSubmitted")}\n\n${buildChoiceHistoryText(userInputRequest, submittedAnswers)}`,
9819
+ : `${t(config.defaultLocale, "server.message.choiceSubmitted", { provider: providerDisplayName(config.defaultLocale, "codex") })}\n\n${buildChoiceHistoryText(userInputRequest, submittedAnswers)}`,
9282
9820
  summary: userInputRequest.testRequest
9283
9821
  ? t(config.defaultLocale, "server.message.choiceSummaryReceivedTest")
9284
9822
  : t(config.defaultLocale, "server.message.choiceSummarySubmitted"),
@@ -9716,8 +10254,98 @@ async function handlePlanDecision({ config, runtime, state, planRequest, decisio
9716
10254
  console.log(`[plan-decision] ${planRequest.requestKey} | ${decision} | ${decisionTransport}`);
9717
10255
  }
9718
10256
 
10257
+ function claudeToolFingerprint(toolName, toolInput) {
10258
+ if (!toolInput || typeof toolInput !== "object") return "";
10259
+ if (toolName === "Bash") {
10260
+ return String(toolInput.command || "").slice(0, 500);
10261
+ }
10262
+ if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") {
10263
+ return String(toolInput.file_path || toolInput.path || "");
10264
+ }
10265
+ if (toolName === "AskUserQuestion") {
10266
+ // PostToolUse may include the user's `answers` in toolInput, which would
10267
+ // change a naive JSON.stringify fingerprint. Hash only the question texts.
10268
+ try {
10269
+ const qs = Array.isArray(toolInput.questions) ? toolInput.questions : [];
10270
+ return JSON.stringify(qs.map((q) => String(q?.question || q?.header || ""))).slice(0, 500);
10271
+ } catch {
10272
+ return "";
10273
+ }
10274
+ }
10275
+ if (toolName === "ExitPlanMode") {
10276
+ return String(toolInput.plan || "").slice(0, 500);
10277
+ }
10278
+ try {
10279
+ return JSON.stringify(toolInput).slice(0, 500);
10280
+ } catch {
10281
+ return "";
10282
+ }
10283
+ }
10284
+
10285
+ function formatClaudeQuestionAnswers(questions, answers) {
10286
+ if (!Array.isArray(questions) || !Array.isArray(answers)) return "";
10287
+ const lines = [];
10288
+ for (let i = 0; i < questions.length; i++) {
10289
+ const q = questions[i] || {};
10290
+ const ans = answers[i] || {};
10291
+ const questionText = String(q.question || q.header || "").trim();
10292
+ if (!questionText) continue;
10293
+ const options = Array.isArray(q.options) ? q.options : [];
10294
+ const optionIndices = Array.isArray(ans.optionIndices) ? ans.optionIndices : [];
10295
+ const labels = optionIndices
10296
+ .map((idx) => options[idx]?.label)
10297
+ .filter((label) => typeof label === "string" && label.length > 0);
10298
+ const note = typeof ans.note === "string" ? ans.note.trim() : "";
10299
+ let answerLine = "";
10300
+ if (labels.length > 0) {
10301
+ answerLine = labels.join(", ");
10302
+ }
10303
+ if (note) {
10304
+ answerLine = answerLine ? `${answerLine} (note: ${note})` : note;
10305
+ }
10306
+ if (!answerLine) {
10307
+ answerLine = "(no answer)";
10308
+ }
10309
+ lines.push(`Q${i + 1}: ${questionText}`);
10310
+ lines.push(`A: ${answerLine}`);
10311
+ }
10312
+ return lines.join("\n");
10313
+ }
10314
+
10315
+ function findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint) {
10316
+ let match = null;
10317
+ for (const approval of runtime.nativeApprovalsByToken.values()) {
10318
+ if (approval.resolved) continue;
10319
+ if (approval.conversationId !== threadId) continue;
10320
+ if (approval.claudeToolName && toolName && approval.claudeToolName !== toolName) continue;
10321
+ if (approval.claudeToolFingerprint && fingerprint && approval.claudeToolFingerprint !== fingerprint) continue;
10322
+ if (!match || approval.createdAtMs > match.createdAtMs) match = approval;
10323
+ }
10324
+ return match;
10325
+ }
10326
+
9719
10327
  async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
9720
- await runtime.ipcClient?.sendApprovalDecision(approval, decision);
10328
+ if (approval.resolveClaudeWaiter) {
10329
+ if (approval.provider === "claude" && approval.kind === "plan") {
10330
+ // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
10331
+ // (Claude still shows the native PC plan dialog). Instead, deny the tool
10332
+ // call with a reason that tells Claude the user already decided on mobile.
10333
+ approval.resolveClaudeWaiter({
10334
+ permissionDecision: "deny",
10335
+ permissionDecisionReason:
10336
+ decision === "accept"
10337
+ ? "User approved the plan via the viveworker mobile app. Proceed with implementing the plan exactly as proposed and do not call ExitPlanMode again."
10338
+ : "User rejected the plan via the viveworker mobile app. Revise the plan based on the conversation context before calling ExitPlanMode again.",
10339
+ });
10340
+ } else {
10341
+ approval.resolveClaudeWaiter(decision);
10342
+ }
10343
+ } else if (approval.provider === "claude") {
10344
+ // notifyOnly Claude approvals have no long-poll waiter and no ipcClient
10345
+ // back-channel — the desktop user already answered. Nothing to send.
10346
+ } else {
10347
+ await runtime.ipcClient?.sendApprovalDecision(approval, decision);
10348
+ }
9721
10349
  approval.resolved = true;
9722
10350
  approval.resolving = false;
9723
10351
  runtime.nativeApprovalsByToken.delete(approval.token);
@@ -9731,8 +10359,8 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
9731
10359
  token: approval.token,
9732
10360
  title: approval.title,
9733
10361
  threadLabel: approval.threadLabel || "",
9734
- messageText: `${approvalDecisionMessage(decision, config.defaultLocale)}\n\n${approval.messageText}`,
9735
- summary: approvalDecisionMessage(decision, config.defaultLocale),
10362
+ messageText: `${approvalDecisionMessage(decision, config.defaultLocale, approval.provider)}\n\n${approval.messageText}`,
10363
+ summary: approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
9736
10364
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
9737
10365
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
9738
10366
  diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
@@ -9740,6 +10368,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
9740
10368
  diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
9741
10369
  diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
9742
10370
  outcome: decision === "accept" ? "approved" : "rejected",
10371
+ provider: approval.provider || "codex",
9743
10372
  });
9744
10373
  if (stateChanged) {
9745
10374
  await saveState(config.stateFile, state);
@@ -10150,6 +10779,26 @@ function createNativeApprovalServer({ config, runtime, state }) {
10150
10779
  });
10151
10780
  }
10152
10781
 
10782
+ if (url.pathname === "/api/settings/claude-away-mode" && req.method === "POST") {
10783
+ const session = requireMutatingApiSession(req, res, config, state);
10784
+ if (!session) {
10785
+ return;
10786
+ }
10787
+ let payload;
10788
+ try {
10789
+ payload = await parseJsonBody(req);
10790
+ } catch {
10791
+ return writeJson(res, 400, { error: "invalid-json-body" });
10792
+ }
10793
+ const enabled = payload?.enabled === true;
10794
+ if (state.claudeAwayMode !== enabled) {
10795
+ state.claudeAwayMode = enabled;
10796
+ await saveState(config.stateFile, state);
10797
+ }
10798
+ await syncClaudeAwayModeSentinel(config, enabled);
10799
+ return writeJson(res, 200, { ok: true, enabled });
10800
+ }
10801
+
10153
10802
  if (url.pathname === "/api/push/status" && req.method === "GET") {
10154
10803
  const session = requireApiSession(req, res, config, state);
10155
10804
  if (!session) {
@@ -10271,6 +10920,221 @@ function createNativeApprovalServer({ config, runtime, state }) {
10271
10920
  });
10272
10921
  }
10273
10922
 
10923
+ if (url.pathname === "/api/providers/claude/events" && req.method === "POST") {
10924
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
10925
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
10926
+ return writeJson(res, 401, { error: "unauthorized" });
10927
+ }
10928
+
10929
+ let body;
10930
+ try {
10931
+ body = await parseJsonBody(req);
10932
+ } catch {
10933
+ return writeJson(res, 400, { error: "invalid-json-body" });
10934
+ }
10935
+
10936
+ const eventType = String(body.eventType || body.hookEventName || "");
10937
+
10938
+ if (eventType === "Stop" || eventType === "SessionEnd") {
10939
+ // Agent turn ended — any still-pending native approvals for this
10940
+ // session were not resolved by PostToolUse, meaning the user
10941
+ // denied them on PC. Resolve as deny so iPhone clears them.
10942
+ const threadId = String(body.threadId || body.sessionId || "");
10943
+ console.log(`[claude-stop] threadId=${threadId} pendingTotal=${runtime.nativeApprovalsByToken.size}`);
10944
+ if (threadId) {
10945
+ const pending = [];
10946
+ for (const approval of runtime.nativeApprovalsByToken.values()) {
10947
+ if (!approval.resolved && approval.conversationId === threadId) {
10948
+ pending.push(approval);
10949
+ }
10950
+ }
10951
+ console.log(`[claude-stop] matched=${pending.length}`);
10952
+ for (const approval of pending) {
10953
+ try {
10954
+ await handleNativeApprovalDecision({ config, runtime, state, approval, decision: "reject" });
10955
+ } catch (error) {
10956
+ console.error(`[claude-stop-resolve-error] ${approval.requestKey} | ${error.message}`);
10957
+ }
10958
+ }
10959
+ }
10960
+ return writeJson(res, 200, {});
10961
+ }
10962
+
10963
+ if (eventType === "file_event") {
10964
+ const threadId = String(body.threadId || body.sessionId || "");
10965
+ const filePath = String(body.filePath || "");
10966
+ const fileEventType = String(body.fileEventType || "");
10967
+ if (!threadId || !filePath || !fileEventType) {
10968
+ return writeJson(res, 200, {});
10969
+ }
10970
+ const eventCwd = String(body.cwd || "");
10971
+ const createdAtMs = Number(body.createdAtMs) || Date.now();
10972
+ const threadLabel =
10973
+ runtime.claudeSessionTitles.get(threadId) ||
10974
+ getNativeThreadLabel({ runtime, conversationId: threadId, cwd: eventCwd }) ||
10975
+ (eventCwd ? path.basename(eventCwd) : "") ||
10976
+ threadId.slice(0, 40);
10977
+ const stableId = `file_event:${fileEventType}:${threadId}:${historyToken(`${threadId}:${createdAtMs}:${filePath}`)}`;
10978
+ const token = historyToken(`file_event:${fileEventType}:${threadId}:${createdAtMs}:${filePath}`);
10979
+ const entry = {
10980
+ stableId,
10981
+ token,
10982
+ kind: "file_event",
10983
+ fileEventType,
10984
+ threadId,
10985
+ threadLabel,
10986
+ title: fileEventTitle(DEFAULT_LOCALE, fileEventType),
10987
+ summary: "",
10988
+ fileRefs: [filePath],
10989
+ diffText: String(body.diffText || ""),
10990
+ diffSource: "claude-hook",
10991
+ diffAvailable: body.diffAvailable === true || Boolean(body.diffText),
10992
+ diffAddedLines: Math.max(0, Number(body.diffAddedLines) || 0),
10993
+ diffRemovedLines: Math.max(0, Number(body.diffRemovedLines) || 0),
10994
+ createdAtMs,
10995
+ cwd: eventCwd,
10996
+ readOnly: true,
10997
+ provider: "claude",
10998
+ };
10999
+ const changed = recordTimelineEntry({ config, runtime, state, entry });
11000
+ if (changed) {
11001
+ try {
11002
+ await saveState(config.stateFile, state);
11003
+ } catch (error) {
11004
+ console.error(`[claude-file-event-save] ${error.message}`);
11005
+ }
11006
+ }
11007
+ return writeJson(res, 200, { ok: true });
11008
+ }
11009
+
11010
+ if (eventType === "PostToolUse" || eventType === "PostToolUseFailure") {
11011
+ // The tool actually ran on PC — resolve any pending native approval
11012
+ // for this session/tool as accepted (so iPhone moves it to completed).
11013
+ const threadId = String(body.threadId || body.sessionId || "");
11014
+ const toolName = String(body.toolName || "");
11015
+ const fingerprint = claudeToolFingerprint(toolName, body.toolInput);
11016
+ console.log(`[claude-post-tool] threadId=${threadId} tool=${toolName} fp=${fingerprint.slice(0, 80)} pendingTotal=${runtime.nativeApprovalsByToken.size}`);
11017
+ const match = findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint);
11018
+ console.log(`[claude-post-tool] match=${match ? match.requestKey : "none"}`);
11019
+ if (match) {
11020
+ try {
11021
+ await handleNativeApprovalDecision({ config, runtime, state, approval: match, decision: "accept" });
11022
+ } catch (error) {
11023
+ console.error(`[claude-post-tool-resolve-error] ${match.requestKey} | ${error.message}`);
11024
+ }
11025
+ }
11026
+ return writeJson(res, 200, {});
11027
+ }
11028
+
11029
+ if (eventType !== "approval_request") {
11030
+ // Non-approval events (Notification, Stop, etc.) — acknowledge immediately
11031
+ return writeJson(res, 200, {});
11032
+ }
11033
+
11034
+ // Approval request — register in shared map so UI can display it,
11035
+ // then long-poll until user decides (or 10 min timeout)
11036
+ const token = crypto.randomBytes(18).toString("hex");
11037
+ const threadId = String(body.threadId || "");
11038
+ const requestId = String(body.requestId || crypto.randomUUID());
11039
+ const requestKey = `${threadId}:${requestId}`;
11040
+
11041
+ const approval = {
11042
+ token,
11043
+ requestKey,
11044
+ conversationId: threadId,
11045
+ requestId,
11046
+ ownerClientId: null,
11047
+ kind: String(body.approvalKind || "command"),
11048
+ threadLabel:
11049
+ runtime.claudeSessionTitles.get(threadId) ||
11050
+ (body.cwd ? path.basename(String(body.cwd)) : "") ||
11051
+ String(body.threadId || "").slice(0, 40),
11052
+ title: config.approvalTitle,
11053
+ messageText: String(body.messageText || ""),
11054
+ fileRefs: Array.isArray(body.fileRefs) ? body.fileRefs : [],
11055
+ previousFileRefs: [],
11056
+ diffText: String(body.diffText || ""),
11057
+ diffAvailable: Boolean(body.diffAvailable),
11058
+ diffSource: String(body.diffSource || "claude_permission_request"),
11059
+ diffAddedLines: Math.max(0, Number(body.diffAddedLines) || 0),
11060
+ diffRemovedLines: Math.max(0, Number(body.diffRemovedLines) || 0),
11061
+ createdAtMs: Number(body.createdAtMs) || Date.now(),
11062
+ resolved: false,
11063
+ resolving: false,
11064
+ resolveClaudeWaiter: null,
11065
+ claudeToolName: String(body.toolName || ""),
11066
+ claudeToolFingerprint: claudeToolFingerprint(String(body.toolName || ""), body.toolInput),
11067
+ provider: "claude",
11068
+ planText: String(body.planText || ""),
11069
+ questions: Array.isArray(body.questions) ? body.questions : [],
11070
+ notifyOnly: body.notifyOnly === true,
11071
+ readOnly: body.notifyOnly === true,
11072
+ };
11073
+
11074
+ runtime.nativeApprovalsByToken.set(token, approval);
11075
+ runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
11076
+
11077
+ deliverWebPushItem({
11078
+ config,
11079
+ state,
11080
+ kind: "approval",
11081
+ token: approval.token,
11082
+ stableId: pendingApprovalStableId(approval),
11083
+ title: approval.title,
11084
+ body: approval.messageText,
11085
+ buildLocalizedContent: ({ locale }) => ({
11086
+ title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
11087
+ body: approval.messageText,
11088
+ }),
11089
+ }).catch(() => {});
11090
+
11091
+ // Notify-only path: phone gets a read-only entry + push, but the
11092
+ // hook does not block on a decision. The PostToolUse handler will
11093
+ // clean it up once the desktop user finishes the tool call.
11094
+ if (approval.notifyOnly) {
11095
+ return writeJson(res, 200, { ok: true, notifyOnly: true });
11096
+ }
11097
+
11098
+ const CLAUDE_APPROVAL_WAIT_MS = 600_000; // 10 min
11099
+
11100
+ const decisionPromise = new Promise((resolve) => {
11101
+ approval.resolveClaudeWaiter = resolve;
11102
+ });
11103
+
11104
+ const timeoutPromise = new Promise((resolve) => {
11105
+ setTimeout(() => resolve("deny"), CLAUDE_APPROVAL_WAIT_MS);
11106
+ });
11107
+
11108
+ const decision = await Promise.race([decisionPromise, timeoutPromise]);
11109
+
11110
+ // Clean up if timed out (waiter was never called)
11111
+ if (!approval.resolved) {
11112
+ approval.resolved = true;
11113
+ approval.resolving = false;
11114
+ runtime.nativeApprovalsByToken.delete(token);
11115
+ runtime.nativeApprovalsByRequestKey.delete(requestKey);
11116
+ }
11117
+
11118
+ // The waiter may resolve with either a string ("accept" / "reject" /
11119
+ // "deny") or an object { permissionDecision, permissionDecisionReason }.
11120
+ // The latter is used by the AskUserQuestion answer flow so the user's
11121
+ // chosen text is fed back to Claude.
11122
+ if (decision && typeof decision === "object") {
11123
+ const responseBody = {
11124
+ permissionDecision:
11125
+ decision.permissionDecision === "allow" ? "allow" : "deny",
11126
+ };
11127
+ if (typeof decision.permissionDecisionReason === "string" && decision.permissionDecisionReason) {
11128
+ responseBody.permissionDecisionReason = decision.permissionDecisionReason;
11129
+ }
11130
+ return writeJson(res, 200, responseBody);
11131
+ }
11132
+
11133
+ return writeJson(res, 200, {
11134
+ permissionDecision: decision === "accept" ? "allow" : "deny",
11135
+ });
11136
+ }
11137
+
10274
11138
  if (url.pathname === "/api/inbox" && req.method === "GET") {
10275
11139
  const session = requireApiSession(req, res, config, state);
10276
11140
  if (!session) {
@@ -10414,6 +11278,9 @@ function createNativeApprovalServer({ config, runtime, state }) {
10414
11278
  approval.resolving = true;
10415
11279
  try {
10416
11280
  await handleNativeApprovalDecision({ config, runtime, state, approval, decision });
11281
+ if (approval.provider === "claude") {
11282
+ activateClaudeDesktopIfMac(req);
11283
+ }
10417
11284
  return writeJson(res, 200, { ok: true, decision });
10418
11285
  } catch (error) {
10419
11286
  approval.resolving = false;
@@ -10421,6 +11288,85 @@ function createNativeApprovalServer({ config, runtime, state }) {
10421
11288
  }
10422
11289
  }
10423
11290
 
11291
+ const apiQuestionAnswerMatch = url.pathname.match(/^\/api\/items\/question\/([^/]+)\/answer$/u);
11292
+ if (apiQuestionAnswerMatch && req.method === "POST") {
11293
+ const session = requireMutatingApiSession(req, res, config, state);
11294
+ if (!session) {
11295
+ return;
11296
+ }
11297
+
11298
+ const token = decodeURIComponent(apiQuestionAnswerMatch[1]);
11299
+ const approval = runtime.nativeApprovalsByToken.get(token);
11300
+ if (!approval) {
11301
+ return writeJson(res, 404, { error: "question-not-found" });
11302
+ }
11303
+ if (approval.kind !== "question") {
11304
+ return writeJson(res, 409, { error: "not-a-question" });
11305
+ }
11306
+ if (approval.resolved || approval.resolving) {
11307
+ return writeJson(res, 409, { error: "question-already-handled" });
11308
+ }
11309
+
11310
+ let payload;
11311
+ try {
11312
+ payload = await parseJsonBody(req);
11313
+ } catch {
11314
+ return writeJson(res, 400, { error: "invalid-json-body" });
11315
+ }
11316
+
11317
+ const answers = Array.isArray(payload?.answers) ? payload.answers : [];
11318
+ const reasonText = formatClaudeQuestionAnswers(approval.questions, answers);
11319
+ if (!reasonText) {
11320
+ return writeJson(res, 400, { error: "no-answers" });
11321
+ }
11322
+
11323
+ approval.resolving = true;
11324
+ try {
11325
+ if (approval.resolveClaudeWaiter) {
11326
+ approval.resolveClaudeWaiter({
11327
+ permissionDecision: "deny",
11328
+ permissionDecisionReason: reasonText,
11329
+ });
11330
+ }
11331
+ approval.resolved = true;
11332
+ approval.resolving = false;
11333
+ runtime.nativeApprovalsByToken.delete(approval.token);
11334
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
11335
+
11336
+ const stateChanged = recordActionHistoryItem({
11337
+ config,
11338
+ runtime,
11339
+ state,
11340
+ kind: "approval",
11341
+ stableId: `approval:${approval.requestKey}:${Date.now()}`,
11342
+ token: approval.token,
11343
+ title: approval.title,
11344
+ threadLabel: approval.threadLabel || "",
11345
+ messageText: reasonText,
11346
+ summary: t(config.defaultLocale, "server.message.questionAnswered", {
11347
+ provider: providerDisplayName(config.defaultLocale, approval.provider),
11348
+ }),
11349
+ fileRefs: [],
11350
+ diffText: "",
11351
+ diffSource: "",
11352
+ diffAvailable: false,
11353
+ diffAddedLines: 0,
11354
+ diffRemovedLines: 0,
11355
+ outcome: "answered",
11356
+ provider: approval.provider || "claude",
11357
+ });
11358
+ if (stateChanged) {
11359
+ await saveState(config.stateFile, state);
11360
+ }
11361
+ console.log(`[claude-question-answer] ${approval.requestKey}`);
11362
+ activateClaudeDesktopIfMac(req);
11363
+ return writeJson(res, 200, { ok: true });
11364
+ } catch (error) {
11365
+ approval.resolving = false;
11366
+ return writeJson(res, 500, { error: error.message });
11367
+ }
11368
+ }
11369
+
10424
11370
  const apiPlanDecisionMatch = url.pathname.match(/^\/api\/items\/plan\/([^/]+)\/(implement|decline)$/u);
10425
11371
  if (apiPlanDecisionMatch && req.method === "POST") {
10426
11372
  const session = requireMutatingApiSession(req, res, config, state);
@@ -10698,7 +11644,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
10698
11644
  title: userInputRequest.title,
10699
11645
  body: userInputRequest.testRequest
10700
11646
  ? `${t(config.defaultLocale, "server.message.choiceSubmittedTest")}\n\n${formatSubmittedTestAnswers(userInputRequest, userInputRequest.draftAnswers)}`
10701
- : t(config.defaultLocale, "server.message.choiceSubmitted"),
11647
+ : t(config.defaultLocale, "server.message.choiceSubmitted", { provider: providerDisplayName(config.defaultLocale, "codex") }),
10702
11648
  tone: "ok",
10703
11649
  })
10704
11650
  );
@@ -11211,6 +12157,28 @@ function writeJson(res, statusCode, body) {
11211
12157
  res.end(`${JSON.stringify(body)}\n`);
11212
12158
  }
11213
12159
 
12160
+ // Return focus to the Claude Desktop app when a Mac client just submitted a
12161
+ // plan / question / approval decision, so the user can resume in Claude
12162
+ // without manually switching apps. iOS / Android paired devices are skipped
12163
+ // (UA match). If Claude is not running, we do nothing — the AppleScript
12164
+ // `exists (process "Claude")` guard prevents an unwanted launch.
12165
+ function activateClaudeDesktopIfMac(req) {
12166
+ try {
12167
+ const ua = String(req?.headers?.["user-agent"] || "");
12168
+ if (/iPhone|iPad|iPod|Android/iu.test(ua)) return;
12169
+ if (!/Macintosh|Mac OS X/iu.test(ua)) return;
12170
+ const script = `tell application "System Events"
12171
+ if exists (processes where name is "Claude") then
12172
+ tell application "Claude" to activate
12173
+ end if
12174
+ end tell`;
12175
+ const child = spawn("osascript", ["-e", script], { stdio: "ignore" });
12176
+ child.unref();
12177
+ } catch {
12178
+ // Best effort.
12179
+ }
12180
+ }
12181
+
11214
12182
  function writeHtml(res, statusCode, html) {
11215
12183
  res.statusCode = statusCode;
11216
12184
  res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -11233,7 +12201,7 @@ function stopHttpServer(server) {
11233
12201
  });
11234
12202
  }
11235
12203
 
11236
- function renderApprovalPage({ eyebrow, title, messageText, token, basePath, actions, locale = DEFAULT_LOCALE }) {
12204
+ function renderApprovalPage({ eyebrow, title, messageText, token, basePath, actions, locale = DEFAULT_LOCALE, provider = "codex" }) {
11237
12205
  const messageHtml = renderMessageHtml(messageText, `<p>${escapeHtml(t(locale, "detail.approvalRequested"))}</p>`);
11238
12206
  const actionsHtml = actions
11239
12207
  .map(
@@ -11338,7 +12306,7 @@ function renderApprovalPage({ eyebrow, title, messageText, token, basePath, acti
11338
12306
  <div class="message">
11339
12307
  ${messageHtml}
11340
12308
  </div>
11341
- <p class="hint">${escapeHtml(t(locale, "summary.approval"))}</p>
12309
+ <p class="hint">${escapeHtml(t(locale, "summary.approval", { provider: providerDisplayName(locale, provider) }))}</p>
11342
12310
  <div class="actions">
11343
12311
  ${actionsHtml}
11344
12312
  <a class="button link" href="#" onclick="history.back(); return false;">${escapeHtml(t(locale, "common.back"))}</a>
@@ -11721,7 +12689,7 @@ function renderMessagePage({ eyebrow, title, messageText, locale = DEFAULT_LOCAL
11721
12689
  </html>`;
11722
12690
  }
11723
12691
 
11724
- function renderStatusPage({ title, body, tone }) {
12692
+ function renderStatusPage({ title, body, tone, provider = "codex", locale = DEFAULT_LOCALE }) {
11725
12693
  return `<!doctype html>
11726
12694
  <html lang="en">
11727
12695
  <head>
@@ -11784,7 +12752,7 @@ function renderStatusPage({ title, body, tone }) {
11784
12752
  </head>
11785
12753
  <body>
11786
12754
  <section class="card">
11787
- <span class="chip">Codex</span>
12755
+ <span class="chip">${escapeHtml(providerDisplayName(locale, provider))}</span>
11788
12756
  <h1>${escapeHtml(title)}</h1>
11789
12757
  <p>${escapeHtml(body)}</p>
11790
12758
  <p class="muted">このページは閉じて大丈夫です。</p>
@@ -11871,16 +12839,18 @@ function markdownMessageStyles() {
11871
12839
  }`;
11872
12840
  }
11873
12841
 
11874
- function approvalDecisionMessage(decision, locale = config?.defaultLocale || DEFAULT_LOCALE) {
12842
+ function approvalDecisionMessage(decision, locale = config?.defaultLocale || DEFAULT_LOCALE, provider = "codex") {
12843
+ const vars = { provider: providerDisplayName(locale, provider) };
11875
12844
  if (decision === "accept") {
11876
- return t(locale, "server.message.approvalAccepted");
12845
+ return t(locale, "server.message.approvalAccepted", vars);
11877
12846
  }
11878
- return t(locale, "server.message.approvalRejected");
12847
+ return t(locale, "server.message.approvalRejected", vars);
11879
12848
  }
11880
12849
 
11881
- function planDecisionMessage(decision, locale = config?.defaultLocale || DEFAULT_LOCALE) {
12850
+ function planDecisionMessage(decision, locale = config?.defaultLocale || DEFAULT_LOCALE, provider = "codex") {
12851
+ const vars = { provider: providerDisplayName(locale, provider) };
11882
12852
  if (decision === "implement") {
11883
- return t(locale, "server.message.planImplemented");
12853
+ return t(locale, "server.message.planImplemented", vars);
11884
12854
  }
11885
12855
  return t(locale, "server.message.planDismissed");
11886
12856
  }
@@ -11896,14 +12866,15 @@ function decisionLabel(decision, locale = config?.defaultLocale || DEFAULT_LOCAL
11896
12866
  return decision === "accept" ? t(locale, "server.action.approve") : t(locale, "server.action.reject");
11897
12867
  }
11898
12868
 
11899
- function approvalDecisionConfirm(decision, locale = config?.defaultLocale || DEFAULT_LOCALE) {
12869
+ function approvalDecisionConfirm(decision, locale = config?.defaultLocale || DEFAULT_LOCALE, provider = "codex") {
12870
+ const vars = { provider: providerDisplayName(locale, provider) };
11900
12871
  if (decision === "implement") {
11901
12872
  return t(locale, "server.confirm.planImplement");
11902
12873
  }
11903
12874
  if (decision === "accept") {
11904
- return t(locale, "server.confirm.approve");
12875
+ return t(locale, "server.confirm.approve", vars);
11905
12876
  }
11906
- return t(locale, "server.confirm.reject");
12877
+ return t(locale, "server.confirm.reject", vars);
11907
12878
  }
11908
12879
 
11909
12880
  function resolvePlanDecisionAnswer(planRequest, decision) {
@@ -12162,6 +13133,7 @@ function buildConfig(cli) {
12162
13133
  pairingToken: process.env.PAIRING_TOKEN || "",
12163
13134
  pairingExpiresAtMs: numberEnv("PAIRING_EXPIRES_AT_MS", 0),
12164
13135
  sessionSecret: process.env.SESSION_SECRET || "",
13136
+ claudeProjectsDir: resolvePath(process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), ".claude", "projects")),
12165
13137
  };
12166
13138
  }
12167
13139
 
@@ -12344,6 +13316,7 @@ async function loadState(stateFile) {
12344
13316
  historyFileSourceFile: cleanText(parsed.historyFileSourceFile ?? ""),
12345
13317
  pairingConsumedAt: Number(parsed.pairingConsumedAt) || 0,
12346
13318
  pairingConsumedCredential: cleanText(parsed.pairingConsumedCredential ?? ""),
13319
+ claudeAwayMode: parsed.claudeAwayMode === true,
12347
13320
  };
12348
13321
  } catch {
12349
13322
  return {
@@ -12370,10 +13343,30 @@ async function loadState(stateFile) {
12370
13343
  historyFileSourceFile: "",
12371
13344
  pairingConsumedAt: 0,
12372
13345
  pairingConsumedCredential: "",
13346
+ claudeAwayMode: false,
12373
13347
  };
12374
13348
  }
12375
13349
  }
12376
13350
 
13351
+ function claudeAwayModeSentinelPath(config) {
13352
+ const dir = config?.configDir || path.join(os.homedir(), ".viveworker");
13353
+ return path.join(dir, "claude-away-mode");
13354
+ }
13355
+
13356
+ async function syncClaudeAwayModeSentinel(config, enabled) {
13357
+ const sentinel = claudeAwayModeSentinelPath(config);
13358
+ try {
13359
+ if (enabled) {
13360
+ await fs.mkdir(path.dirname(sentinel), { recursive: true });
13361
+ await fs.writeFile(sentinel, "on\n", "utf8");
13362
+ } else {
13363
+ await fs.unlink(sentinel).catch(() => {});
13364
+ }
13365
+ } catch (error) {
13366
+ console.error(`[claude-away-mode-sentinel-error] ${error.message}`);
13367
+ }
13368
+ }
13369
+
12377
13370
  async function saveState(stateFile, state) {
12378
13371
  const output = JSON.stringify(state, null, 2);
12379
13372
  await fs.mkdir(path.dirname(stateFile), { recursive: true });
@@ -12772,7 +13765,7 @@ function getThreadName(sessionIndex, rolloutThreadLabels, threadId, cwd, filePat
12772
13765
  }
12773
13766
 
12774
13767
  function describeContext({ threadName }) {
12775
- const threadLabel = truncate(cleanText(threadName || ""), 90) || "Codex task";
13768
+ const threadLabel = truncate(cleanText(threadName || ""), 90) || t(DEFAULT_LOCALE, "server.fallback.codexTask", { provider: providerDisplayName(DEFAULT_LOCALE, "codex") });
12776
13769
  return { threadLabel };
12777
13770
  }
12778
13771
 
@@ -12855,10 +13848,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
12855
13848
  if (!threadId) {
12856
13849
  return entry;
12857
13850
  }
12858
- const nativeThreadLabel = getNativeThreadLabel({
13851
+ const claudeTitle = runtime.claudeSessionTitles.get(threadId) || "";
13852
+ const nativeThreadLabel = claudeTitle || getNativeThreadLabel({
12859
13853
  runtime,
12860
13854
  conversationId: threadId,
12861
- cwd: "",
13855
+ cwd: cleanText(entry.cwd || ""),
12862
13856
  });
12863
13857
  const threadLabel = preferTitleOnlyJsonThreadLabel(nativeThreadLabel, threadId, entry.messageText, entry.summary);
12864
13858
  const title = threadLabel || kindTitle(config.defaultLocale, entry.kind);
@@ -12890,10 +13884,11 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
12890
13884
  if (!threadId) {
12891
13885
  return entry;
12892
13886
  }
12893
- const nativeThreadLabel = getNativeThreadLabel({
13887
+ const claudeTitle = runtime.claudeSessionTitles.get(threadId) || "";
13888
+ const nativeThreadLabel = claudeTitle || getNativeThreadLabel({
12894
13889
  runtime,
12895
13890
  conversationId: threadId,
12896
- cwd: "",
13891
+ cwd: cleanText(entry.cwd || ""),
12897
13892
  });
12898
13893
  const threadLabel = preferTitleOnlyJsonThreadLabel(nativeThreadLabel, threadId, entry.messageText, entry.summary);
12899
13894
  const title = threadLabel || kindTitle(config.defaultLocale, entry.kind);
@@ -13301,7 +14296,7 @@ async function main() {
13301
14296
 
13302
14297
  console.log(
13303
14298
  [
13304
- "Codex ntfy bridge",
14299
+ "viveworker bridge",
13305
14300
  `dryRun=${config.dryRun}`,
13306
14301
  `once=${config.once}`,
13307
14302
  `codexHome=${config.codexHome}`,