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.
- package/README.md +27 -8
- package/package.json +7 -2
- package/scripts/viveworker-bridge.mjs +1146 -151
- package/scripts/viveworker-claude-hook.mjs +839 -0
- package/scripts/viveworker.mjs +71 -0
- package/web/app.css +171 -0
- package/web/app.js +523 -59
- package/web/i18n.js +115 -71
|
@@ -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
|
-
|
|
3496
|
-
|
|
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) || "
|
|
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
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
|
|
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
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
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:
|
|
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
|
|
8645
|
-
latestChangeType:
|
|
8646
|
-
latestChangeFileRefs
|
|
8647
|
-
diffAddedLines
|
|
8648
|
-
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
|
-
|
|
9369
|
+
const approvalKind = cleanText(approval.kind || "");
|
|
9370
|
+
const detail = {
|
|
8852
9371
|
kind: "approval",
|
|
8853
|
-
approvalKind
|
|
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:
|
|
8867
|
-
actions:
|
|
8868
|
-
|
|
8869
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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) || "
|
|
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
|
|
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
|
|
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
|
-
"
|
|
14299
|
+
"viveworker bridge",
|
|
13305
14300
|
`dryRun=${config.dryRun}`,
|
|
13306
14301
|
`once=${config.once}`,
|
|
13307
14302
|
`codexHome=${config.codexHome}`,
|