viveworker 0.7.0-beta.0 → 0.7.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,18 +12,35 @@ import process from "node:process";
12
12
  import { createInterface } from "node:readline";
13
13
  import { inspect } from "node:util";
14
14
  import { fileURLToPath } from "node:url";
15
+ import zlib from "node:zlib";
15
16
  import webPush from "web-push";
17
+ import * as hazbaseAuth from "@hazbase/auth";
16
18
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "../web/i18n.js";
17
19
  import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
18
20
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
19
21
  import { buildAgentCard, handleA2ARequest, resolveA2ATaskDecision, completeA2ATask, failA2ATask } from "./a2a-handler.mjs";
20
22
  import { registerWithRelay, startRelayPolling, stopRelayPolling, postRelayResult, getRelayStatus, updatePublicTasksFlag } from "./a2a-relay-client.mjs";
21
- import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, listInboxItems } from "./moltbook-api.mjs";
23
+ import { createMoltbookClient, readScoutState, writeScoutState, rollScoutDayIfNeeded, markPostSeen, recordComposeAttempt, writeDraft, readDraft, deleteDraft, listPendingDrafts, solveVerificationPuzzle, solvePuzzleWithLLM, recordPuzzleAttempt, listInboxItems } from "./moltbook-api.mjs";
22
24
 
23
25
  const __filename = fileURLToPath(import.meta.url);
24
26
  const __dirname = path.dirname(__filename);
25
27
  const workspaceRoot = path.resolve(__dirname, "..");
26
28
  const webRoot = path.join(workspaceRoot, "web");
29
+ const {
30
+ bootstrapPasskeyAccount,
31
+ completePasskeyAssertion,
32
+ completePasskeyRegistration,
33
+ listSupportedChains,
34
+ requestEmailOtp: requestHazbaseEmailOtp,
35
+ requestPasskeyAssertionChallenge,
36
+ requestPasskeyRegistrationChallenge,
37
+ setApiEndpoint: setHazbaseApiEndpoint,
38
+ setClientKey: setHazbaseClientKey,
39
+ verifyEmailOtp: verifyHazbaseEmailOtp,
40
+ } = hazbaseAuth;
41
+ const listSupportedPayments = typeof hazbaseAuth.listSupportedPayments === "function"
42
+ ? hazbaseAuth.listSupportedPayments.bind(hazbaseAuth)
43
+ : async () => ({ networks: [], defaultNetwork: "base-sepolia" });
27
44
  const appPackageVersion = readPackageVersion();
28
45
  const sessionCookieName = "viveworker_session";
29
46
  const deviceCookieName = "viveworker_device";
@@ -38,6 +55,153 @@ const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
38
55
  const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
39
56
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
40
57
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
58
+ const HAZBASE_METADATA_TIMEOUT_MS = 1500;
59
+
60
+ // Shared memo for buildDiffThreadGroups. Each call spawns 3 git subprocesses
61
+ // per tracked repo (`git diff --name-status`, `git status --porcelain`,
62
+ // `git diff`) plus a `git rev-parse` per unique dir — roughly 10+ subprocess
63
+ // spawns for a typical multi-repo state. Both /api/inbox/diff and
64
+ // /api/items/diff_thread/:token call this function; when the user taps a
65
+ // detail right after the inbox finished loading, the computation is
66
+ // duplicated unnecessarily. Short TTL + explicit invalidation when
67
+ // recentCodeEvents mutates keeps the result fresh without the recompute cost.
68
+ // Hoisted here so code-event migration at startup can call
69
+ // invalidateDiffThreadGroupsCache() before any of the use sites below
70
+ // execute, avoiding a temporal-dead-zone ReferenceError on `let`.
71
+ const DIFF_THREAD_GROUPS_CACHE_TTL_MS = 3000;
72
+ let diffThreadGroupsCache = null;
73
+ let diffThreadGroupsPending = null;
74
+
75
+ function invalidateDiffThreadGroupsCache() {
76
+ diffThreadGroupsCache = null;
77
+ // Don't touch diffThreadGroupsPending — an in-flight build reflects the
78
+ // state at the time it started, and callers racing the rebuild would
79
+ // otherwise pile up their own subprocess spawns.
80
+ }
81
+
82
+ async function getDiffThreadGroupsCached(runtime, state, config) {
83
+ const now = Date.now();
84
+ if (
85
+ diffThreadGroupsCache &&
86
+ now - diffThreadGroupsCache.computedAtMs < DIFF_THREAD_GROUPS_CACHE_TTL_MS
87
+ ) {
88
+ return diffThreadGroupsCache.groups;
89
+ }
90
+ if (diffThreadGroupsPending) {
91
+ return diffThreadGroupsPending;
92
+ }
93
+ diffThreadGroupsPending = (async () => {
94
+ try {
95
+ const groups = await buildDiffThreadGroups(runtime, state, config);
96
+ diffThreadGroupsCache = { groups, computedAtMs: Date.now() };
97
+ return groups;
98
+ } finally {
99
+ diffThreadGroupsPending = null;
100
+ }
101
+ })();
102
+ return diffThreadGroupsPending;
103
+ }
104
+
105
+ // Coalesced state persistence. The full state JSON is ~8 MB on disk (dominated
106
+ // by `recentCodeEvents` and `recentTimelineEntries` caches); JSON.stringify
107
+ // blocks the event loop ~70 ms and fs.writeFile takes another ~80 ms. Before
108
+ // this helper, every tiny mutation (push subscribe/unsubscribe/test, locale
109
+ // change, claude-away toggle, A2A toggles, …) awaited a ~150 ms
110
+ // serialize+write before the HTTP response fired — and the event-loop block
111
+ // delayed every concurrent request on the same server too. That's what made
112
+ // the settings/notification buttons feel sluggish: the click → visible
113
+ // response round-trip paid the cost of re-serializing 5 MB of code-event
114
+ // history every time.
115
+ //
116
+ // `scheduleSaveState` queues a write within SAVE_STATE_DEBOUNCE_MS and returns
117
+ // synchronously, so HTTP handlers can respond immediately after mutating
118
+ // in-memory state. Multiple mutations within the window coalesce into one
119
+ // write. `flushPendingStateWrite` drains on graceful shutdown.
120
+ //
121
+ // Durability trade-off: at most ~SAVE_STATE_MAX_DELAY_MS of in-memory
122
+ // mutations can be lost on a hard crash. Callers that require stronger
123
+ // guarantees (pairing, revocation, the periodic scan loop) still `await
124
+ // saveState(...)` directly — only the hot settings/notification paths opt in
125
+ // to the coalescer.
126
+ const SAVE_STATE_DEBOUNCE_MS = 500;
127
+ const SAVE_STATE_MAX_DELAY_MS = 1500;
128
+ let saveStateTimer = null;
129
+ let saveStateFirstScheduleAtMs = 0;
130
+ let saveStateInFlight = null;
131
+ let saveStateDirty = false;
132
+
133
+ function scheduleSaveState(config, state) {
134
+ if (!config?.stateFile) return;
135
+ if (saveStateInFlight) {
136
+ // An in-flight write already reflects the current (or earlier) state.
137
+ // Mark dirty so the .finally handler starts another write when it
138
+ // finishes — overlapping writes would just stomp on each other.
139
+ saveStateDirty = true;
140
+ return;
141
+ }
142
+ const now = Date.now();
143
+ if (!saveStateFirstScheduleAtMs) {
144
+ saveStateFirstScheduleAtMs = now;
145
+ }
146
+ if (saveStateTimer) {
147
+ clearTimeout(saveStateTimer);
148
+ }
149
+ const elapsed = now - saveStateFirstScheduleAtMs;
150
+ const remaining = SAVE_STATE_MAX_DELAY_MS - elapsed;
151
+ const delay = Math.max(0, Math.min(SAVE_STATE_DEBOUNCE_MS, remaining));
152
+ saveStateTimer = setTimeout(() => {
153
+ saveStateTimer = null;
154
+ saveStateFirstScheduleAtMs = 0;
155
+ startSaveStateFlush(config, state);
156
+ }, delay);
157
+ }
158
+
159
+ function startSaveStateFlush(config, state) {
160
+ saveStateDirty = false;
161
+ const promise = (async () => {
162
+ try {
163
+ await saveState(config.stateFile, state);
164
+ } catch (error) {
165
+ console.error(`[save-state-error] ${error.message}`);
166
+ }
167
+ })();
168
+ saveStateInFlight = promise;
169
+ promise.finally(() => {
170
+ if (saveStateInFlight === promise) {
171
+ saveStateInFlight = null;
172
+ }
173
+ if (saveStateDirty) {
174
+ // A mutation arrived mid-flight. Start the follow-up write
175
+ // synchronously — we've already held the original debounce window,
176
+ // so tacking on another 500 ms would defeat coalescing. Going
177
+ // through scheduleSaveState() instead would also introduce a timer
178
+ // that flushPendingStateWrite() would have to know about.
179
+ saveStateDirty = false;
180
+ startSaveStateFlush(config, state);
181
+ }
182
+ });
183
+ }
184
+
185
+ async function flushPendingStateWrite(config, state) {
186
+ // Promote any pending debounce timer to an immediate flush.
187
+ if (saveStateTimer) {
188
+ clearTimeout(saveStateTimer);
189
+ saveStateTimer = null;
190
+ saveStateFirstScheduleAtMs = 0;
191
+ startSaveStateFlush(config, state);
192
+ }
193
+ // Drain the in-flight write plus any cascades triggered by saveStateDirty.
194
+ // Bounded so a pathological re-entrancy bug can't hang shutdown.
195
+ for (let iter = 0; iter < 4 && (saveStateInFlight || saveStateDirty); iter += 1) {
196
+ if (saveStateInFlight) {
197
+ await saveStateInFlight.catch(() => {});
198
+ }
199
+ if (saveStateDirty && !saveStateInFlight) {
200
+ saveStateDirty = false;
201
+ startSaveStateFlush(config, state);
202
+ }
203
+ }
204
+ }
41
205
 
42
206
  const cli = parseCliArgs(process.argv.slice(2));
43
207
  const envFile = resolveEnvFile(cli.envFile);
@@ -47,6 +211,7 @@ await maybeRotateStartupPairingEnv(envFile);
47
211
 
48
212
  const config = buildConfig(cli);
49
213
  validateConfig(config);
214
+ configureHazbaseClient(config);
50
215
 
51
216
  const runtime = {
52
217
  fileStates: new Map(),
@@ -165,6 +330,7 @@ const backfilledAmbientSuggestionsStateChanged = backfillAmbientSuggestionsState
165
330
  const migratedRecentCodeEventsStateChanged = migrateRecentCodeEventsState({ config, runtime, state });
166
331
  const restoredTimelineImagePathsStateChanged = await backfillPersistedTimelineImagePaths({ config, runtime, state });
167
332
  const backfilledMoltbookInboxChanged = await backfillMoltbookInboxHistory({ config, runtime, state });
333
+ const recoveredMissingProviderStateChanged = await recoverMissingProviderStateFromBackup({ config, runtime, state });
168
334
  runtime.historyFileState.offset = Number(state.historyFileOffset) || 0;
169
335
  runtime.historyFileState.sourceFile = cleanText(state.historyFileSourceFile ?? "");
170
336
 
@@ -263,8 +429,23 @@ function clearDeviceLocaleOverride(state, deviceId) {
263
429
  return true;
264
430
  }
265
431
 
432
+ function autoPilotWriteLaneState(state) {
433
+ return {
434
+ content: state?.autoPilotWriteLaneContent === true,
435
+ uiTests: state?.autoPilotWriteLaneUiTests === true,
436
+ source: state?.autoPilotWriteLaneSource === true,
437
+ };
438
+ }
439
+
440
+ function hasAnyAutoPilotWriteLaneEnabled(state) {
441
+ const lanes = autoPilotWriteLaneState(state);
442
+ return lanes.content || lanes.uiTests || lanes.source;
443
+ }
444
+
266
445
  function buildSessionLocalePayload(config, state, deviceId) {
267
446
  const resolved = resolveDeviceLocaleInfo(config, state, deviceId);
447
+ const writeLanes = autoPilotWriteLaneState(state);
448
+ const trustedWritesEnabled = hasAnyAutoPilotWriteLaneEnabled(state);
268
449
  return {
269
450
  locale: resolved.locale,
270
451
  localeSource: resolved.source,
@@ -273,6 +454,12 @@ function buildSessionLocalePayload(config, state, deviceId) {
273
454
  deviceDetectedLocale: resolved.detectedLocale || null,
274
455
  deviceOverrideLocale: resolved.overrideLocale || null,
275
456
  claudeAwayMode: state?.claudeAwayMode === true,
457
+ autoPilotTrustedReads: state?.autoPilotTrustedReads === true,
458
+ autoPilotTrustedWrites: trustedWritesEnabled,
459
+ autoPilotTrustedWritesShadow: false,
460
+ autoPilotWriteLaneContent: writeLanes.content,
461
+ autoPilotWriteLaneUiTests: writeLanes.uiTests,
462
+ autoPilotWriteLaneSource: writeLanes.source,
276
463
  moltbookEnabled: Boolean(config.moltbookApiKey),
277
464
  a2aEnabled: Boolean(config.a2aApiKey),
278
465
  a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
@@ -770,6 +957,425 @@ function tokenizeShellWords(commandText) {
770
957
  return tokens;
771
958
  }
772
959
 
960
+ function isPathWithinRoot(rootPath, candidatePath) {
961
+ const normalizedRoot = cleanText(rootPath || "");
962
+ const normalizedCandidate = cleanText(candidatePath || "");
963
+ if (!normalizedRoot || !normalizedCandidate) {
964
+ return false;
965
+ }
966
+ const relativePath = path.relative(normalizedRoot, normalizedCandidate);
967
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
968
+ }
969
+
970
+ function isSensitiveCommandPath(candidatePath) {
971
+ const normalized = cleanText(candidatePath || "");
972
+ if (!normalized) {
973
+ return true;
974
+ }
975
+ const lower = normalized.toLowerCase();
976
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
977
+ const basename = segments[segments.length - 1] || "";
978
+ if (segments.some((segment) => [".ssh", ".aws", ".gnupg", ".azure", ".kube"].includes(segment))) {
979
+ return true;
980
+ }
981
+ if (basename === ".npmrc" || basename === ".netrc" || basename === ".env" || basename.startsWith(".env.")) {
982
+ return true;
983
+ }
984
+ if (basename.endsWith(".pem") || basename.endsWith(".key") || basename.endsWith(".p12") || basename.endsWith(".pfx")) {
985
+ return true;
986
+ }
987
+ if (/^id_[a-z0-9._-]+$/iu.test(basename) || basename.includes("secret") || basename.includes("credential")) {
988
+ return true;
989
+ }
990
+ return false;
991
+ }
992
+
993
+ function resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }) {
994
+ const rawToken = cleanText(token || "");
995
+ const normalizedCwd = cleanText(cwd || "");
996
+ const normalizedWorkspaceRoot = cleanText(workspaceRoot || normalizedCwd);
997
+ if (!rawToken || !normalizedCwd || !normalizedWorkspaceRoot) {
998
+ return null;
999
+ }
1000
+ if (rawToken === "-" || rawToken.startsWith("~") || rawToken.startsWith("http://") || rawToken.startsWith("https://")) {
1001
+ return null;
1002
+ }
1003
+ const resolved = path.resolve(normalizedCwd, rawToken);
1004
+ if (!isPathWithinRoot(normalizedWorkspaceRoot, resolved)) {
1005
+ return null;
1006
+ }
1007
+ if (isSensitiveCommandPath(resolved)) {
1008
+ return null;
1009
+ }
1010
+ return resolved;
1011
+ }
1012
+
1013
+ function isGitObjectPathToken(token) {
1014
+ const normalized = cleanText(token || "");
1015
+ if (!normalized || normalized.startsWith("-")) {
1016
+ return false;
1017
+ }
1018
+ return /^[^:\s][^:\s]*:.+/u.test(normalized);
1019
+ }
1020
+
1021
+ function isLiteralGitPathspecToken(token) {
1022
+ const normalized = cleanText(token || "");
1023
+ if (!normalized || normalized === "--" || normalized.startsWith(":(") || normalized.startsWith(":/")) {
1024
+ return false;
1025
+ }
1026
+ if (/[*?[\]{}]/u.test(normalized)) {
1027
+ return false;
1028
+ }
1029
+ return !isGitObjectPathToken(normalized);
1030
+ }
1031
+
1032
+ function isAllowedGitMetadataOnlyFlag(token) {
1033
+ const normalized = cleanText(token || "");
1034
+ if (!normalized) {
1035
+ return false;
1036
+ }
1037
+ return [
1038
+ "--stat",
1039
+ "--shortstat",
1040
+ "--numstat",
1041
+ "--name-only",
1042
+ "--name-status",
1043
+ "--summary",
1044
+ "--compact-summary",
1045
+ "--oneline",
1046
+ "--no-patch",
1047
+ "-s",
1048
+ ].includes(normalized);
1049
+ }
1050
+
1051
+ function hasDisallowedGitPatchOutput(tokens) {
1052
+ return tokens.some((token) => {
1053
+ const normalized = cleanText(token || "");
1054
+ if (!normalized) {
1055
+ return false;
1056
+ }
1057
+ return (
1058
+ normalized === "-p" ||
1059
+ normalized === "--patch" ||
1060
+ normalized === "--patch-with-stat" ||
1061
+ normalized === "--raw" ||
1062
+ normalized === "--cc" ||
1063
+ normalized === "--combined-all-paths" ||
1064
+ normalized === "--binary" ||
1065
+ normalized === "--word-diff" ||
1066
+ normalized.startsWith("--word-diff=") ||
1067
+ normalized === "-U" ||
1068
+ normalized.startsWith("-U") ||
1069
+ normalized === "--unified" ||
1070
+ normalized.startsWith("--unified=")
1071
+ );
1072
+ });
1073
+ }
1074
+
1075
+ function extractGitPathspecTokens(tokens) {
1076
+ const separatorIndex = tokens.indexOf("--");
1077
+ if (separatorIndex < 0) {
1078
+ return [];
1079
+ }
1080
+ return tokens.slice(separatorIndex + 1).filter(Boolean);
1081
+ }
1082
+
1083
+ function resolveTrustedDiffSectionPath({ token, cwd, workspaceRoot }) {
1084
+ const normalizedToken = cleanText(token || "");
1085
+ if (!normalizedToken) {
1086
+ return null;
1087
+ }
1088
+ const preferredWorkspace = resolveTrustedReadTargetPath({
1089
+ token: normalizedToken,
1090
+ cwd: workspaceRoot,
1091
+ workspaceRoot,
1092
+ });
1093
+ if (preferredWorkspace) {
1094
+ return preferredWorkspace;
1095
+ }
1096
+ return resolveTrustedReadTargetPath({
1097
+ token: normalizedToken,
1098
+ cwd,
1099
+ workspaceRoot,
1100
+ });
1101
+ }
1102
+
1103
+ function extractTrustedReadFileRefs(command, tokens, cwd, workspaceRoot) {
1104
+ const normalizedCommand = cleanText(command || "");
1105
+ if (!normalizedCommand) {
1106
+ return null;
1107
+ }
1108
+
1109
+ if (normalizedCommand === "pwd" || (normalizedCommand === "git" && cleanText(tokens[1] || "") === "status")) {
1110
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1111
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1112
+ }
1113
+
1114
+ if (normalizedCommand === "git") {
1115
+ const gitSubcommand = cleanText(tokens[1] || "");
1116
+ if (!["diff", "show"].includes(gitSubcommand)) {
1117
+ return null;
1118
+ }
1119
+ if (tokens.some((token) => token === "-C" || token.startsWith("-C") || token === "--output" || token.startsWith("--output="))) {
1120
+ return null;
1121
+ }
1122
+ const commandArgs = tokens.slice(2);
1123
+ if (commandArgs.some((token) => isGitObjectPathToken(token))) {
1124
+ return null;
1125
+ }
1126
+ const explicitPathspecs = extractGitPathspecTokens(tokens);
1127
+ if (explicitPathspecs.length > 0) {
1128
+ if (explicitPathspecs.some((token) => !isLiteralGitPathspecToken(token))) {
1129
+ return null;
1130
+ }
1131
+ const resolvedPaths = explicitPathspecs
1132
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1133
+ .filter(Boolean);
1134
+ return resolvedPaths.length === explicitPathspecs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1135
+ }
1136
+ if (hasDisallowedGitPatchOutput(commandArgs)) {
1137
+ return null;
1138
+ }
1139
+ const metadataOnly = commandArgs.some((token) => isAllowedGitMetadataOnlyFlag(token));
1140
+ if (!metadataOnly) {
1141
+ return null;
1142
+ }
1143
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1144
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1145
+ }
1146
+
1147
+ if (normalizedCommand === "ls") {
1148
+ const pathArgs = tokens.slice(1).filter((token) => !String(token || "").startsWith("-"));
1149
+ if (pathArgs.length === 0) {
1150
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1151
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1152
+ }
1153
+ const resolvedPaths = pathArgs
1154
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1155
+ .filter(Boolean);
1156
+ return resolvedPaths.length === pathArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1157
+ }
1158
+
1159
+ if (normalizedCommand === "find") {
1160
+ if (tokens.some((token) => ["-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprintf", "-fls"].includes(token))) {
1161
+ return null;
1162
+ }
1163
+ const pathArgs = [];
1164
+ for (const token of tokens.slice(1)) {
1165
+ if (!token || token === "--") continue;
1166
+ if (token === "!" || token === "(" || token === ")") break;
1167
+ if (token.startsWith("-")) break;
1168
+ pathArgs.push(token);
1169
+ }
1170
+ const effectivePaths = pathArgs.length ? pathArgs : ["."];
1171
+ const resolvedPaths = effectivePaths
1172
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1173
+ .filter(Boolean);
1174
+ return resolvedPaths.length === effectivePaths.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1175
+ }
1176
+
1177
+ if (normalizedCommand === "sed") {
1178
+ if (!tokens.includes("-n") || tokens.includes("-i") || tokens.includes("--in-place")) {
1179
+ return null;
1180
+ }
1181
+ let script = "";
1182
+ const fileArgs = [];
1183
+ for (let index = 1; index < tokens.length; index += 1) {
1184
+ const token = tokens[index];
1185
+ if (!token) continue;
1186
+ if (!script) {
1187
+ if (token === "-e") {
1188
+ script = tokens[index + 1] || "";
1189
+ index += 1;
1190
+ continue;
1191
+ }
1192
+ if (token.startsWith("-")) {
1193
+ continue;
1194
+ }
1195
+ script = token;
1196
+ continue;
1197
+ }
1198
+ if (!token.startsWith("-")) {
1199
+ fileArgs.push(token);
1200
+ }
1201
+ }
1202
+ if (!script || !/^[0-9,$; p-]+$/u.test(script) || fileArgs.length === 0) {
1203
+ return null;
1204
+ }
1205
+ const resolvedPaths = fileArgs
1206
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1207
+ .filter(Boolean);
1208
+ return resolvedPaths.length === fileArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1209
+ }
1210
+
1211
+ if (normalizedCommand === "rg") {
1212
+ let seenPattern = false;
1213
+ const fileArgs = [];
1214
+ for (const token of tokens.slice(1)) {
1215
+ if (!seenPattern) {
1216
+ if (token === "--") {
1217
+ seenPattern = true;
1218
+ continue;
1219
+ }
1220
+ if (String(token || "").startsWith("-")) {
1221
+ continue;
1222
+ }
1223
+ seenPattern = true;
1224
+ continue;
1225
+ }
1226
+ if (!String(token || "").startsWith("-")) {
1227
+ fileArgs.push(token);
1228
+ }
1229
+ }
1230
+ const effectivePaths = fileArgs.length ? fileArgs : ["."];
1231
+ const resolvedPaths = effectivePaths
1232
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1233
+ .filter(Boolean);
1234
+ return resolvedPaths.length === effectivePaths.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1235
+ }
1236
+
1237
+ if (["cat", "nl", "head", "tail", "wc"].includes(normalizedCommand)) {
1238
+ const fileArgs = tokens.slice(1).filter((token) => !String(token || "").startsWith("-"));
1239
+ if (fileArgs.length === 0) {
1240
+ return null;
1241
+ }
1242
+ const resolvedPaths = fileArgs
1243
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1244
+ .filter(Boolean);
1245
+ return resolvedPaths.length === fileArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1246
+ }
1247
+
1248
+ return null;
1249
+ }
1250
+
1251
+ function stripAllowedTrustedReadRedirections(tokens) {
1252
+ const filtered = [];
1253
+ for (let index = 0; index < tokens.length; index += 1) {
1254
+ const token = String(tokens[index] || "");
1255
+ if (!token) {
1256
+ continue;
1257
+ }
1258
+ if (token === "2>" || token === "2>>") {
1259
+ const target = String(tokens[index + 1] || "");
1260
+ if (target !== "/dev/null") {
1261
+ return null;
1262
+ }
1263
+ index += 1;
1264
+ continue;
1265
+ }
1266
+ if (/^2>>?\/dev\/null$/u.test(token)) {
1267
+ continue;
1268
+ }
1269
+ if (token.includes(">") || token.includes("<")) {
1270
+ return null;
1271
+ }
1272
+ filtered.push(token);
1273
+ }
1274
+ return filtered;
1275
+ }
1276
+
1277
+ function isAllowedTrustedReadPostProcessStage(stageTokens) {
1278
+ if (!Array.isArray(stageTokens) || stageTokens.length === 0) {
1279
+ return false;
1280
+ }
1281
+ const [command, ...args] = stageTokens.map((token) => cleanText(token || ""));
1282
+ if (!command) {
1283
+ return false;
1284
+ }
1285
+ if (command === "head" || command === "tail") {
1286
+ if (args.length === 0) {
1287
+ return true;
1288
+ }
1289
+ if (args.length === 1 && /^-\d+$/u.test(args[0])) {
1290
+ return true;
1291
+ }
1292
+ if (args.length === 2 && args[0] === "-n" && /^\d+$/u.test(args[1])) {
1293
+ return true;
1294
+ }
1295
+ return false;
1296
+ }
1297
+ if (command === "wc") {
1298
+ return args.length === 0 || (args.length === 1 && args[0] === "-l");
1299
+ }
1300
+ return false;
1301
+ }
1302
+
1303
+ function normalizeTrustedReadTokens(tokens) {
1304
+ const strippedTokens = stripAllowedTrustedReadRedirections(tokens);
1305
+ if (!strippedTokens || strippedTokens.length === 0) {
1306
+ return null;
1307
+ }
1308
+ if (strippedTokens.some((token) => {
1309
+ const value = String(token || "");
1310
+ return (
1311
+ ["||", "&&", ";", "&"].includes(value) ||
1312
+ value.includes("`") ||
1313
+ value.includes("$(")
1314
+ );
1315
+ })) {
1316
+ return null;
1317
+ }
1318
+ const stages = [];
1319
+ let currentStage = [];
1320
+ for (const token of strippedTokens) {
1321
+ if (token === "|") {
1322
+ if (currentStage.length === 0) {
1323
+ return null;
1324
+ }
1325
+ stages.push(currentStage);
1326
+ currentStage = [];
1327
+ continue;
1328
+ }
1329
+ currentStage.push(token);
1330
+ }
1331
+ if (currentStage.length === 0) {
1332
+ return null;
1333
+ }
1334
+ stages.push(currentStage);
1335
+ if (stages.length === 0) {
1336
+ return null;
1337
+ }
1338
+ if (stages.slice(1).some((stageTokens) => !isAllowedTrustedReadPostProcessStage(stageTokens))) {
1339
+ return null;
1340
+ }
1341
+ return stages[0];
1342
+ }
1343
+
1344
+ function evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot }) {
1345
+ const normalizedCwd = cleanText(cwd || "");
1346
+ const normalizedWorkspaceRoot = cleanText(workspaceRoot || normalizedCwd);
1347
+ const normalizedCommandText = unwrapShellCommand(commandText);
1348
+ const rawTokens = tokenizeShellWords(normalizedCommandText);
1349
+ if (!normalizedCommandText || !normalizedCwd || !normalizedWorkspaceRoot || rawTokens.length === 0) {
1350
+ return null;
1351
+ }
1352
+
1353
+ if (!isPathWithinRoot(normalizedWorkspaceRoot, path.resolve(normalizedCwd))) {
1354
+ return null;
1355
+ }
1356
+
1357
+ const tokens = normalizeTrustedReadTokens(rawTokens);
1358
+ if (!tokens || tokens.length === 0) {
1359
+ return null;
1360
+ }
1361
+
1362
+ const command = cleanText(tokens[0]);
1363
+ if (!command || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(command)) {
1364
+ return null;
1365
+ }
1366
+
1367
+ const fileRefs = extractTrustedReadFileRefs(command, tokens, normalizedCwd, normalizedWorkspaceRoot);
1368
+ if (!fileRefs) {
1369
+ return null;
1370
+ }
1371
+
1372
+ return {
1373
+ command,
1374
+ commandText: normalizedCommandText,
1375
+ fileRefs,
1376
+ };
1377
+ }
1378
+
773
1379
  function unwrapShellCommand(commandText) {
774
1380
  const normalized = String(commandText || "").trim();
775
1381
  if (!normalized) {
@@ -848,6 +1454,14 @@ function extractReadFileRefsFromCommand(commandText) {
848
1454
  return [];
849
1455
  }
850
1456
 
1457
+ function autoPilotApprovalMessage(locale, commandText) {
1458
+ const prefix = t(locale, "server.message.autoPilotTrustedReadApproved");
1459
+ const commandBlock = cleanText(commandText || "")
1460
+ ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${cleanText(commandText || "")}\n\`\`\``
1461
+ : "";
1462
+ return [prefix, commandBlock].filter(Boolean).join("\n\n");
1463
+ }
1464
+
851
1465
  function extractUpdatedFileRefsByType(outputText, patchText = "") {
852
1466
  const parsedSections = parseApplyPatchSections(patchText);
853
1467
  if (parsedSections.length > 0) {
@@ -2209,15 +2823,23 @@ function normalizeHistoryItems(rawItems, maxItems) {
2209
2823
  .sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0));
2210
2824
  const deduped = [];
2211
2825
  const seen = new Set();
2826
+ const perProviderCount = {};
2827
+ const knownProviders = new Set(["codex", "claude", "moltbook", "a2a", "viveworker"]);
2212
2828
  for (const item of normalized) {
2213
2829
  if (seen.has(item.stableId)) {
2214
2830
  continue;
2215
2831
  }
2832
+ const provider = normalizeProvider(item.provider);
2833
+ if (!knownProviders.has(provider)) {
2834
+ knownProviders.add(provider);
2835
+ }
2836
+ const providerCount = perProviderCount[provider] ?? 0;
2837
+ if (providerCount >= maxItems) {
2838
+ continue;
2839
+ }
2216
2840
  seen.add(item.stableId);
2217
2841
  deduped.push(item);
2218
- if (deduped.length >= maxItems) {
2219
- break;
2220
- }
2842
+ perProviderCount[provider] = providerCount + 1;
2221
2843
  }
2222
2844
  return deduped;
2223
2845
  }
@@ -2226,6 +2848,9 @@ function normalizeHistoryItem(raw) {
2226
2848
  if (!isPlainObject(raw)) {
2227
2849
  return null;
2228
2850
  }
2851
+ if (shouldHideClaudeInternalItem(raw)) {
2852
+ return null;
2853
+ }
2229
2854
 
2230
2855
  const stableId = cleanText(raw.stableId ?? raw.id ?? "");
2231
2856
  const kind = cleanText(raw.kind ?? "");
@@ -2237,8 +2862,10 @@ function normalizeHistoryItem(raw) {
2237
2862
  const threadLabel = skipHistoryThreadLabelRewrite
2238
2863
  ? rawThreadLabel
2239
2864
  : preferTitleOnlyJsonThreadLabel(rawThreadLabel, threadId, raw.messageText, raw.summary, raw.detailText, raw.message);
2865
+ const rawTitle = cleanText(raw.title ?? "");
2240
2866
  const title =
2241
- cleanText(raw.title ?? "") || (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2867
+ (!isFallbackTimelineTitle(rawTitle, kind, threadId) ? rawTitle : "") ||
2868
+ (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2242
2869
  const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
2243
2870
  const summary = normalizeNotificationText(raw.summary ?? "") || formatNotificationBody(messageText, 100) || "";
2244
2871
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
@@ -2248,7 +2875,7 @@ function normalizeHistoryItem(raw) {
2248
2875
 
2249
2876
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
2250
2877
 
2251
- return {
2878
+ const normalized = {
2252
2879
  stableId,
2253
2880
  token: cleanText(raw.token ?? "") || historyToken(stableId),
2254
2881
  kind,
@@ -2276,6 +2903,7 @@ function normalizeHistoryItem(raw) {
2276
2903
  ...(raw.instruction != null ? { instruction: cleanText(raw.instruction) } : {}),
2277
2904
  ...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
2278
2905
  };
2906
+ return shouldHideClaudeInternalItem(normalized) ? null : normalized;
2279
2907
  }
2280
2908
 
2281
2909
  function historyToken(stableId) {
@@ -2305,10 +2933,41 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2305
2933
  const perProviderCount = {};
2306
2934
  const knownProviders = new Set(["codex", "claude", "moltbook", "a2a", "viveworker"]);
2307
2935
  const saturatedProviders = new Set();
2936
+ // Content-based dedup for user_message entries: the same logical user turn
2937
+ // can surface through two independent readers (rollout session files and
2938
+ // ~/.codex/history.jsonl) that compute different stableIds
2939
+ // (`user_message:<threadId>:<turn_id|ISO-ts>` vs
2940
+ // `user_message:<threadId>:<integer-id|unix-seconds>`), so the stableId-only
2941
+ // filter above doesn't catch them. Collapse entries with the same
2942
+ // (threadId, messageText) pair when their createdAtMs are within 60 s —
2943
+ // wide enough to absorb rollout-vs-history-file flush skew (we've seen
2944
+ // several seconds in the wild) but narrow enough not to collapse a genuine
2945
+ // repeat (e.g. user retries the same "run tests" command minutes apart).
2946
+ // Literal here rather than a named const because this function is first
2947
+ // called at top-level module init (line ~302) which runs before any const
2948
+ // declared below would leave the TDZ.
2949
+ const userMessageDedupWindowMs = 60_000;
2950
+ const userMessageIndex = new Map();
2308
2951
  for (const item of normalized) {
2309
2952
  if (seen.has(item.stableId)) {
2310
2953
  continue;
2311
2954
  }
2955
+ if (item.kind === "user_message") {
2956
+ const threadKey = cleanText(item.threadId || "");
2957
+ const textKey = cleanText(item.messageText || "");
2958
+ if (threadKey && textKey) {
2959
+ const key = `${threadKey}\u0001${textKey}`;
2960
+ const existingMs = userMessageIndex.get(key);
2961
+ const itemMs = Number(item.createdAtMs || 0);
2962
+ if (
2963
+ existingMs !== undefined &&
2964
+ Math.abs(existingMs - itemMs) <= userMessageDedupWindowMs
2965
+ ) {
2966
+ continue;
2967
+ }
2968
+ userMessageIndex.set(key, itemMs);
2969
+ }
2970
+ }
2312
2971
  const prov = item.provider || "codex";
2313
2972
  if (saturatedProviders.has(prov)) {
2314
2973
  continue;
@@ -2352,6 +3011,7 @@ function migrateRecentCodeEventsState({ config, runtime, state }) {
2352
3011
  const previousItems = Array.isArray(state.recentCodeEvents) ? normalizeCodeEvents(state.recentCodeEvents, config.maxCodeEvents) : [];
2353
3012
  runtime.recentCodeEvents = nextItems;
2354
3013
  state.recentCodeEvents = nextItems;
3014
+ invalidateDiffThreadGroupsCache();
2355
3015
  return JSON.stringify(nextItems) !== JSON.stringify(previousItems);
2356
3016
  }
2357
3017
 
@@ -2383,6 +3043,9 @@ function normalizeTimelineEntry(raw) {
2383
3043
  if (!isPlainObject(raw)) {
2384
3044
  return null;
2385
3045
  }
3046
+ if (shouldHideClaudeInternalItem(raw)) {
3047
+ return null;
3048
+ }
2386
3049
 
2387
3050
  const stableId = cleanText(raw.stableId ?? raw.id ?? "");
2388
3051
  const kind = cleanText(raw.kind ?? "");
@@ -2420,14 +3083,15 @@ function normalizeTimelineEntry(raw) {
2420
3083
  raw.detailText,
2421
3084
  raw.message
2422
3085
  );
3086
+ const rawTitle = cleanText(raw.title ?? "");
2423
3087
  const title =
2424
- cleanText(raw.title ?? "") ||
3088
+ (!isFallbackTimelineTitle(rawTitle, kind, threadId) ? rawTitle : "") ||
2425
3089
  (kind === "file_event" ? fileEventTitle(DEFAULT_LOCALE, fileEventType) : "") ||
2426
3090
  threadLabel ||
2427
3091
  kindTitle(DEFAULT_LOCALE, kind);
2428
3092
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
2429
3093
 
2430
- return {
3094
+ const normalized = {
2431
3095
  stableId,
2432
3096
  token: cleanText(raw.token ?? "") || historyToken(stableId),
2433
3097
  kind,
@@ -2473,6 +3137,7 @@ function normalizeTimelineEntry(raw) {
2473
3137
  ...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
2474
3138
  ...(suggestions.length > 0 ? { suggestions } : {}),
2475
3139
  };
3140
+ return shouldHideClaudeInternalItem(normalized) ? null : normalized;
2476
3141
  }
2477
3142
 
2478
3143
  function recordTimelineEntry({ config, runtime, state, entry }) {
@@ -2563,6 +3228,9 @@ function recordCodeEvent({ config, runtime, state, entry }) {
2563
3228
  );
2564
3229
  runtime.recentCodeEvents = nextItems;
2565
3230
  state.recentCodeEvents = nextItems;
3231
+ if (changed) {
3232
+ invalidateDiffThreadGroupsCache();
3233
+ }
2566
3234
  return changed;
2567
3235
  }
2568
3236
 
@@ -2614,6 +3282,9 @@ function syncRecentCodeEventsFromTimeline({ config, runtime, state }) {
2614
3282
  );
2615
3283
  runtime.recentCodeEvents = nextItems;
2616
3284
  state.recentCodeEvents = nextItems;
3285
+ if (changed) {
3286
+ invalidateDiffThreadGroupsCache();
3287
+ }
2617
3288
  return changed;
2618
3289
  }
2619
3290
 
@@ -2840,13 +3511,21 @@ function pendingChoiceStableId(userInputRequest) {
2840
3511
  return `choice:${userInputRequest.requestKey}`;
2841
3512
  }
2842
3513
 
2843
- function buildAppItemUrl(config, kind, token) {
3514
+ function buildAppItemUrl(config, kind, token, options = {}) {
2844
3515
  const url = new URL(`${config.nativeApprovalPublicBaseUrl}/app`);
2845
3516
  url.searchParams.set("item", `${kind}:${token}`);
3517
+ const tab = cleanText(options.tab || "");
3518
+ const subtab = cleanText(options.subtab || "");
3519
+ if (tab) {
3520
+ url.searchParams.set("tab", tab);
3521
+ }
3522
+ if (subtab) {
3523
+ url.searchParams.set("subtab", subtab);
3524
+ }
2846
3525
  return url.toString();
2847
3526
  }
2848
3527
 
2849
- function buildPushPayload({ config, kind, token, stableId, title, body }) {
3528
+ function buildPushPayload({ config, kind, token, stableId, title, body, tab = "", subtab = "" }) {
2850
3529
  return {
2851
3530
  title: withNotificationIcon(kind, title),
2852
3531
  body: formatNotificationBody(body, config.completionDetailThresholdChars) || body || title,
@@ -2855,7 +3534,7 @@ function buildPushPayload({ config, kind, token, stableId, title, body }) {
2855
3534
  kind,
2856
3535
  token,
2857
3536
  stableId,
2858
- url: buildAppItemUrl(config, kind, token),
3537
+ url: buildAppItemUrl(config, kind, token, { tab, subtab }),
2859
3538
  },
2860
3539
  };
2861
3540
  }
@@ -2975,7 +3654,7 @@ function pushDeliveryKey(deviceId, stableId) {
2975
3654
  return `${cleanText(deviceId || "")}:${cleanText(stableId || "")}`;
2976
3655
  }
2977
3656
 
2978
- async function deliverWebPushItem({ config, state, kind, token, stableId, title, body, buildLocalizedContent = null }) {
3657
+ async function deliverWebPushItem({ config, state, kind, token, stableId, title, body, tab = "", subtab = "", buildLocalizedContent = null }) {
2979
3658
  if (!config.webPushEnabled || config.dryRun) {
2980
3659
  return false;
2981
3660
  }
@@ -3010,6 +3689,8 @@ async function deliverWebPushItem({ config, state, kind, token, stableId, title,
3010
3689
  stableId,
3011
3690
  title: localizedContent?.title || title,
3012
3691
  body: localizedContent?.body || body,
3692
+ tab,
3693
+ subtab,
3013
3694
  })
3014
3695
  );
3015
3696
  await webPush.sendNotification(
@@ -3093,18 +3774,24 @@ async function scanOnce({ config, runtime, state }) {
3093
3774
  }
3094
3775
 
3095
3776
  if (config.webUiEnabled) {
3777
+ let claudeTranscriptChanged = false;
3096
3778
  if (now - runtime.lastClaudeScanAt >= config.directoryScanIntervalMs) {
3097
3779
  runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir);
3098
3780
  runtime.lastClaudeScanAt = now;
3099
3781
  }
3782
+ let claudeSessionTitlesChanged = false;
3100
3783
  if (now - runtime.lastClaudeSessionTitleScanAt >= config.directoryScanIntervalMs) {
3101
- await refreshClaudeSessionTitles(runtime);
3784
+ claudeSessionTitlesChanged = await refreshClaudeSessionTitles(runtime);
3102
3785
  runtime.lastClaudeSessionTitleScanAt = now;
3103
3786
  }
3104
3787
  for (const filePath of runtime.claudeKnownFiles) {
3105
3788
  const changed = await processClaudeTranscriptFile({ filePath, config, runtime, state, now });
3789
+ claudeTranscriptChanged = claudeTranscriptChanged || changed;
3106
3790
  dirty = dirty || changed;
3107
3791
  }
3792
+ if (claudeTranscriptChanged || claudeSessionTitlesChanged) {
3793
+ dirty = refreshResolvedThreadLabels({ config, runtime, state }) || dirty;
3794
+ }
3108
3795
  }
3109
3796
 
3110
3797
  if (config.webUiEnabled) {
@@ -3330,6 +4017,7 @@ function fileEventCallIdFromStableId(stableId) {
3330
4017
  }
3331
4018
 
3332
4019
  async function refreshClaudeSessionTitles(runtime) {
4020
+ let changed = false;
3333
4021
  // Read ~/Library/Application Support/Claude/claude-code-sessions/*/*/local_*.json
3334
4022
  // Each file maps cliSessionId → title (auto-generated by Claude Desktop).
3335
4023
  const baseDir = path.join(
@@ -3367,7 +4055,10 @@ async function refreshClaudeSessionTitles(runtime) {
3367
4055
  const cliSessionId = cleanText(data?.cliSessionId || "");
3368
4056
  const title = cleanText(data?.title || "");
3369
4057
  if (cliSessionId && title) {
3370
- runtime.claudeSessionTitles.set(cliSessionId, title);
4058
+ if (runtime.claudeSessionTitles.get(cliSessionId) !== title) {
4059
+ runtime.claudeSessionTitles.set(cliSessionId, title);
4060
+ changed = true;
4061
+ }
3371
4062
  }
3372
4063
  } catch {
3373
4064
  // skip unreadable/invalid
@@ -3378,6 +4069,49 @@ async function refreshClaudeSessionTitles(runtime) {
3378
4069
  } catch {
3379
4070
  // base dir missing — Claude Desktop not installed
3380
4071
  }
4072
+
4073
+ for (const filePath of runtime.claudeKnownFiles ?? []) {
4074
+ let lines;
4075
+ try {
4076
+ const raw = await fs.readFile(filePath, "utf8");
4077
+ lines = raw.split("\n");
4078
+ } catch {
4079
+ continue;
4080
+ }
4081
+ for (const rawLine of lines) {
4082
+ const trimmed = rawLine.trim();
4083
+ if (!trimmed) continue;
4084
+ let record;
4085
+ try {
4086
+ record = JSON.parse(trimmed);
4087
+ } catch {
4088
+ continue;
4089
+ }
4090
+ if (record?.entrypoint !== "sdk-cli" || record?.type !== "user") {
4091
+ continue;
4092
+ }
4093
+ const threadId = cleanText(record?.sessionId || "");
4094
+ const message = record?.message || {};
4095
+ let text = "";
4096
+ if (typeof message.content === "string") {
4097
+ text = message.content;
4098
+ } else if (Array.isArray(message.content)) {
4099
+ for (const block of message.content) {
4100
+ if (block?.type === "text" && block.text) {
4101
+ text += block.text;
4102
+ }
4103
+ }
4104
+ }
4105
+ const derivedTitle = deriveClaudeSdkCliThreadLabel(text, threadId);
4106
+ if (threadId && derivedTitle && runtime.claudeSessionTitles.get(threadId) !== derivedTitle) {
4107
+ runtime.claudeSessionTitles.set(threadId, derivedTitle);
4108
+ changed = true;
4109
+ }
4110
+ break;
4111
+ }
4112
+ }
4113
+
4114
+ return changed;
3381
4115
  }
3382
4116
 
3383
4117
  async function listClaudeTranscriptFiles(claudeProjectsDir) {
@@ -3497,8 +4231,10 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3497
4231
  fileState.threadLabel = path.basename(record.cwd);
3498
4232
  }
3499
4233
 
3500
- // Only process Claude Desktop sessions
3501
- if (record.entrypoint !== "claude-desktop") continue;
4234
+ // Accept both Claude Desktop sessions and the current sdk-cli sessions
4235
+ // that Claude emits for agent-driven work. The latter regressed the
4236
+ // timeline/completed views because they were silently skipped here.
4237
+ if (record.entrypoint !== "claude-desktop" && record.entrypoint !== "sdk-cli") continue;
3502
4238
 
3503
4239
  const type = record.type;
3504
4240
  if (type !== "user" && type !== "assistant") continue;
@@ -3526,6 +4262,15 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3526
4262
  }
3527
4263
  text = cleanText(text);
3528
4264
  if (!text) continue;
4265
+ if (type === "user" && record.entrypoint === "sdk-cli") {
4266
+ const derivedThreadLabel = deriveClaudeSdkCliThreadLabel(text, threadId);
4267
+ if (derivedThreadLabel) {
4268
+ fileState.threadLabel = derivedThreadLabel;
4269
+ if (threadId) {
4270
+ runtime.claudeSessionTitles.set(threadId, derivedThreadLabel);
4271
+ }
4272
+ }
4273
+ }
3529
4274
  // Skip Claude Code internal system messages (task notifications, system
3530
4275
  // reminders, etc.) — these are XML-like tags injected into the
3531
4276
  // conversation that should not appear on the user-facing timeline.
@@ -3587,6 +4332,7 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3587
4332
  config,
3588
4333
  state,
3589
4334
  kind: "assistant_final",
4335
+ tab: "timeline",
3590
4336
  token: entry.token,
3591
4337
  stableId: entry.stableId,
3592
4338
  title: threadLabel || "Claude",
@@ -3608,6 +4354,129 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3608
4354
  return dirty;
3609
4355
  }
3610
4356
 
4357
+ async function loadRecoveryBackupState(stateFile) {
4358
+ const dir = path.dirname(stateFile);
4359
+ const base = path.basename(stateFile);
4360
+ let entries;
4361
+ try {
4362
+ entries = await fs.readdir(dir, { withFileTypes: true });
4363
+ } catch {
4364
+ return null;
4365
+ }
4366
+
4367
+ const candidates = [];
4368
+ for (const entry of entries) {
4369
+ if (!entry.isFile()) continue;
4370
+ if (!entry.name.startsWith(`${base}.bak-`)) continue;
4371
+ const filePath = path.join(dir, entry.name);
4372
+ try {
4373
+ const stat = await fs.stat(filePath);
4374
+ candidates.push({ filePath, mtimeMs: Number(stat.mtimeMs) || 0 });
4375
+ } catch {
4376
+ // ignore unreadable backup
4377
+ }
4378
+ }
4379
+
4380
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
4381
+ for (const candidate of candidates) {
4382
+ try {
4383
+ const raw = await fs.readFile(candidate.filePath, "utf8");
4384
+ return JSON.parse(raw);
4385
+ } catch {
4386
+ // try next backup
4387
+ }
4388
+ }
4389
+ return null;
4390
+ }
4391
+
4392
+ function providerSetForItems(items) {
4393
+ return new Set(
4394
+ (Array.isArray(items) ? items : [])
4395
+ .map((item) => normalizeProvider(item?.provider))
4396
+ .filter(Boolean)
4397
+ );
4398
+ }
4399
+
4400
+ function missingRecoveryProviders(items) {
4401
+ const present = providerSetForItems(items);
4402
+ return ["claude", "a2a"].filter((provider) => !present.has(provider));
4403
+ }
4404
+
4405
+ function selectRecoveryItems(items, missingProviders) {
4406
+ const wanted = new Set(missingProviders);
4407
+ return (Array.isArray(items) ? items : []).filter((item) => wanted.has(normalizeProvider(item?.provider)));
4408
+ }
4409
+
4410
+ async function recoverMissingProviderStateFromBackup({ config, runtime, state }) {
4411
+ const missingHistoryProviders = missingRecoveryProviders(state.recentHistoryItems ?? runtime.recentHistoryItems);
4412
+ const missingTimelineProviders = missingRecoveryProviders(state.recentTimelineEntries ?? runtime.recentTimelineEntries);
4413
+ const shouldRecoverCodeEvents = !Array.isArray(state.recentCodeEvents) || state.recentCodeEvents.length === 0;
4414
+
4415
+ if (missingHistoryProviders.length === 0 && missingTimelineProviders.length === 0 && !shouldRecoverCodeEvents) {
4416
+ return false;
4417
+ }
4418
+
4419
+ const backup = await loadRecoveryBackupState(config.stateFile);
4420
+ if (!backup) {
4421
+ return false;
4422
+ }
4423
+
4424
+ let changed = false;
4425
+
4426
+ if (missingHistoryProviders.length > 0) {
4427
+ const mergedHistory = normalizeHistoryItems(
4428
+ [
4429
+ ...(state.recentHistoryItems ?? runtime.recentHistoryItems ?? []),
4430
+ ...selectRecoveryItems(backup.recentHistoryItems, missingHistoryProviders),
4431
+ ],
4432
+ config.maxHistoryItems
4433
+ );
4434
+ if (JSON.stringify(mergedHistory) !== JSON.stringify(state.recentHistoryItems ?? runtime.recentHistoryItems ?? [])) {
4435
+ state.recentHistoryItems = mergedHistory;
4436
+ runtime.recentHistoryItems = mergedHistory;
4437
+ changed = true;
4438
+ }
4439
+ }
4440
+
4441
+ if (missingTimelineProviders.length > 0) {
4442
+ const mergedTimeline = normalizeTimelineEntries(
4443
+ [
4444
+ ...(state.recentTimelineEntries ?? runtime.recentTimelineEntries ?? []),
4445
+ ...selectRecoveryItems(backup.recentTimelineEntries, missingTimelineProviders),
4446
+ ],
4447
+ config.maxTimelineEntries
4448
+ );
4449
+ if (JSON.stringify(mergedTimeline) !== JSON.stringify(state.recentTimelineEntries ?? runtime.recentTimelineEntries ?? [])) {
4450
+ state.recentTimelineEntries = mergedTimeline;
4451
+ runtime.recentTimelineEntries = mergedTimeline;
4452
+ changed = true;
4453
+ }
4454
+ }
4455
+
4456
+ if (shouldRecoverCodeEvents) {
4457
+ const mergedCodeEvents = normalizeCodeEvents(
4458
+ [
4459
+ ...(state.recentCodeEvents ?? runtime.recentCodeEvents ?? []),
4460
+ ...(Array.isArray(backup.recentCodeEvents) ? backup.recentCodeEvents : []),
4461
+ ],
4462
+ config.maxCodeEvents
4463
+ );
4464
+ if (JSON.stringify(mergedCodeEvents) !== JSON.stringify(state.recentCodeEvents ?? runtime.recentCodeEvents ?? [])) {
4465
+ state.recentCodeEvents = mergedCodeEvents;
4466
+ runtime.recentCodeEvents = mergedCodeEvents;
4467
+ invalidateDiffThreadGroupsCache();
4468
+ changed = true;
4469
+ }
4470
+ }
4471
+
4472
+ if (changed) {
4473
+ console.log(
4474
+ `[state-recovery] recovered history=${missingHistoryProviders.join(",") || "none"} timeline=${missingTimelineProviders.join(",") || "none"} code=${shouldRecoverCodeEvents ? "yes" : "no"}`
4475
+ );
4476
+ }
4477
+ return changed;
4478
+ }
4479
+
3611
4480
  async function processSqliteCompletionLog({ config, runtime, state, now }) {
3612
4481
  const logsDbFile = cleanText(runtime.logsDbFile || config.codexLogsDbFile || "");
3613
4482
  if (!logsDbFile) {
@@ -4826,6 +5695,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
4826
5695
  config,
4827
5696
  state,
4828
5697
  kind: "completion",
5698
+ tab: "inbox",
5699
+ subtab: "completed",
4829
5700
  token: historyToken(event.id),
4830
5701
  stableId: event.id,
4831
5702
  title: event.title,
@@ -5134,6 +6005,28 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
5134
6005
  request,
5135
6006
  approval: existing,
5136
6007
  });
6008
+ const existingAutoPilotWriteCandidate =
6009
+ existing.kind === "file"
6010
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval: existing })
6011
+ : null;
6012
+ if (existingAutoPilotWriteCandidate) {
6013
+ try {
6014
+ await autoApproveTrustedWrite({
6015
+ config,
6016
+ runtime,
6017
+ state,
6018
+ approval: existing,
6019
+ candidate: existingAutoPilotWriteCandidate,
6020
+ });
6021
+ continue;
6022
+ } catch (error) {
6023
+ existing.resolved = false;
6024
+ existing.resolving = false;
6025
+ runtime.nativeApprovalsByRequestKey.set(requestKey, existing);
6026
+ runtime.nativeApprovalsByToken.set(existing.token, existing);
6027
+ console.error(`[auto-pilot-write-error] ${requestKey} | ${error.message}`);
6028
+ }
6029
+ }
5137
6030
  if (changed) {
5138
6031
  const fileKeys = isPlainObject(existing.rawParams) ? Object.keys(existing.rawParams).join(",") : "";
5139
6032
  console.log(
@@ -5156,6 +6049,57 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
5156
6049
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
5157
6050
  runtime.nativeApprovalsByToken.set(approval.token, approval);
5158
6051
 
6052
+ const nativeAutoPilotMatch =
6053
+ approval.kind === "command"
6054
+ ? maybeAutoApproveTrustedRead({
6055
+ state,
6056
+ commandText: approval.rawParams?.command ?? approval.rawParams?.cmd ?? "",
6057
+ cwd: approval.rawParams?.cwd ?? approval.rawParams?.grantRoot ?? "",
6058
+ workspaceRoot: approval.rawParams?.grantRoot ?? approval.rawParams?.cwd ?? "",
6059
+ })
6060
+ : null;
6061
+ if (nativeAutoPilotMatch) {
6062
+ try {
6063
+ await autoApproveTrustedRead({
6064
+ config,
6065
+ runtime,
6066
+ state,
6067
+ approval,
6068
+ policyMatch: nativeAutoPilotMatch,
6069
+ });
6070
+ continue;
6071
+ } catch (error) {
6072
+ approval.resolved = false;
6073
+ approval.resolving = false;
6074
+ runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
6075
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
6076
+ console.error(`[auto-pilot-error] ${requestKey} | ${error.message}`);
6077
+ }
6078
+ }
6079
+
6080
+ const nativeAutoPilotWriteCandidate =
6081
+ approval.kind === "file"
6082
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval })
6083
+ : null;
6084
+ if (nativeAutoPilotWriteCandidate) {
6085
+ try {
6086
+ await autoApproveTrustedWrite({
6087
+ config,
6088
+ runtime,
6089
+ state,
6090
+ approval,
6091
+ candidate: nativeAutoPilotWriteCandidate,
6092
+ });
6093
+ continue;
6094
+ } catch (error) {
6095
+ approval.resolved = false;
6096
+ approval.resolving = false;
6097
+ runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
6098
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
6099
+ console.error(`[auto-pilot-write-error] ${requestKey} | ${error.message}`);
6100
+ }
6101
+ }
6102
+
5159
6103
  if (previousKeys.has(requestKey)) {
5160
6104
  const fileKeys = isPlainObject(approval.rawParams) ? Object.keys(approval.rawParams).join(",") : "";
5161
6105
  console.log(
@@ -5187,6 +6131,8 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
5187
6131
  config,
5188
6132
  state,
5189
6133
  kind: "approval",
6134
+ tab: "inbox",
6135
+ subtab: "pending",
5190
6136
  token: approval.token,
5191
6137
  stableId: pendingApprovalStableId(approval),
5192
6138
  title: approval.title,
@@ -5310,6 +6256,8 @@ async function syncPlanImplementationRequests({
5310
6256
  config,
5311
6257
  state,
5312
6258
  kind: "plan",
6259
+ tab: "inbox",
6260
+ subtab: "pending",
5313
6261
  token: planRequest.token,
5314
6262
  stableId: pendingPlanStableId(planRequest),
5315
6263
  title: planRequest.title,
@@ -5515,6 +6463,8 @@ async function syncGenericUserInputRequests({
5515
6463
  config,
5516
6464
  state,
5517
6465
  kind: "choice",
6466
+ tab: "inbox",
6467
+ subtab: "pending",
5518
6468
  token: userInputRequest.token,
5519
6469
  stableId: pendingChoiceStableId(userInputRequest),
5520
6470
  title: userInputRequest.title,
@@ -5633,6 +6583,8 @@ async function buildNativeApprovalPayload({ config, runtime, conversationId, req
5633
6583
  ownerClientId: runtime.threadOwnerClientIds.get(conversationId) ?? null,
5634
6584
  approvalIds,
5635
6585
  rawParams,
6586
+ cwd: cleanText(rawParams?.cwd ?? rawParams?.grantRoot ?? ""),
6587
+ workspaceRoot: cleanText(rawParams?.grantRoot ?? rawParams?.cwd ?? ""),
5636
6588
  fileRefs: normalizeTimelineFileRefs(mergedDelta?.fileRefs ?? []),
5637
6589
  diffText: normalizeTimelineDiffText(mergedDelta?.diffText ?? ""),
5638
6590
  diffAvailable: Boolean(mergedDelta?.diffAvailable),
@@ -5669,6 +6621,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5669
6621
  ownerClientId: approval.ownerClientId,
5670
6622
  approvalIds: approval.approvalIds,
5671
6623
  rawParams: approval.rawParams,
6624
+ cwd: approval.cwd,
6625
+ workspaceRoot: approval.workspaceRoot,
5672
6626
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
5673
6627
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
5674
6628
  diffAvailable: Boolean(approval.diffAvailable),
@@ -5688,6 +6642,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5688
6642
  approval.ownerClientId = payload.ownerClientId;
5689
6643
  approval.approvalIds = payload.approvalIds;
5690
6644
  approval.rawParams = payload.rawParams;
6645
+ approval.cwd = payload.cwd;
6646
+ approval.workspaceRoot = payload.workspaceRoot;
5691
6647
  approval.fileRefs = payload.fileRefs;
5692
6648
  approval.diffText = payload.diffText;
5693
6649
  approval.diffAvailable = payload.diffAvailable;
@@ -5707,6 +6663,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5707
6663
  ownerClientId: approval.ownerClientId,
5708
6664
  approvalIds: approval.approvalIds,
5709
6665
  rawParams: approval.rawParams,
6666
+ cwd: approval.cwd,
6667
+ workspaceRoot: approval.workspaceRoot,
5710
6668
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
5711
6669
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
5712
6670
  diffAvailable: Boolean(approval.diffAvailable),
@@ -7767,6 +8725,67 @@ function getNativeThreadLabel({ runtime, conversationId, cwd }) {
7767
8725
  return shortId(normalizedConversationId) || t(DEFAULT_LOCALE, "server.fallback.codexTask", { provider: providerDisplayName(DEFAULT_LOCALE, "codex") });
7768
8726
  }
7769
8727
 
8728
+ function isFallbackConversationLabel(label, conversationId) {
8729
+ const normalizedLabel = cleanText(label || "");
8730
+ const normalizedConversationId = cleanText(conversationId || "");
8731
+ if (!normalizedLabel) {
8732
+ return true;
8733
+ }
8734
+ if (normalizedConversationId && normalizedLabel === shortId(normalizedConversationId)) {
8735
+ return true;
8736
+ }
8737
+ return false;
8738
+ }
8739
+
8740
+ function deriveClaudeSdkCliThreadLabel(messageText, conversationId = "") {
8741
+ const cleaned = stripNotificationMarkup(stripEnvironmentContextBlocks(messageText || ""));
8742
+ const single = cleanText(cleaned);
8743
+ if (!single || /^\d+$/u.test(single)) {
8744
+ return "";
8745
+ }
8746
+
8747
+ if (/^You are scoring Moltbook posts? for an AI agent\b/iu.test(single)) {
8748
+ return "Moltbook scoring";
8749
+ }
8750
+ if (/^Codex from another agent:/iu.test(single)) {
8751
+ return truncate(cleanText(single.replace(/^Codex from another agent:\s*/iu, "")), 90) || "Cross-agent task";
8752
+ }
8753
+
8754
+ const firstSentence = cleanText(single.split(/(?<=[.!?。!?])\s+/u)[0] || "");
8755
+ const candidate = firstSentence || single;
8756
+ if (!candidate || candidate === shortId(conversationId)) {
8757
+ return "";
8758
+ }
8759
+ return truncate(candidate, 90);
8760
+ }
8761
+
8762
+ function isHiddenClaudeInternalScoringText(text) {
8763
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
8764
+ if (!single) {
8765
+ return false;
8766
+ }
8767
+ return /^You are scoring Moltbook posts? for an AI agent\b/iu.test(single);
8768
+ }
8769
+
8770
+ function shouldHideClaudeInternalItem(item) {
8771
+ if (!isPlainObject(item)) {
8772
+ return false;
8773
+ }
8774
+ if (normalizeProvider(item.provider) !== "claude") {
8775
+ return false;
8776
+ }
8777
+ const threadLabel = cleanText(item.threadLabel ?? "");
8778
+ if (threadLabel === "Moltbook scoring") {
8779
+ return true;
8780
+ }
8781
+ return (
8782
+ isHiddenClaudeInternalScoringText(item.messageText) ||
8783
+ isHiddenClaudeInternalScoringText(item.summary) ||
8784
+ isHiddenClaudeInternalScoringText(item.detailText) ||
8785
+ isHiddenClaudeInternalScoringText(item.message)
8786
+ );
8787
+ }
8788
+
7770
8789
  function threadStateArchiveStatus(threadState) {
7771
8790
  if (!isPlainObject(threadState)) {
7772
8791
  return "";
@@ -8724,6 +9743,8 @@ function markDevicePaired(state, config, deviceId, metadata = {}, now = Date.now
8724
9743
  return JSON.stringify(previous ?? null) !== JSON.stringify(next);
8725
9744
  }
8726
9745
 
9746
+ const TOUCH_DEVICE_TRUST_DEBOUNCE_MS = 60_000;
9747
+
8727
9748
  function touchDeviceTrust(state, config, deviceId, now = Date.now()) {
8728
9749
  const normalizedDeviceId = cleanText(deviceId || "");
8729
9750
  const current = getActiveDeviceTrustRecord(state, config, normalizedDeviceId, now);
@@ -8731,6 +9752,20 @@ function touchDeviceTrust(state, config, deviceId, now = Date.now()) {
8731
9752
  return false;
8732
9753
  }
8733
9754
 
9755
+ // Debounce: `lastAuthenticatedAtMs` is bumped to `now` on every
9756
+ // authenticated request. Without this guard, every `/api/session`
9757
+ // (and `/api/bootstrap`) call flags the record as "changed" and the
9758
+ // caller runs a full `saveState`, which synchronously stringifies an
9759
+ // 8+ MB state.json on the main thread and starves the event loop —
9760
+ // driving TLS handshake latency for any in-flight connection into the
9761
+ // seconds range. Skip the write when we've touched it recently; the
9762
+ // next real change (pairing, revocation, locale update, etc.) still
9763
+ // goes through unchanged.
9764
+ const previousTouch = Number(current.lastAuthenticatedAtMs) || 0;
9765
+ if (previousTouch > 0 && now - previousTouch < TOUCH_DEVICE_TRUST_DEBOUNCE_MS) {
9766
+ return false;
9767
+ }
9768
+
8734
9769
  const next = {
8735
9770
  ...current,
8736
9771
  lastAuthenticatedAtMs: now,
@@ -8853,6 +9888,22 @@ function buildDeviceSummary({ config, state, deviceId, record, currentDeviceId,
8853
9888
  };
8854
9889
  }
8855
9890
 
9891
+ // Shared between `/api/session` and `/api/bootstrap` so both return an
9892
+ // identical session shape.
9893
+ function buildSessionPayload({ config, state, session }) {
9894
+ return {
9895
+ authenticated: Boolean(session.authenticated),
9896
+ expiresAtMs: Number(session.expiresAtMs) || 0,
9897
+ pairingAvailable: isPairingAvailableForState(config, state),
9898
+ webPushEnabled: config.webPushEnabled,
9899
+ httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
9900
+ appVersion: appPackageVersion,
9901
+ deviceId: session.deviceId || null,
9902
+ temporaryPairing: session.temporaryPairing === true,
9903
+ ...buildSessionLocalePayload(config, state, session.deviceId),
9904
+ };
9905
+ }
9906
+
8856
9907
  function buildDevicesResponse({ config, state, session, locale }) {
8857
9908
  return {
8858
9909
  devices: activeTrustedDevices(state, config).map(({ deviceId, record }) =>
@@ -9608,7 +10659,7 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
9608
10659
  }
9609
10660
 
9610
10661
  async function buildDiffInboxItems(runtime, state, config, locale) {
9611
- return (await buildDiffThreadGroups(runtime, state, config)).map((group) => ({
10662
+ return (await getDiffThreadGroupsCached(runtime, state, config)).map((group) => ({
9612
10663
  kind: "diff_thread",
9613
10664
  token: group.token,
9614
10665
  threadId: group.threadId,
@@ -9767,14 +10818,24 @@ async function buildDiffThreadGroups(runtime, state, config) {
9767
10818
  .sort((left, right) => Number(right.latestChangedAtMs ?? 0) - Number(left.latestChangedAtMs ?? 0));
9768
10819
  }
9769
10820
 
9770
- async function buildInboxResponse(runtime, state, config, locale) {
10821
+ // Split into two halves so `/api/inbox` (pending + completed) returns
10822
+ // without waiting on the diff build, which spawns `git` subprocesses per
10823
+ // tracked repo inside `buildCurrentUnstagedChangesForRepo`. The diff half
10824
+ // is served from `/api/inbox/diff` and fetched separately by the PWA so
10825
+ // it doesn't block first paint of the inbox/completed lists.
10826
+ function buildInboxFastResponse(runtime, state, config, locale) {
9771
10827
  return {
9772
10828
  pending: buildPendingInboxItems(runtime, state, config, locale),
9773
- diff: await buildDiffInboxItems(runtime, state, config, locale),
9774
10829
  completed: buildCompletedInboxItems(runtime, state, config, locale),
9775
10830
  };
9776
10831
  }
9777
10832
 
10833
+ async function buildInboxDiffResponse(runtime, state, config, locale) {
10834
+ return {
10835
+ diff: await buildDiffInboxItems(runtime, state, config, locale),
10836
+ };
10837
+ }
10838
+
9778
10839
  function buildOperationalTimelineEntries(runtime, state, config, locale) {
9779
10840
  const now = Date.now();
9780
10841
  const items = [];
@@ -9988,9 +11049,10 @@ function buildTimelineResponse(runtime, state, config, locale) {
9988
11049
  };
9989
11050
  }
9990
11051
 
9991
- function buildPendingApprovalDetail(runtime, approval, locale) {
11052
+ function buildPendingApprovalDetail(runtime, state, approval, locale) {
9992
11053
  const previousContext = buildPreviousApprovalContext(runtime, approval);
9993
11054
  const approvalKind = cleanText(approval.kind || "");
11055
+ const autoPilotReview = buildAutoPilotManualReview(runtime, state, approval, locale);
9994
11056
  const detail = {
9995
11057
  kind: "approval",
9996
11058
  approvalKind,
@@ -10007,6 +11069,7 @@ function buildPendingApprovalDetail(runtime, approval, locale) {
10007
11069
  diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
10008
11070
  diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
10009
11071
  previousContext,
11072
+ autoPilotReview,
10010
11073
  readOnly: approval.readOnly === true,
10011
11074
  actions: approval.readOnly === true
10012
11075
  ? []
@@ -10031,78 +11094,265 @@ function buildPendingApprovalDetail(runtime, approval, locale) {
10031
11094
  return detail;
10032
11095
  }
10033
11096
 
10034
- function buildPreviousApprovalContext(runtime, approval) {
10035
- const threadId = cleanText(approval?.conversationId || "");
10036
- const approvalCreatedAtMs = Number(approval?.createdAtMs) || 0;
10037
- if (!threadId || !approvalCreatedAtMs) {
10038
- return null;
10039
- }
10040
-
10041
- const previousEntry = runtime.recentTimelineEntries
10042
- .filter((entry) => {
10043
- if (!timelineMessageKinds.has(entry.kind)) {
10044
- return false;
10045
- }
10046
- if (cleanText(entry.threadId || "") !== threadId) {
10047
- return false;
10048
- }
10049
- return Number(entry.createdAtMs) > 0 && Number(entry.createdAtMs) < approvalCreatedAtMs;
10050
- })
10051
- .sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0))[0];
10052
-
10053
- if (!previousEntry) {
11097
+ function buildAutoPilotManualReview(runtime, state, approval, locale) {
11098
+ if (!isPlainObject(approval) || approval.readOnly === true) {
10054
11099
  return null;
10055
11100
  }
10056
-
10057
- const sourceText = normalizeLongText(previousEntry.messageText || previousEntry.summary || "");
10058
- if (!sourceText) {
11101
+ if (cleanText(approval.kind || "") !== "file") {
10059
11102
  return null;
10060
11103
  }
10061
-
10062
- return {
10063
- kind: previousEntry.kind,
10064
- createdAtMs: Number(previousEntry.createdAtMs) || 0,
10065
- messageHtml: renderMessageHtml(sourceText, "<p></p>"),
10066
- };
11104
+ return buildAutoPilotWriteManualReview(runtime, state, approval, locale);
10067
11105
  }
10068
11106
 
10069
- function buildInterruptedTimelineContext(runtime, entry, locale) {
10070
- if (!runtime || !isTurnAbortedDisplayMessage(entry?.messageText)) {
10071
- return null;
11107
+ function buildAutoPilotWriteManualReview(runtime, state, approval, locale) {
11108
+ const writesEnabled = isAutoPilotTrustedWritesEnabled(state);
11109
+ if (!writesEnabled) {
11110
+ return {
11111
+ title: t(locale, "detail.autoPilot.manualTitle"),
11112
+ body: t(locale, "detail.autoPilot.writeDisabled"),
11113
+ };
10072
11114
  }
10073
11115
 
10074
- const threadId = cleanText(entry?.threadId || "");
10075
- const interruptedCreatedAtMs = Number(entry?.createdAtMs) || 0;
10076
- if (!threadId || !interruptedCreatedAtMs) {
10077
- return null;
11116
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
11117
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
11118
+ if (!cwd || !workspaceRoot) {
11119
+ return {
11120
+ title: t(locale, "detail.autoPilot.manualTitle"),
11121
+ body: t(locale, "detail.autoPilot.writeMissingWorkspace"),
11122
+ };
10078
11123
  }
10079
11124
 
10080
- const previousEntry = runtime.recentTimelineEntries
10081
- .filter((candidate) => {
10082
- if (!timelineMessageKinds.has(cleanText(candidate?.kind || ""))) {
10083
- return false;
10084
- }
10085
- if (cleanText(candidate?.threadId || "") !== threadId) {
10086
- return false;
10087
- }
10088
- if (Number(candidate?.createdAtMs) <= 0 || Number(candidate?.createdAtMs) >= interruptedCreatedAtMs) {
10089
- return false;
10090
- }
10091
- return !isTurnAbortedDisplayMessage(candidate?.messageText);
10092
- })
10093
- .sort((left, right) => Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0))[0];
11125
+ const diffText = normalizeTimelineDiffText(approval?.diffText ?? "");
11126
+ if (!diffText) {
11127
+ return {
11128
+ title: t(locale, "detail.autoPilot.manualTitle"),
11129
+ body: t(locale, "detail.autoPilot.writeMissingDiff"),
11130
+ };
11131
+ }
10094
11132
 
10095
- if (!previousEntry) {
10096
- return null;
11133
+ const rawFileRefs = normalizeTimelineFileRefs(approval?.fileRefs ?? []);
11134
+ if (rawFileRefs.length === 0) {
11135
+ return {
11136
+ title: t(locale, "detail.autoPilot.manualTitle"),
11137
+ body: t(locale, "detail.autoPilot.writeMissingFiles"),
11138
+ };
10097
11139
  }
10098
11140
 
10099
- const sourceText = normalizeLongText(previousEntry.messageText || previousEntry.summary || "");
10100
- if (!sourceText) {
10101
- return null;
11141
+ const resolvedFileRefs = resolveTrustedWriteFileRefs({
11142
+ fileRefs: approval?.fileRefs ?? [],
11143
+ cwd,
11144
+ workspaceRoot,
11145
+ });
11146
+ if (!resolvedFileRefs) {
11147
+ return {
11148
+ title: t(locale, "detail.autoPilot.manualTitle"),
11149
+ body: t(locale, "detail.autoPilot.writeInvalidFiles"),
11150
+ };
10102
11151
  }
10103
11152
 
10104
- return {
10105
- kind: previousEntry.kind,
11153
+ const counts = diffLineCounts(diffText);
11154
+ const totalChangedLines =
11155
+ Math.max(0, Number(counts.addedLines) || 0) +
11156
+ Math.max(0, Number(counts.removedLines) || 0);
11157
+ if (totalChangedLines === 0) {
11158
+ return {
11159
+ title: t(locale, "detail.autoPilot.manualTitle"),
11160
+ body: t(locale, "detail.autoPilot.writeMissingDiff"),
11161
+ };
11162
+ }
11163
+ if (totalChangedLines > 120) {
11164
+ return {
11165
+ title: t(locale, "detail.autoPilot.manualTitle"),
11166
+ body: t(locale, "detail.autoPilot.writeDiffTooLarge"),
11167
+ };
11168
+ }
11169
+
11170
+ const diffSections = splitUnifiedDiffTextByFile(diffText);
11171
+ if (diffSections.length === 0) {
11172
+ return {
11173
+ title: t(locale, "detail.autoPilot.manualTitle"),
11174
+ body: t(locale, "detail.autoPilot.writeDiffUnreadable"),
11175
+ };
11176
+ }
11177
+ if (diffSections.length > 3) {
11178
+ return {
11179
+ title: t(locale, "detail.autoPilot.manualTitle"),
11180
+ body: t(locale, "detail.autoPilot.writeDiffTooLarge"),
11181
+ };
11182
+ }
11183
+
11184
+ if (
11185
+ /^(?:new file mode|deleted file mode|rename from|rename to|old mode|new mode|similarity index|dissimilarity index|GIT binary patch|Binary files )/mu.test(diffText)
11186
+ ) {
11187
+ return {
11188
+ title: t(locale, "detail.autoPilot.manualTitle"),
11189
+ body: t(locale, "detail.autoPilot.writeDangerousDiff"),
11190
+ };
11191
+ }
11192
+
11193
+ const diffSectionRefs = resolveTrustedWriteDiffSectionFileRefs({
11194
+ diffSections,
11195
+ cwd,
11196
+ workspaceRoot,
11197
+ });
11198
+ if (!diffSectionRefs || !sameNormalizedFileRefSet(diffSectionRefs, resolvedFileRefs)) {
11199
+ return {
11200
+ title: t(locale, "detail.autoPilot.manualTitle"),
11201
+ body: t(locale, "detail.autoPilot.writeDiffMismatch"),
11202
+ };
11203
+ }
11204
+
11205
+ const lanes = autoPilotWriteLaneState(state);
11206
+ const isContentOnly = resolvedFileRefs.every((fileRef) => isAutoPilotContentWritePath(fileRef));
11207
+ if (isContentOnly) {
11208
+ if (!lanes.content) {
11209
+ return {
11210
+ title: t(locale, "detail.autoPilot.manualTitle"),
11211
+ body: t(locale, "detail.autoPilot.writeContentDisabled"),
11212
+ };
11213
+ }
11214
+ return {
11215
+ title: t(locale, "detail.autoPilot.manualTitle"),
11216
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
11217
+ };
11218
+ }
11219
+
11220
+ const isUiTestsOnly = resolvedFileRefs.every((fileRef) => isAutoPilotUiTestWritePath(fileRef));
11221
+ if (isUiTestsOnly) {
11222
+ if (!lanes.uiTests) {
11223
+ return {
11224
+ title: t(locale, "detail.autoPilot.manualTitle"),
11225
+ body: t(locale, "detail.autoPilot.writeUiDisabled"),
11226
+ };
11227
+ }
11228
+ if (totalChangedLines > 80) {
11229
+ return {
11230
+ title: t(locale, "detail.autoPilot.manualTitle"),
11231
+ body: t(locale, "detail.autoPilot.writeUiTooLarge"),
11232
+ };
11233
+ }
11234
+ if (hasUnsafeUiOrTestWriteDiff(diffText)) {
11235
+ return {
11236
+ title: t(locale, "detail.autoPilot.manualTitle"),
11237
+ body: t(locale, "detail.autoPilot.writeUiUnsafe"),
11238
+ };
11239
+ }
11240
+ return {
11241
+ title: t(locale, "detail.autoPilot.manualTitle"),
11242
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
11243
+ };
11244
+ }
11245
+
11246
+ const isSourceOnly = resolvedFileRefs.every((fileRef) => isAutoPilotSourceWritePath(fileRef));
11247
+ if (isSourceOnly) {
11248
+ if (!lanes.source) {
11249
+ return {
11250
+ title: t(locale, "detail.autoPilot.manualTitle"),
11251
+ body: t(locale, "detail.autoPilot.writeSourceDisabled"),
11252
+ };
11253
+ }
11254
+ if (totalChangedLines > 40) {
11255
+ return {
11256
+ title: t(locale, "detail.autoPilot.manualTitle"),
11257
+ body: t(locale, "detail.autoPilot.writeSourceTooLarge"),
11258
+ };
11259
+ }
11260
+ if (hasUnsafeSourceWriteDiff(diffText)) {
11261
+ return {
11262
+ title: t(locale, "detail.autoPilot.manualTitle"),
11263
+ body: t(locale, "detail.autoPilot.writeSourceUnsafe"),
11264
+ };
11265
+ }
11266
+ if (!hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs })) {
11267
+ return {
11268
+ title: t(locale, "detail.autoPilot.manualTitle"),
11269
+ body: t(locale, "detail.autoPilot.writeSourceContinuity"),
11270
+ };
11271
+ }
11272
+ return {
11273
+ title: t(locale, "detail.autoPilot.manualTitle"),
11274
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
11275
+ };
11276
+ }
11277
+
11278
+ return {
11279
+ title: t(locale, "detail.autoPilot.manualTitle"),
11280
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
11281
+ };
11282
+ }
11283
+
11284
+ function buildPreviousApprovalContext(runtime, approval) {
11285
+ const threadId = cleanText(approval?.conversationId || "");
11286
+ const approvalCreatedAtMs = Number(approval?.createdAtMs) || 0;
11287
+ if (!threadId || !approvalCreatedAtMs) {
11288
+ return null;
11289
+ }
11290
+
11291
+ const previousEntry = runtime.recentTimelineEntries
11292
+ .filter((entry) => {
11293
+ if (!timelineMessageKinds.has(entry.kind)) {
11294
+ return false;
11295
+ }
11296
+ if (cleanText(entry.threadId || "") !== threadId) {
11297
+ return false;
11298
+ }
11299
+ return Number(entry.createdAtMs) > 0 && Number(entry.createdAtMs) < approvalCreatedAtMs;
11300
+ })
11301
+ .sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0))[0];
11302
+
11303
+ if (!previousEntry) {
11304
+ return null;
11305
+ }
11306
+
11307
+ const sourceText = normalizeLongText(previousEntry.messageText || previousEntry.summary || "");
11308
+ if (!sourceText) {
11309
+ return null;
11310
+ }
11311
+
11312
+ return {
11313
+ kind: previousEntry.kind,
11314
+ createdAtMs: Number(previousEntry.createdAtMs) || 0,
11315
+ messageHtml: renderMessageHtml(sourceText, "<p></p>"),
11316
+ };
11317
+ }
11318
+
11319
+ function buildInterruptedTimelineContext(runtime, entry, locale) {
11320
+ if (!runtime || !isTurnAbortedDisplayMessage(entry?.messageText)) {
11321
+ return null;
11322
+ }
11323
+
11324
+ const threadId = cleanText(entry?.threadId || "");
11325
+ const interruptedCreatedAtMs = Number(entry?.createdAtMs) || 0;
11326
+ if (!threadId || !interruptedCreatedAtMs) {
11327
+ return null;
11328
+ }
11329
+
11330
+ const previousEntry = runtime.recentTimelineEntries
11331
+ .filter((candidate) => {
11332
+ if (!timelineMessageKinds.has(cleanText(candidate?.kind || ""))) {
11333
+ return false;
11334
+ }
11335
+ if (cleanText(candidate?.threadId || "") !== threadId) {
11336
+ return false;
11337
+ }
11338
+ if (Number(candidate?.createdAtMs) <= 0 || Number(candidate?.createdAtMs) >= interruptedCreatedAtMs) {
11339
+ return false;
11340
+ }
11341
+ return !isTurnAbortedDisplayMessage(candidate?.messageText);
11342
+ })
11343
+ .sort((left, right) => Number(right?.createdAtMs ?? 0) - Number(left?.createdAtMs ?? 0))[0];
11344
+
11345
+ if (!previousEntry) {
11346
+ return null;
11347
+ }
11348
+
11349
+ const sourceText = normalizeLongText(previousEntry.messageText || previousEntry.summary || "");
11350
+ if (!sourceText) {
11351
+ return null;
11352
+ }
11353
+
11354
+ return {
11355
+ kind: previousEntry.kind,
10106
11356
  label: t(locale, "detail.interruptedTask"),
10107
11357
  createdAtMs: Number(previousEntry.createdAtMs) || 0,
10108
11358
  messageHtml: renderMessageHtml(sourceText, "<p></p>"),
@@ -10951,46 +12201,572 @@ function claudeToolFingerprint(toolName, toolInput) {
10951
12201
  }
10952
12202
  }
10953
12203
 
10954
- function formatClaudeQuestionAnswers(questions, answers) {
10955
- if (!Array.isArray(questions) || !Array.isArray(answers)) return "";
10956
- const lines = [];
10957
- for (let i = 0; i < questions.length; i++) {
10958
- const q = questions[i] || {};
10959
- const ans = answers[i] || {};
10960
- const questionText = String(q.question || q.header || "").trim();
10961
- if (!questionText) continue;
10962
- const options = Array.isArray(q.options) ? q.options : [];
10963
- const optionIndices = Array.isArray(ans.optionIndices) ? ans.optionIndices : [];
10964
- const labels = optionIndices
10965
- .map((idx) => options[idx]?.label)
10966
- .filter((label) => typeof label === "string" && label.length > 0);
10967
- const note = typeof ans.note === "string" ? ans.note.trim() : "";
10968
- let answerLine = "";
10969
- if (labels.length > 0) {
10970
- answerLine = labels.join(", ");
10971
- }
10972
- if (note) {
10973
- answerLine = answerLine ? `${answerLine} (note: ${note})` : note;
10974
- }
10975
- if (!answerLine) {
10976
- answerLine = "(no answer)";
10977
- }
10978
- lines.push(`Q${i + 1}: ${questionText}`);
10979
- lines.push(`A: ${answerLine}`);
12204
+ function formatClaudeQuestionAnswers(questions, answers) {
12205
+ if (!Array.isArray(questions) || !Array.isArray(answers)) return "";
12206
+ const lines = [];
12207
+ for (let i = 0; i < questions.length; i++) {
12208
+ const q = questions[i] || {};
12209
+ const ans = answers[i] || {};
12210
+ const questionText = String(q.question || q.header || "").trim();
12211
+ if (!questionText) continue;
12212
+ const options = Array.isArray(q.options) ? q.options : [];
12213
+ const optionIndices = Array.isArray(ans.optionIndices) ? ans.optionIndices : [];
12214
+ const labels = optionIndices
12215
+ .map((idx) => options[idx]?.label)
12216
+ .filter((label) => typeof label === "string" && label.length > 0);
12217
+ const note = typeof ans.note === "string" ? ans.note.trim() : "";
12218
+ let answerLine = "";
12219
+ if (labels.length > 0) {
12220
+ answerLine = labels.join(", ");
12221
+ }
12222
+ if (note) {
12223
+ answerLine = answerLine ? `${answerLine} (note: ${note})` : note;
12224
+ }
12225
+ if (!answerLine) {
12226
+ answerLine = "(no answer)";
12227
+ }
12228
+ lines.push(`Q${i + 1}: ${questionText}`);
12229
+ lines.push(`A: ${answerLine}`);
12230
+ }
12231
+ return lines.join("\n");
12232
+ }
12233
+
12234
+ function findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint) {
12235
+ let match = null;
12236
+ for (const approval of runtime.nativeApprovalsByToken.values()) {
12237
+ if (approval.resolved) continue;
12238
+ if (approval.conversationId !== threadId) continue;
12239
+ if (approval.claudeToolName && toolName && approval.claudeToolName !== toolName) continue;
12240
+ if (approval.claudeToolFingerprint && fingerprint && approval.claudeToolFingerprint !== fingerprint) continue;
12241
+ if (!match || approval.createdAtMs > match.createdAtMs) match = approval;
12242
+ }
12243
+ return match;
12244
+ }
12245
+
12246
+ function maybeAutoApproveTrustedRead({ state, commandText, cwd, workspaceRoot }) {
12247
+ if (state?.autoPilotTrustedReads !== true) {
12248
+ return null;
12249
+ }
12250
+ return evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot });
12251
+ }
12252
+
12253
+ function isAutoPilotTrustedWritesEnabled(state) {
12254
+ return hasAnyAutoPilotWriteLaneEnabled(state);
12255
+ }
12256
+
12257
+ function isDeniedTrustedWritePath(candidatePath) {
12258
+ const normalized = cleanText(candidatePath || "");
12259
+ if (!normalized) {
12260
+ return true;
12261
+ }
12262
+ const lower = normalized.toLowerCase();
12263
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
12264
+ const basename = segments[segments.length - 1] || "";
12265
+ if (isSensitiveCommandPath(normalized)) {
12266
+ return true;
12267
+ }
12268
+ if (segments.some((segment) => [".github", ".gitlab", ".terraform", ".claude", ".husky", ".vscode"].includes(segment))) {
12269
+ return true;
12270
+ }
12271
+ if (
12272
+ [
12273
+ "package.json",
12274
+ "package-lock.json",
12275
+ "pnpm-lock.yaml",
12276
+ "yarn.lock",
12277
+ "bun.lockb",
12278
+ "cargo.toml",
12279
+ "cargo.lock",
12280
+ "gemfile",
12281
+ "gemfile.lock",
12282
+ "podfile",
12283
+ "podfile.lock",
12284
+ "composer.json",
12285
+ "composer.lock",
12286
+ "pipfile",
12287
+ "pipfile.lock",
12288
+ "poetry.lock",
12289
+ "requirements.txt",
12290
+ "dockerfile",
12291
+ "wrangler.toml",
12292
+ "tsconfig.json",
12293
+ "tsconfig.tsbuildinfo",
12294
+ ].includes(basename)
12295
+ ) {
12296
+ return true;
12297
+ }
12298
+ return false;
12299
+ }
12300
+
12301
+ function isAutoPilotContentWritePath(candidatePath) {
12302
+ const normalized = cleanText(candidatePath || "");
12303
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
12304
+ return false;
12305
+ }
12306
+ const lower = normalized.toLowerCase();
12307
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
12308
+ const basename = segments[segments.length - 1] || "";
12309
+ const extension = path.extname(basename);
12310
+ if ([".md", ".mdx", ".txt", ".rst", ".adoc"].includes(extension)) {
12311
+ return true;
12312
+ }
12313
+ if (["license", "notice", "copying", "readme", "changelog", "contributing"].includes(path.basename(basename, extension))) {
12314
+ return true;
12315
+ }
12316
+ if (segments.includes("i18n") && [".js", ".ts", ".json", ".yaml", ".yml"].includes(extension)) {
12317
+ return true;
12318
+ }
12319
+ if (segments.includes("messages") && extension === ".json") {
12320
+ return true;
12321
+ }
12322
+ return false;
12323
+ }
12324
+
12325
+ function isAutoPilotUiTestWritePath(candidatePath) {
12326
+ const normalized = cleanText(candidatePath || "");
12327
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
12328
+ return false;
12329
+ }
12330
+ const lower = normalized.toLowerCase();
12331
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
12332
+ const basename = segments[segments.length - 1] || "";
12333
+ const extension = path.extname(basename);
12334
+ if ([".css", ".scss", ".sass", ".less", ".styl", ".pcss"].includes(extension)) {
12335
+ return true;
12336
+ }
12337
+ if (segments.includes("__tests__") || /\.(test|spec)\.[cm]?[jt]sx?$/u.test(basename)) {
12338
+ return true;
12339
+ }
12340
+ if ((segments.includes("web") || segments.includes("components")) && [".js", ".jsx", ".ts", ".tsx", ".html"].includes(extension)) {
12341
+ return true;
12342
+ }
12343
+ return false;
12344
+ }
12345
+
12346
+ function isAutoPilotSourceWritePath(candidatePath) {
12347
+ const normalized = cleanText(candidatePath || "");
12348
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
12349
+ return false;
12350
+ }
12351
+ if (isAutoPilotContentWritePath(normalized) || isAutoPilotUiTestWritePath(normalized)) {
12352
+ return false;
12353
+ }
12354
+ const extension = path.extname(normalized.toLowerCase());
12355
+ return [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"].includes(extension);
12356
+ }
12357
+
12358
+ function extractAddedDiffLines(diffText) {
12359
+ return normalizeTimelineDiffText(diffText)
12360
+ .split("\n")
12361
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"));
12362
+ }
12363
+
12364
+ function addedDiffLinesContain(diffText, pattern) {
12365
+ return extractAddedDiffLines(diffText).some((line) => pattern.test(line.slice(1)));
12366
+ }
12367
+
12368
+ function hasUnsafeUiOrTestWriteDiff(diffText) {
12369
+ const patterns = [
12370
+ /\bprocess\.env\b/u,
12371
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
12372
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
12373
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
12374
+ /\bcrypto\b/u,
12375
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
12376
+ ];
12377
+ return (
12378
+ addedDiffLinesContain(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
12379
+ addedDiffLinesContain(diffText, /\brequire\s*\(/u) ||
12380
+ patterns.some((pattern) => addedDiffLinesContain(diffText, pattern))
12381
+ );
12382
+ }
12383
+
12384
+ function hasUnsafeSourceWriteDiff(diffText) {
12385
+ const patterns = [
12386
+ /\bprocess\.env\b/u,
12387
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
12388
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
12389
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
12390
+ /\b(?:net|tls|dgram|http2?)\b/u,
12391
+ /\bcrypto\b/u,
12392
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
12393
+ ];
12394
+ return (
12395
+ addedDiffLinesContain(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
12396
+ addedDiffLinesContain(diffText, /\brequire\s*\(/u) ||
12397
+ patterns.some((pattern) => addedDiffLinesContain(diffText, pattern))
12398
+ );
12399
+ }
12400
+
12401
+ const AUTO_PILOT_SOURCE_CONTINUITY_WINDOW_MS = 15 * 60 * 1000;
12402
+
12403
+ function isReadContinuityTimelineEntry(entry) {
12404
+ if (!isPlainObject(entry)) {
12405
+ return false;
12406
+ }
12407
+ if (cleanText(entry.kind || "") === "file_event" && cleanText(entry.fileEventType || "") === "read") {
12408
+ return true;
12409
+ }
12410
+ return (
12411
+ cleanText(entry.kind || "") === "approval" &&
12412
+ cleanText(entry.outcome || "") === "approved" &&
12413
+ !normalizeTimelineDiffText(entry.diffText ?? "") &&
12414
+ normalizeTimelineFileRefs(entry.fileRefs ?? []).length > 0
12415
+ );
12416
+ }
12417
+
12418
+ function resolveRecentReadContinuityFileRefs({ entry, cwd, workspaceRoot }) {
12419
+ const normalizedRefs = normalizeTimelineFileRefs(entry?.fileRefs ?? []);
12420
+ if (normalizedRefs.length === 0) {
12421
+ return [];
12422
+ }
12423
+ const resolvedRefs = [];
12424
+ for (const fileRef of normalizedRefs) {
12425
+ const resolved = resolveTrustedReadTargetPath({
12426
+ token: fileRef,
12427
+ cwd,
12428
+ workspaceRoot,
12429
+ });
12430
+ if (resolved) {
12431
+ resolvedRefs.push(resolved);
12432
+ }
12433
+ }
12434
+ return normalizeTimelineFileRefs(resolvedRefs);
12435
+ }
12436
+
12437
+ function hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs }) {
12438
+ const normalizedTargets = normalizeTimelineFileRefs(resolvedFileRefs ?? []);
12439
+ if (normalizedTargets.length !== 1) {
12440
+ return false;
12441
+ }
12442
+ const targetFileRef = normalizedTargets[0];
12443
+ const conversationId = cleanText(approval?.conversationId || "");
12444
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
12445
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
12446
+ if (!cwd || !workspaceRoot) {
12447
+ return false;
12448
+ }
12449
+ const timelineEntries =
12450
+ Array.isArray(runtime?.recentTimelineEntries) && runtime.recentTimelineEntries.length > 0
12451
+ ? runtime.recentTimelineEntries
12452
+ : normalizeTimelineEntries(state?.recentTimelineEntries ?? [], 40);
12453
+ const now = Date.now();
12454
+ for (const entry of timelineEntries) {
12455
+ if (!isReadContinuityTimelineEntry(entry)) {
12456
+ continue;
12457
+ }
12458
+ const createdAtMs = Number(entry?.createdAtMs) || 0;
12459
+ if (!createdAtMs || now - createdAtMs > AUTO_PILOT_SOURCE_CONTINUITY_WINDOW_MS) {
12460
+ continue;
12461
+ }
12462
+ const entryThreadId = cleanText(entry?.threadId || extractConversationIdFromStableId(entry?.stableId) || "");
12463
+ if (conversationId && entryThreadId && entryThreadId !== conversationId) {
12464
+ continue;
12465
+ }
12466
+ const entryFileRefs = resolveRecentReadContinuityFileRefs({
12467
+ entry,
12468
+ cwd,
12469
+ workspaceRoot,
12470
+ });
12471
+ if (entryFileRefs.includes(targetFileRef)) {
12472
+ return true;
12473
+ }
12474
+ }
12475
+ return false;
12476
+ }
12477
+
12478
+ function classifyTrustedWriteLane({ runtime, state, approval, resolvedFileRefs, diffText, totalChangedLines }) {
12479
+ const lanes = autoPilotWriteLaneState(state);
12480
+ if (
12481
+ lanes.content &&
12482
+ resolvedFileRefs.length >= 1 &&
12483
+ resolvedFileRefs.length <= 3 &&
12484
+ totalChangedLines <= 120 &&
12485
+ resolvedFileRefs.every((fileRef) => isAutoPilotContentWritePath(fileRef))
12486
+ ) {
12487
+ return "content";
12488
+ }
12489
+ if (
12490
+ lanes.uiTests &&
12491
+ resolvedFileRefs.length >= 1 &&
12492
+ resolvedFileRefs.length <= 2 &&
12493
+ totalChangedLines <= 80 &&
12494
+ resolvedFileRefs.every((fileRef) => isAutoPilotUiTestWritePath(fileRef)) &&
12495
+ !hasUnsafeUiOrTestWriteDiff(diffText)
12496
+ ) {
12497
+ return "ui_tests";
12498
+ }
12499
+ if (
12500
+ lanes.source &&
12501
+ resolvedFileRefs.length === 1 &&
12502
+ totalChangedLines <= 40 &&
12503
+ resolvedFileRefs.every((fileRef) => isAutoPilotSourceWritePath(fileRef)) &&
12504
+ !hasUnsafeSourceWriteDiff(diffText) &&
12505
+ hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs })
12506
+ ) {
12507
+ return "source";
12508
+ }
12509
+ return "";
12510
+ }
12511
+
12512
+ function resolveTrustedWriteFileRefs({ fileRefs, cwd, workspaceRoot }) {
12513
+ const normalizedRefs = normalizeTimelineFileRefs(fileRefs ?? []);
12514
+ if (normalizedRefs.length === 0 || normalizedRefs.length > 3) {
12515
+ return null;
12516
+ }
12517
+ const resolvedRefs = [];
12518
+ for (const fileRef of normalizedRefs) {
12519
+ const resolved = resolveTrustedReadTargetPath({
12520
+ token: fileRef,
12521
+ cwd,
12522
+ workspaceRoot,
12523
+ });
12524
+ if (!resolved || isDeniedTrustedWritePath(resolved)) {
12525
+ return null;
12526
+ }
12527
+ resolvedRefs.push(resolved);
12528
+ }
12529
+ return normalizeTimelineFileRefs(resolvedRefs);
12530
+ }
12531
+
12532
+ function sameNormalizedFileRefSet(leftRefs, rightRefs) {
12533
+ const left = normalizeTimelineFileRefs(leftRefs ?? []);
12534
+ const right = normalizeTimelineFileRefs(rightRefs ?? []);
12535
+ if (left.length !== right.length) {
12536
+ return false;
12537
+ }
12538
+ const rightSet = new Set(right);
12539
+ return left.every((value) => rightSet.has(value));
12540
+ }
12541
+
12542
+ function resolveTrustedWriteDiffSectionFileRefs({ diffSections, cwd, workspaceRoot }) {
12543
+ const resolvedSections = [];
12544
+ for (const section of diffSections) {
12545
+ const paths = extractUnifiedDiffSectionPaths(section);
12546
+ const sectionFileRef = cleanText(paths.newFileRef || paths.oldFileRef || "");
12547
+ if (!sectionFileRef) {
12548
+ return null;
12549
+ }
12550
+ if (paths.oldFileRef && paths.newFileRef && cleanText(paths.oldFileRef) !== cleanText(paths.newFileRef)) {
12551
+ return null;
12552
+ }
12553
+ const resolved = resolveTrustedDiffSectionPath({
12554
+ token: sectionFileRef,
12555
+ cwd,
12556
+ workspaceRoot,
12557
+ });
12558
+ if (!resolved || isDeniedTrustedWritePath(resolved)) {
12559
+ return null;
12560
+ }
12561
+ resolvedSections.push(resolved);
12562
+ }
12563
+ return normalizeTimelineFileRefs(resolvedSections);
12564
+ }
12565
+
12566
+ function evaluateTrustedWriteApprovalCandidate({ runtime, state, approval }) {
12567
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
12568
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
12569
+ const resolvedFileRefs = resolveTrustedWriteFileRefs({
12570
+ fileRefs: approval?.fileRefs ?? [],
12571
+ cwd,
12572
+ workspaceRoot,
12573
+ });
12574
+ const diffText = normalizeTimelineDiffText(approval?.diffText ?? "");
12575
+ if (!cwd || !workspaceRoot || !resolvedFileRefs || !diffText) {
12576
+ return null;
12577
+ }
12578
+ const counts = diffLineCounts(diffText);
12579
+ const totalChangedLines =
12580
+ Math.max(0, Number(counts.addedLines) || 0) +
12581
+ Math.max(0, Number(counts.removedLines) || 0);
12582
+ if (totalChangedLines === 0 || totalChangedLines > 120) {
12583
+ return null;
12584
+ }
12585
+ const diffSections = splitUnifiedDiffTextByFile(diffText);
12586
+ if (diffSections.length === 0 || diffSections.length > 3) {
12587
+ return null;
12588
+ }
12589
+ if (
12590
+ /^(?:new file mode|deleted file mode|rename from|rename to|old mode|new mode|similarity index|dissimilarity index|GIT binary patch|Binary files )/mu.test(diffText)
12591
+ ) {
12592
+ return null;
12593
+ }
12594
+ const diffSectionRefs = resolveTrustedWriteDiffSectionFileRefs({
12595
+ diffSections,
12596
+ cwd,
12597
+ workspaceRoot,
12598
+ });
12599
+ if (!diffSectionRefs || !sameNormalizedFileRefSet(diffSectionRefs, resolvedFileRefs)) {
12600
+ return null;
12601
+ }
12602
+ const lane = classifyTrustedWriteLane({
12603
+ runtime,
12604
+ state: state ?? approval?.stateSnapshot ?? null,
12605
+ approval,
12606
+ resolvedFileRefs: diffSectionRefs,
12607
+ diffText,
12608
+ totalChangedLines,
12609
+ });
12610
+ if (!lane) {
12611
+ return null;
12612
+ }
12613
+ return {
12614
+ fileRefs: diffSectionRefs,
12615
+ diffText,
12616
+ diffAddedLines: Math.max(0, Number(counts.addedLines) || 0),
12617
+ diffRemovedLines: Math.max(0, Number(counts.removedLines) || 0),
12618
+ totalChangedLines,
12619
+ lane,
12620
+ };
12621
+ }
12622
+
12623
+ function maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval }) {
12624
+ if (!isAutoPilotTrustedWritesEnabled(state)) {
12625
+ return null;
12626
+ }
12627
+ if (cleanText(approval?.kind || "") !== "file") {
12628
+ return null;
12629
+ }
12630
+ return evaluateTrustedWriteApprovalCandidate({
12631
+ runtime,
12632
+ state,
12633
+ approval: { ...approval, stateSnapshot: state },
12634
+ });
12635
+ }
12636
+
12637
+ function autoPilotTrustedWriteMessage(locale, approval, candidate) {
12638
+ const prefix = t(locale, "server.message.autoPilotTrustedWriteApproved");
12639
+ const fileBlock = candidate?.fileRefs?.length
12640
+ ? `${t(locale, "server.message.filesLabel")}\n\`\`\`text\n${candidate.fileRefs.join("\n")}\n\`\`\``
12641
+ : "";
12642
+ const sizeLine =
12643
+ candidate && Number.isFinite(candidate.diffAddedLines) && Number.isFinite(candidate.diffRemovedLines)
12644
+ ? `${t(locale, "server.message.diffSummaryLabel")} +${candidate.diffAddedLines} / -${candidate.diffRemovedLines}`
12645
+ : "";
12646
+ const originalMessage = cleanText(approval?.messageText || "");
12647
+ return [prefix, sizeLine, fileBlock, originalMessage].filter(Boolean).join("\n\n");
12648
+ }
12649
+
12650
+ async function recordAutoApprovedTrustedRead({
12651
+ config,
12652
+ runtime,
12653
+ state,
12654
+ approval,
12655
+ policyMatch,
12656
+ }) {
12657
+ const stateChanged = recordActionHistoryItem({
12658
+ config,
12659
+ runtime,
12660
+ state,
12661
+ kind: "approval",
12662
+ stableId: `approval:${approval.requestKey}:autopilot`,
12663
+ token: approval.token,
12664
+ title: approval.title,
12665
+ threadLabel: approval.threadLabel || "",
12666
+ messageText: autoPilotApprovalMessage(config.defaultLocale, policyMatch.commandText),
12667
+ summary: t(config.defaultLocale, "server.message.autoPilotTrustedReadSummary"),
12668
+ fileRefs: normalizeTimelineFileRefs(policyMatch.fileRefs ?? approval.fileRefs ?? []),
12669
+ diffText: "",
12670
+ diffSource: "",
12671
+ diffAvailable: false,
12672
+ diffAddedLines: 0,
12673
+ diffRemovedLines: 0,
12674
+ outcome: "approved",
12675
+ provider: approval.provider || "codex",
12676
+ });
12677
+ if (stateChanged) {
12678
+ await saveState(config.stateFile, state);
12679
+ }
12680
+ }
12681
+
12682
+ async function recordAutoApprovedTrustedWrite({
12683
+ config,
12684
+ runtime,
12685
+ state,
12686
+ approval,
12687
+ candidate,
12688
+ }) {
12689
+ const stateChanged = recordActionHistoryItem({
12690
+ config,
12691
+ runtime,
12692
+ state,
12693
+ kind: "approval",
12694
+ stableId: `approval:${approval.requestKey}:autopilot-write:${candidate.lane || "content"}`,
12695
+ token: approval.token,
12696
+ title: approval.title,
12697
+ threadLabel: approval.threadLabel || "",
12698
+ messageText: autoPilotTrustedWriteMessage(config.defaultLocale, approval, candidate),
12699
+ summary: t(config.defaultLocale, "server.message.autoPilotTrustedWriteSummary"),
12700
+ fileRefs: candidate.fileRefs,
12701
+ diffText: candidate.diffText,
12702
+ diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
12703
+ diffAvailable: true,
12704
+ diffAddedLines: candidate.diffAddedLines,
12705
+ diffRemovedLines: candidate.diffRemovedLines,
12706
+ outcome: "approved",
12707
+ provider: approval.provider || "codex",
12708
+ });
12709
+ if (stateChanged) {
12710
+ await saveState(config.stateFile, state);
12711
+ }
12712
+ }
12713
+
12714
+ async function autoApproveTrustedRead({
12715
+ config,
12716
+ runtime,
12717
+ state,
12718
+ approval,
12719
+ policyMatch,
12720
+ }) {
12721
+ if (approval.provider === "claude" && approval.resolveClaudeWaiter) {
12722
+ approval.resolveClaudeWaiter({
12723
+ permissionDecision: "allow",
12724
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedReadReason"),
12725
+ });
12726
+ } else if (approval.provider !== "claude") {
12727
+ await runtime.ipcClient?.sendApprovalDecision(approval, "accept");
10980
12728
  }
10981
- return lines.join("\n");
12729
+ approval.resolved = true;
12730
+ approval.resolving = false;
12731
+ runtime.nativeApprovalsByToken.delete(approval.token);
12732
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
12733
+ await recordAutoApprovedTrustedRead({
12734
+ config,
12735
+ runtime,
12736
+ state,
12737
+ approval,
12738
+ policyMatch,
12739
+ });
12740
+ console.log(`[auto-pilot-approved] ${approval.requestKey} | ${policyMatch.command}`);
10982
12741
  }
10983
12742
 
10984
- function findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint) {
10985
- let match = null;
10986
- for (const approval of runtime.nativeApprovalsByToken.values()) {
10987
- if (approval.resolved) continue;
10988
- if (approval.conversationId !== threadId) continue;
10989
- if (approval.claudeToolName && toolName && approval.claudeToolName !== toolName) continue;
10990
- if (approval.claudeToolFingerprint && fingerprint && approval.claudeToolFingerprint !== fingerprint) continue;
10991
- if (!match || approval.createdAtMs > match.createdAtMs) match = approval;
12743
+ async function autoApproveTrustedWrite({
12744
+ config,
12745
+ runtime,
12746
+ state,
12747
+ approval,
12748
+ candidate,
12749
+ }) {
12750
+ if (approval.provider === "claude" && approval.resolveClaudeWaiter) {
12751
+ approval.resolveClaudeWaiter({
12752
+ permissionDecision: "allow",
12753
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedWriteReason"),
12754
+ });
12755
+ } else if (approval.provider !== "claude") {
12756
+ await runtime.ipcClient?.sendApprovalDecision(approval, "accept");
10992
12757
  }
10993
- return match;
12758
+ approval.resolved = true;
12759
+ approval.resolving = false;
12760
+ runtime.nativeApprovalsByToken.delete(approval.token);
12761
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
12762
+ await recordAutoApprovedTrustedWrite({
12763
+ config,
12764
+ runtime,
12765
+ state,
12766
+ approval,
12767
+ candidate,
12768
+ });
12769
+ console.log(`[auto-pilot-approved-write] ${approval.requestKey} | files=${candidate.fileRefs.length} | lines=${candidate.totalChangedLines}`);
10994
12770
  }
10995
12771
 
10996
12772
  async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
@@ -11047,7 +12823,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
11047
12823
 
11048
12824
  async function buildApiItemDetail({ config, runtime, state, kind, token, locale }) {
11049
12825
  if (kind === "diff_thread") {
11050
- const group = (await buildDiffThreadGroups(runtime, state, config)).find((entry) => entry.token === token);
12826
+ const group = (await getDiffThreadGroupsCached(runtime, state, config)).find((entry) => entry.token === token);
11051
12827
  return group ? buildDiffThreadDetail(group, locale) : null;
11052
12828
  }
11053
12829
  if (kind === "file_event") {
@@ -11113,7 +12889,7 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
11113
12889
  if (kind === "approval") {
11114
12890
  const approval = runtime.nativeApprovalsByToken.get(token);
11115
12891
  if (approval) {
11116
- return buildPendingApprovalDetail(runtime, approval, locale);
12892
+ return buildPendingApprovalDetail(runtime, state, approval, locale);
11117
12893
  }
11118
12894
  const historicalApproval = historyItemByToken(runtime, kind, token);
11119
12895
  return historicalApproval ? buildHistoryDetail(historicalApproval, locale, runtime) : null;
@@ -11343,21 +13119,81 @@ function contentTypeForFile(filePath) {
11343
13119
  }
11344
13120
  }
11345
13121
 
11346
- async function serveWebAsset(res, urlPath) {
13122
+ // Per-file cache of {raw, gzip, mtimeMs}. Gzip of the ~464KB app shell
13123
+ // (app.js 278KB + app.css 76KB + i18n.js 110KB) takes a few ms to compute,
13124
+ // and without this the bridge would recompress on every request. Keyed by
13125
+ // resolved filePath so /app and /index.html share a cache slot.
13126
+ const WEB_ASSET_CACHE = new Map();
13127
+ const GZIPPABLE_EXTENSIONS = new Set([
13128
+ ".js",
13129
+ ".mjs",
13130
+ ".css",
13131
+ ".html",
13132
+ ".htm",
13133
+ ".json",
13134
+ ".webmanifest",
13135
+ ".svg",
13136
+ ".txt",
13137
+ ".map",
13138
+ ]);
13139
+ const MIN_GZIP_BYTES = 512;
13140
+
13141
+ async function loadWebAssetEntry(filePath) {
13142
+ const stat = await fs.stat(filePath);
13143
+ const cached = WEB_ASSET_CACHE.get(filePath);
13144
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
13145
+ return cached;
13146
+ }
13147
+ const raw = await fs.readFile(filePath);
13148
+ const extension = path.extname(filePath).toLowerCase();
13149
+ let gzip = null;
13150
+ if (GZIPPABLE_EXTENSIONS.has(extension) && raw.length >= MIN_GZIP_BYTES) {
13151
+ try {
13152
+ gzip = zlib.gzipSync(raw, { level: 6 });
13153
+ } catch {
13154
+ gzip = null;
13155
+ }
13156
+ }
13157
+ const entry = {
13158
+ raw,
13159
+ gzip,
13160
+ mtimeMs: stat.mtimeMs,
13161
+ size: stat.size,
13162
+ contentType: contentTypeForFile(filePath),
13163
+ };
13164
+ WEB_ASSET_CACHE.set(filePath, entry);
13165
+ return entry;
13166
+ }
13167
+
13168
+ function clientAcceptsGzip(req) {
13169
+ const header = req?.headers?.["accept-encoding"];
13170
+ if (!header) return false;
13171
+ const value = Array.isArray(header) ? header.join(",") : String(header);
13172
+ return /\bgzip\b/i.test(value);
13173
+ }
13174
+
13175
+ async function serveWebAsset(res, urlPath, req) {
11347
13176
  const filePath = resolveWebAsset(urlPath);
11348
13177
  if (!filePath) {
11349
13178
  return false;
11350
13179
  }
11351
13180
 
11352
13181
  try {
11353
- const body = await fs.readFile(filePath);
13182
+ const entry = await loadWebAssetEntry(filePath);
13183
+ const wantsGzip = entry.gzip && clientAcceptsGzip(req);
11354
13184
  res.statusCode = 200;
11355
- res.setHeader("Content-Type", contentTypeForFile(filePath));
13185
+ res.setHeader("Content-Type", entry.contentType);
11356
13186
  res.setHeader("Cache-Control", "no-store, max-age=0");
11357
13187
  if (urlPath === "/sw.js") {
11358
13188
  res.setHeader("Service-Worker-Allowed", "/");
11359
13189
  }
11360
- res.end(body);
13190
+ if (wantsGzip) {
13191
+ res.setHeader("Content-Encoding", "gzip");
13192
+ res.setHeader("Vary", "Accept-Encoding");
13193
+ res.end(entry.gzip);
13194
+ } else {
13195
+ res.end(entry.raw);
13196
+ }
11361
13197
  return true;
11362
13198
  } catch {
11363
13199
  return false;
@@ -11489,10 +13325,11 @@ function createNativeApprovalServer({ config, runtime, state }) {
11489
13325
  url.pathname === "/app.js" ||
11490
13326
  url.pathname === "/app.css" ||
11491
13327
  url.pathname === "/i18n.js" ||
13328
+ url.pathname === "/hazbase-passkey.js" ||
11492
13329
  url.pathname === "/sw.js" ||
11493
13330
  url.pathname.startsWith("/icons/")
11494
13331
  ) {
11495
- const served = await serveWebAsset(res, url.pathname);
13332
+ const served = await serveWebAsset(res, url.pathname, req);
11496
13333
  if (served) {
11497
13334
  return;
11498
13335
  }
@@ -11632,16 +13469,41 @@ function createNativeApprovalServer({ config, runtime, state }) {
11632
13469
  if (session.authenticated && session.restoredFromDevice) {
11633
13470
  setSessionCookie(res, config);
11634
13471
  }
13472
+ return writeJson(res, 200, buildSessionPayload({ config, state, session }));
13473
+ }
13474
+
13475
+ // Collapses the PWA's boot-time fan-out (session + inbox + timeline
13476
+ // + devices) into a single HTTPS round-trip. iOS Safari tears down
13477
+ // connections aggressively, so each parallel fetch pays its own
13478
+ // TLS handshake — and `JSON.stringify(state)` hiccups on other
13479
+ // requests make those handshakes bursty. One endpoint = one
13480
+ // handshake + one response, which drops the "Completed list is
13481
+ // blank for several seconds after launch" latency floor.
13482
+ if (url.pathname === "/api/bootstrap" && req.method === "GET") {
13483
+ const session = readSession(req, config, state);
13484
+ const localeInfo = resolveDeviceLocaleInfo(config, state, session.deviceId);
13485
+ if (session.authenticated && session.deviceId) {
13486
+ const trustChanged = touchDeviceTrust(state, config, session.deviceId);
13487
+ const metadataChanged = updateCurrentDeviceSnapshot(state, config, session.deviceId, {
13488
+ userAgent: requestUserAgent(req),
13489
+ lastLocale: localeInfo.locale,
13490
+ });
13491
+ if (trustChanged || metadataChanged) {
13492
+ await saveState(config.stateFile, state);
13493
+ }
13494
+ }
13495
+ if (session.authenticated && session.restoredFromDevice) {
13496
+ setSessionCookie(res, config);
13497
+ }
13498
+ const sessionPayload = buildSessionPayload({ config, state, session });
13499
+ if (!session.authenticated) {
13500
+ return writeJson(res, 200, { session: sessionPayload });
13501
+ }
11635
13502
  return writeJson(res, 200, {
11636
- authenticated: Boolean(session.authenticated),
11637
- expiresAtMs: Number(session.expiresAtMs) || 0,
11638
- pairingAvailable: isPairingAvailableForState(config, state),
11639
- webPushEnabled: config.webPushEnabled,
11640
- httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
11641
- appVersion: appPackageVersion,
11642
- deviceId: session.deviceId || null,
11643
- temporaryPairing: session.temporaryPairing === true,
11644
- ...buildSessionLocalePayload(config, state, session.deviceId),
13503
+ session: sessionPayload,
13504
+ inbox: buildInboxFastResponse(runtime, state, config, localeInfo.locale),
13505
+ timeline: buildTimelineResponse(runtime, state, config, localeInfo.locale),
13506
+ devices: buildDevicesResponse({ config, state, session, locale: localeInfo.locale }),
11645
13507
  });
11646
13508
  }
11647
13509
 
@@ -11756,7 +13618,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11756
13618
  lastLocale: resolveDeviceLocaleInfo(config, state, session.deviceId).locale,
11757
13619
  }) || changed;
11758
13620
  if (changed) {
11759
- await saveState(config.stateFile, state);
13621
+ scheduleSaveState(config, state);
11760
13622
  }
11761
13623
  return writeJson(res, 200, {
11762
13624
  ok: true,
@@ -11778,12 +13640,80 @@ function createNativeApprovalServer({ config, runtime, state }) {
11778
13640
  const enabled = payload?.enabled === true;
11779
13641
  if (state.claudeAwayMode !== enabled) {
11780
13642
  state.claudeAwayMode = enabled;
11781
- await saveState(config.stateFile, state);
13643
+ scheduleSaveState(config, state);
11782
13644
  }
11783
13645
  await syncClaudeAwayModeSentinel(config, enabled);
11784
13646
  return writeJson(res, 200, { ok: true, enabled });
11785
13647
  }
11786
13648
 
13649
+ if (url.pathname === "/api/settings/auto-pilot" && req.method === "POST") {
13650
+ const session = requireMutatingApiSession(req, res, config, state);
13651
+ if (!session) {
13652
+ return;
13653
+ }
13654
+ let payload;
13655
+ try {
13656
+ payload = await parseJsonBody(req);
13657
+ } catch {
13658
+ return writeJson(res, 400, { error: "invalid-json-body" });
13659
+ }
13660
+ const trustedReadsEnabled =
13661
+ typeof payload?.trustedReadsEnabled === "boolean"
13662
+ ? payload.trustedReadsEnabled === true
13663
+ : state.autoPilotTrustedReads === true;
13664
+ const hasLaneUpdate =
13665
+ typeof payload?.writeLaneContentEnabled === "boolean" ||
13666
+ typeof payload?.writeLaneUiTestsEnabled === "boolean" ||
13667
+ typeof payload?.writeLaneSourceEnabled === "boolean";
13668
+ const legacyTrustedWritesEnabled =
13669
+ typeof payload?.trustedWritesEnabled === "boolean"
13670
+ ? payload.trustedWritesEnabled === true
13671
+ : typeof payload?.trustedWritesShadowEnabled === "boolean"
13672
+ ? payload.trustedWritesShadowEnabled === true
13673
+ : null;
13674
+ const currentLanes = autoPilotWriteLaneState(state);
13675
+ const writeLaneContentEnabled =
13676
+ typeof payload?.writeLaneContentEnabled === "boolean"
13677
+ ? payload.writeLaneContentEnabled === true
13678
+ : !hasLaneUpdate && legacyTrustedWritesEnabled != null
13679
+ ? legacyTrustedWritesEnabled
13680
+ : currentLanes.content;
13681
+ const writeLaneUiTestsEnabled =
13682
+ typeof payload?.writeLaneUiTestsEnabled === "boolean"
13683
+ ? payload.writeLaneUiTestsEnabled === true
13684
+ : currentLanes.uiTests;
13685
+ const writeLaneSourceEnabled =
13686
+ typeof payload?.writeLaneSourceEnabled === "boolean"
13687
+ ? payload.writeLaneSourceEnabled === true
13688
+ : currentLanes.source;
13689
+ const trustedWritesEnabled =
13690
+ writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled;
13691
+ if (
13692
+ state.autoPilotTrustedReads !== trustedReadsEnabled ||
13693
+ state.autoPilotWriteLaneContent !== writeLaneContentEnabled ||
13694
+ state.autoPilotWriteLaneUiTests !== writeLaneUiTestsEnabled ||
13695
+ state.autoPilotWriteLaneSource !== writeLaneSourceEnabled ||
13696
+ isAutoPilotTrustedWritesEnabled(state) !== trustedWritesEnabled
13697
+ ) {
13698
+ state.autoPilotTrustedReads = trustedReadsEnabled;
13699
+ state.autoPilotTrustedWrites = trustedWritesEnabled;
13700
+ state.autoPilotTrustedWritesShadow = false;
13701
+ state.autoPilotWriteLaneContent = writeLaneContentEnabled;
13702
+ state.autoPilotWriteLaneUiTests = writeLaneUiTestsEnabled;
13703
+ state.autoPilotWriteLaneSource = writeLaneSourceEnabled;
13704
+ await saveState(config.stateFile, state);
13705
+ }
13706
+ return writeJson(res, 200, {
13707
+ ok: true,
13708
+ trustedReadsEnabled,
13709
+ trustedWritesEnabled,
13710
+ trustedWritesShadowEnabled: false,
13711
+ writeLaneContentEnabled,
13712
+ writeLaneUiTestsEnabled,
13713
+ writeLaneSourceEnabled,
13714
+ });
13715
+ }
13716
+
11787
13717
  if (url.pathname === "/api/settings/a2a-executor" && req.method === "POST") {
11788
13718
  const session = requireMutatingApiSession(req, res, config, state);
11789
13719
  if (!session) return;
@@ -11797,7 +13727,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11797
13727
  const preference = valid.has(payload?.preference) ? payload.preference : "auto";
11798
13728
  if (state.a2aExecutorPreference !== preference) {
11799
13729
  state.a2aExecutorPreference = preference;
11800
- await saveState(config.stateFile, state);
13730
+ scheduleSaveState(config, state);
11801
13731
  }
11802
13732
  return writeJson(res, 200, { ok: true, preference });
11803
13733
  }
@@ -11967,6 +13897,251 @@ function createNativeApprovalServer({ config, runtime, state }) {
11967
13897
  });
11968
13898
  }
11969
13899
 
13900
+
13901
+ if (url.pathname === "/api/hazbase/status" && req.method === "GET") {
13902
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
13903
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
13904
+ if (!hookAuth) {
13905
+ const session = requireApiSession(req, res, config, state);
13906
+ if (!session) return;
13907
+ }
13908
+ if (!config.hazbaseApiUrl) {
13909
+ return writeJson(res, 200, { enabled: false });
13910
+ }
13911
+ const hazbase = normalizeHazbaseState(state.hazbase);
13912
+ const [paymentsResult, chainsResult] = await Promise.allSettled([
13913
+ fetchHazbaseMetadata(config, "/api/meta/payments"),
13914
+ fetchHazbaseMetadata(config, "/api/meta/chains"),
13915
+ ]);
13916
+ const payments = paymentsResult.status === "fulfilled" ? paymentsResult.value : null;
13917
+ const chains = chainsResult.status === "fulfilled" ? chainsResult.value : null;
13918
+ const errors = [];
13919
+ if (paymentsResult.status === "rejected") {
13920
+ errors.push(paymentsResult.reason?.message || String(paymentsResult.reason || "payments metadata unavailable"));
13921
+ }
13922
+ if (chainsResult.status === "rejected") {
13923
+ errors.push(chainsResult.reason?.message || String(chainsResult.reason || "chains metadata unavailable"));
13924
+ }
13925
+ const supportedChains = Array.isArray(chains?.chains)
13926
+ ? chains.chains.filter((entry) => Number(entry.chainId) === 8453 || Number(entry.chainId) === 84532)
13927
+ : [];
13928
+ const payoutAddresses = {
13929
+ 8453: resolveHazbaseAccountForChain(hazbase.accounts, 8453)?.smartAccountAddress || "",
13930
+ 84532: resolveHazbaseAccountForChain(hazbase.accounts, 84532)?.smartAccountAddress || "",
13931
+ };
13932
+ return writeJson(res, 200, {
13933
+ enabled: true,
13934
+ apiUrl: config.hazbaseApiUrl,
13935
+ signedIn: Boolean(hazbase.accessToken),
13936
+ email: hazbase.email,
13937
+ userId: hazbase.userId,
13938
+ sessionId: hazbase.sessionId,
13939
+ deviceBindingId: hazbase.deviceBindingId,
13940
+ credentialId: hazbase.credentialId,
13941
+ highTrustExpiresAt: hazbase.highTrustExpiresAt,
13942
+ accounts: hazbase.accounts,
13943
+ supportedPayments: payments?.networks || [],
13944
+ supportedChains,
13945
+ defaultPaymentNetwork: payments?.defaultNetwork || "base-sepolia",
13946
+ payoutAddresses,
13947
+ error: errors.join(" | "),
13948
+ });
13949
+ }
13950
+
13951
+ if (url.pathname === "/api/hazbase/payout-address" && req.method === "GET") {
13952
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
13953
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
13954
+ if (!hookAuth) {
13955
+ const session = requireApiSession(req, res, config, state);
13956
+ if (!session) return;
13957
+ }
13958
+ const hazbase = normalizeHazbaseState(state.hazbase);
13959
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
13960
+ const chainId = Number(url.searchParams.get("chainId") || 0);
13961
+ if (chainId !== 8453 && chainId !== 84532) {
13962
+ return writeJson(res, 400, { error: "unsupported-chain" });
13963
+ }
13964
+ const account = resolveHazbaseAccountForChain(hazbase.accounts, chainId);
13965
+ if (!account?.smartAccountAddress) {
13966
+ return writeJson(res, 409, { error: "hazbase-wallet-account-missing" });
13967
+ }
13968
+ return writeJson(res, 200, {
13969
+ chainId,
13970
+ payoutMethod: "hazbase_wallet",
13971
+ payoutAddress: account.smartAccountAddress,
13972
+ smartAccountAddress: account.smartAccountAddress,
13973
+ accountVariant: account.accountVariant || "",
13974
+ relayMode: account.relayMode || "",
13975
+ });
13976
+ }
13977
+
13978
+ if (url.pathname === "/api/hazbase/request-otp" && req.method === "POST") {
13979
+ const session = requireMutatingApiSession(req, res, config, state);
13980
+ if (!session) return;
13981
+ const body = await parseJsonBody(req);
13982
+ const email = cleanText(body?.email || "");
13983
+ if (!email) return writeJson(res, 400, { error: "email-required" });
13984
+ const result = await requestHazbaseEmailOtp({ email });
13985
+ state.hazbase = { ...normalizeHazbaseState(state.hazbase), email };
13986
+ await saveState(config.stateFile, state);
13987
+ return writeJson(res, 200, result);
13988
+ }
13989
+
13990
+ if (url.pathname === "/api/hazbase/verify-otp" && req.method === "POST") {
13991
+ const session = requireMutatingApiSession(req, res, config, state);
13992
+ if (!session) return;
13993
+ const body = await parseJsonBody(req);
13994
+ const email = cleanText(body?.email || state.hazbase?.email || "");
13995
+ const code = cleanText(body?.code || "");
13996
+ if (!email || !code) return writeJson(res, 400, { error: "otp-required" });
13997
+ const result = await verifyHazbaseEmailOtp({ email, code });
13998
+ state.hazbase = {
13999
+ ...normalizeHazbaseState(state.hazbase),
14000
+ email: result.email || email,
14001
+ accessToken: cleanText(result.accessToken || ""),
14002
+ sessionId: cleanText(result.sessionId || ""),
14003
+ userId: cleanText(result.userId || ""),
14004
+ accounts: Array.isArray(result.accounts) ? result.accounts : [],
14005
+ highTrustToken: "",
14006
+ highTrustExpiresAt: "",
14007
+ };
14008
+ await saveState(config.stateFile, state);
14009
+ return writeJson(res, 200, result);
14010
+ }
14011
+
14012
+ if (url.pathname === "/api/hazbase/passkey/register/challenge" && req.method === "POST") {
14013
+ const session = requireMutatingApiSession(req, res, config, state);
14014
+ if (!session) return;
14015
+ const hazbase = normalizeHazbaseState(state.hazbase);
14016
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14017
+ const body = await parseJsonBody(req);
14018
+ let partnerOrigin;
14019
+ try {
14020
+ partnerOrigin = deriveHazbasePartnerPasskeyOrigin(req, config);
14021
+ } catch (error) {
14022
+ return writeJson(res, 400, {
14023
+ error: error?.code || "hazbase-passkey-local-host-required",
14024
+ message: error?.message || "Open viveworker on its .local hostname to use hazBase passkeys.",
14025
+ });
14026
+ }
14027
+ const result = await requestPasskeyRegistrationChallenge({
14028
+ emailSession: hazbase.accessToken,
14029
+ deviceLabel: cleanText(body?.deviceLabel || config.hazbaseDeviceLabel || "") || undefined,
14030
+ partnerOrigin,
14031
+ });
14032
+ return writeJson(res, 200, result);
14033
+ }
14034
+
14035
+ if (url.pathname === "/api/hazbase/passkey/register/complete" && req.method === "POST") {
14036
+ const session = requireMutatingApiSession(req, res, config, state);
14037
+ if (!session) return;
14038
+ const hazbase = normalizeHazbaseState(state.hazbase);
14039
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14040
+ const body = await parseJsonBody(req);
14041
+ const result = await completePasskeyRegistration({
14042
+ emailSession: hazbase.accessToken,
14043
+ challengeId: cleanText(body?.challengeId || ""),
14044
+ credential: body?.credential,
14045
+ deviceLabel: cleanText(body?.deviceLabel || config.hazbaseDeviceLabel || "") || undefined,
14046
+ });
14047
+ state.hazbase = {
14048
+ ...hazbase,
14049
+ deviceBindingId: cleanText(result.deviceBindingId || hazbase.deviceBindingId || ""),
14050
+ credentialId: cleanText(result.credentialId || hazbase.credentialId || ""),
14051
+ };
14052
+ await saveState(config.stateFile, state);
14053
+ return writeJson(res, 200, result);
14054
+ }
14055
+
14056
+ if (url.pathname === "/api/hazbase/passkey/assert/challenge" && req.method === "POST") {
14057
+ const session = requireMutatingApiSession(req, res, config, state);
14058
+ if (!session) return;
14059
+ const hazbase = normalizeHazbaseState(state.hazbase);
14060
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14061
+ const body = await parseJsonBody(req);
14062
+ let partnerOrigin;
14063
+ try {
14064
+ partnerOrigin = deriveHazbasePartnerPasskeyOrigin(req, config);
14065
+ } catch (error) {
14066
+ return writeJson(res, 400, {
14067
+ error: error?.code || "hazbase-passkey-local-host-required",
14068
+ message: error?.message || "Open viveworker on its .local hostname to use hazBase passkeys.",
14069
+ });
14070
+ }
14071
+ const result = await requestPasskeyAssertionChallenge({
14072
+ emailSession: hazbase.accessToken,
14073
+ purpose: cleanText(body?.purpose || "bootstrap") || "bootstrap",
14074
+ deviceBindingId: hazbase.deviceBindingId || undefined,
14075
+ partnerOrigin,
14076
+ });
14077
+ return writeJson(res, 200, result);
14078
+ }
14079
+
14080
+ if (url.pathname === "/api/hazbase/passkey/assert/complete" && req.method === "POST") {
14081
+ const session = requireMutatingApiSession(req, res, config, state);
14082
+ if (!session) return;
14083
+ const hazbase = normalizeHazbaseState(state.hazbase);
14084
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14085
+ const body = await parseJsonBody(req);
14086
+ const result = await completePasskeyAssertion({
14087
+ emailSession: hazbase.accessToken,
14088
+ challengeId: cleanText(body?.challengeId || ""),
14089
+ credential: body?.credential,
14090
+ purpose: cleanText(body?.purpose || "bootstrap") || "bootstrap",
14091
+ deviceBindingId: hazbase.deviceBindingId || undefined,
14092
+ });
14093
+ state.hazbase = {
14094
+ ...hazbase,
14095
+ deviceBindingId: cleanText(result.deviceBindingId || hazbase.deviceBindingId || ""),
14096
+ credentialId: cleanText(result.credentialId || hazbase.credentialId || ""),
14097
+ highTrustToken: cleanText(result.highTrustToken || ""),
14098
+ highTrustExpiresAt: cleanText(result.highTrustExpiresAt || ""),
14099
+ };
14100
+ await saveState(config.stateFile, state);
14101
+ return writeJson(res, 200, result);
14102
+ }
14103
+
14104
+ if (url.pathname === "/api/hazbase/account/bootstrap" && req.method === "POST") {
14105
+ const session = requireMutatingApiSession(req, res, config, state);
14106
+ if (!session) return;
14107
+ const hazbase = normalizeHazbaseState(state.hazbase);
14108
+ if (!hazbase.accessToken || !hazbase.deviceBindingId || !hazbase.highTrustToken) {
14109
+ return writeJson(res, 409, { error: "hazbase-bootstrap-prerequisites-missing" });
14110
+ }
14111
+ const body = await parseJsonBody(req);
14112
+ const chainId = Number(body?.chainId || 0);
14113
+ if (chainId !== 8453 && chainId !== 84532) {
14114
+ return writeJson(res, 400, { error: "unsupported-chain" });
14115
+ }
14116
+ const result = await bootstrapPasskeyAccount({
14117
+ emailSession: hazbase.accessToken,
14118
+ deviceBindingId: hazbase.deviceBindingId,
14119
+ highTrustToken: hazbase.highTrustToken,
14120
+ chainId,
14121
+ });
14122
+ const nextAccounts = mergeHazbaseAccountSummaries(hazbase.accounts, [{
14123
+ smartAccountAddress: result.smartAccountAddress,
14124
+ chainId: result.chainId,
14125
+ accountVariant: result.accountVariant,
14126
+ relayMode: result.relayMode,
14127
+ primaryDeviceBindingId: result.deviceBindingId,
14128
+ }]);
14129
+ state.hazbase = {
14130
+ ...hazbase,
14131
+ accounts: nextAccounts,
14132
+ };
14133
+ await saveState(config.stateFile, state);
14134
+ return writeJson(res, 200, result);
14135
+ }
14136
+
14137
+ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
14138
+ const session = requireMutatingApiSession(req, res, config, state);
14139
+ if (!session) return;
14140
+ state.hazbase = normalizeHazbaseState(null);
14141
+ await saveState(config.stateFile, state);
14142
+ return writeJson(res, 200, { ok: true });
14143
+ }
14144
+
11970
14145
  // Toggle acceptPublicTasks on the A2A relay.
11971
14146
  if (url.pathname === "/api/a2a/public-tasks" && req.method === "POST") {
11972
14147
  const session = requireApiSession(req, res, config, state);
@@ -11975,11 +14150,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11975
14150
  const accept = body?.accept === true;
11976
14151
  config.a2aAcceptPublicTasks = accept;
11977
14152
  state.a2aAcceptPublicTasks = accept;
11978
- try {
11979
- await saveState(config.stateFile, state);
11980
- } catch (error) {
11981
- console.error(`[a2a-public-tasks-save] ${error.message}`);
11982
- }
14153
+ scheduleSaveState(config, state);
11983
14154
  // Re-register with relay to propagate the flag.
11984
14155
  if (config.a2aRelayUrl && config.a2aRelayUserId) {
11985
14156
  try {
@@ -12154,6 +14325,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12154
14325
  await deliverWebPushItem({
12155
14326
  config, state,
12156
14327
  kind: "thread_share",
14328
+ tab: "inbox",
14329
+ subtab: "pending",
12157
14330
  token,
12158
14331
  stableId: `thread_share:${shareId}`,
12159
14332
  title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
@@ -12251,6 +14424,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12251
14424
  await deliverWebPushItem({
12252
14425
  config, state,
12253
14426
  kind: "thread_share",
14427
+ tab: "inbox",
14428
+ subtab: "completed",
12254
14429
  token: share.token,
12255
14430
  stableId: `thread_share_fallback:${share.shareId}`,
12256
14431
  title: "Thread Share: target unreachable",
@@ -12393,7 +14568,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
12393
14568
  lastLocale: resolveDeviceLocaleInfo(config, state, session.deviceId).locale,
12394
14569
  });
12395
14570
  if (changed || metadataChanged) {
12396
- await saveState(config.stateFile, state);
14571
+ scheduleSaveState(config, state);
12397
14572
  }
12398
14573
  return writeJson(res, 200, {
12399
14574
  ok: true,
@@ -12416,7 +14591,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
12416
14591
  changed = deletePushSubscriptionsForDevice(state, session.deviceId) || changed;
12417
14592
  }
12418
14593
  if (changed) {
12419
- await saveState(config.stateFile, state);
14594
+ scheduleSaveState(config, state);
12420
14595
  }
12421
14596
  return writeJson(res, 200, { ok: true, subscribed: false });
12422
14597
  }
@@ -12428,13 +14603,13 @@ function createNativeApprovalServer({ config, runtime, state }) {
12428
14603
  }
12429
14604
  try {
12430
14605
  await sendPushTestToDevice({ config, state, session });
12431
- await saveState(config.stateFile, state);
14606
+ scheduleSaveState(config, state);
12432
14607
  return writeJson(res, 200, { ok: true });
12433
14608
  } catch (error) {
12434
14609
  const statusCode = Number(error?.statusCode) || 0;
12435
14610
  if (statusCode === 404 || statusCode === 410) {
12436
14611
  deletePushSubscriptionsForDevice(state, session.deviceId);
12437
- await saveState(config.stateFile, state);
14612
+ scheduleSaveState(config, state);
12438
14613
  return writeJson(res, 410, { error: "push-subscription-expired" });
12439
14614
  }
12440
14615
  return writeJson(res, 500, {
@@ -12640,6 +14815,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12640
14815
  diffSource: String(body.diffSource || "claude_permission_request"),
12641
14816
  diffAddedLines: Math.max(0, Number(body.diffAddedLines) || 0),
12642
14817
  diffRemovedLines: Math.max(0, Number(body.diffRemovedLines) || 0),
14818
+ cwd: body.cwd ? String(body.cwd) : "",
14819
+ workspaceRoot: body.cwd ? String(body.cwd) : "",
12643
14820
  createdAtMs: Number(body.createdAtMs) || Date.now(),
12644
14821
  resolved: false,
12645
14822
  resolving: false,
@@ -12653,6 +14830,59 @@ function createNativeApprovalServer({ config, runtime, state }) {
12653
14830
  readOnly: body.notifyOnly === true,
12654
14831
  };
12655
14832
 
14833
+ const claudeAutoPilotMatch =
14834
+ approval.kind === "command"
14835
+ ? maybeAutoApproveTrustedRead({
14836
+ state,
14837
+ commandText: body?.toolInput?.command ?? "",
14838
+ cwd: body.cwd ? String(body.cwd) : "",
14839
+ workspaceRoot: body.cwd ? String(body.cwd) : "",
14840
+ })
14841
+ : null;
14842
+ if (claudeAutoPilotMatch) {
14843
+ try {
14844
+ await recordAutoApprovedTrustedRead({
14845
+ config,
14846
+ runtime,
14847
+ state,
14848
+ approval,
14849
+ policyMatch: claudeAutoPilotMatch,
14850
+ });
14851
+ console.log(`[auto-pilot-approved] ${approval.requestKey} | ${claudeAutoPilotMatch.command}`);
14852
+ return writeJson(res, 200, {
14853
+ permissionDecision: "allow",
14854
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedReadReason"),
14855
+ });
14856
+ } catch (error) {
14857
+ console.error(`[auto-pilot-error] ${approval.requestKey} | ${error.message}`);
14858
+ }
14859
+ }
14860
+
14861
+ const claudeAutoPilotWriteCandidate =
14862
+ approval.kind === "file"
14863
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval })
14864
+ : null;
14865
+ if (claudeAutoPilotWriteCandidate) {
14866
+ try {
14867
+ await recordAutoApprovedTrustedWrite({
14868
+ config,
14869
+ runtime,
14870
+ state,
14871
+ approval,
14872
+ candidate: claudeAutoPilotWriteCandidate,
14873
+ });
14874
+ console.log(
14875
+ `[auto-pilot-approved-write] ${approval.requestKey} | files=${claudeAutoPilotWriteCandidate.fileRefs.length} | lines=${claudeAutoPilotWriteCandidate.totalChangedLines}`
14876
+ );
14877
+ return writeJson(res, 200, {
14878
+ permissionDecision: "allow",
14879
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedWriteReason"),
14880
+ });
14881
+ } catch (error) {
14882
+ console.error(`[auto-pilot-write-error] ${approval.requestKey} | ${error.message}`);
14883
+ }
14884
+ }
14885
+
12656
14886
  runtime.nativeApprovalsByToken.set(token, approval);
12657
14887
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
12658
14888
 
@@ -12660,6 +14890,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12660
14890
  config,
12661
14891
  state,
12662
14892
  kind: "approval",
14893
+ tab: "inbox",
14894
+ subtab: "pending",
12663
14895
  token: approval.token,
12664
14896
  stableId: pendingApprovalStableId(approval),
12665
14897
  title: approval.title,
@@ -12789,6 +15021,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12789
15021
  config,
12790
15022
  state,
12791
15023
  kind: "moltbook_reply",
15024
+ tab: "inbox",
15025
+ subtab: "pending",
12792
15026
  token,
12793
15027
  stableId: `moltbook_reply:${item.sourceId}`,
12794
15028
  title: item.title,
@@ -12970,6 +15204,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12970
15204
  config,
12971
15205
  state,
12972
15206
  kind: "moltbook_draft",
15207
+ tab: "inbox",
15208
+ subtab: "pending",
12973
15209
  token,
12974
15210
  stableId: `moltbook_draft:${sourceId}`,
12975
15211
  title: pushTitle,
@@ -13066,6 +15302,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
13066
15302
  try {
13067
15303
  await deliverWebPushItem({
13068
15304
  config, state, kind: "moltbook_draft", token: draft.token,
15305
+ tab: "inbox",
15306
+ subtab: "completed",
13069
15307
  stableId: `moltbook_draft_failed:${draft.sourceId}`,
13070
15308
  title: "Draft post failed",
13071
15309
  body: String(postError.message || "").slice(0, 160),
@@ -13089,7 +15327,16 @@ function createNativeApprovalServer({ config, runtime, state }) {
13089
15327
  return;
13090
15328
  }
13091
15329
  const locale = resolveDeviceLocaleInfo(config, state, session.deviceId).locale;
13092
- return writeJson(res, 200, await buildInboxResponse(runtime, state, config, locale));
15330
+ return writeJson(res, 200, buildInboxFastResponse(runtime, state, config, locale));
15331
+ }
15332
+
15333
+ if (url.pathname === "/api/inbox/diff" && req.method === "GET") {
15334
+ const session = requireApiSession(req, res, config, state);
15335
+ if (!session) {
15336
+ return;
15337
+ }
15338
+ const locale = resolveDeviceLocaleInfo(config, state, session.deviceId).locale;
15339
+ return writeJson(res, 200, await buildInboxDiffResponse(runtime, state, config, locale));
13093
15340
  }
13094
15341
 
13095
15342
  if (url.pathname === "/api/timeline" && req.method === "GET") {
@@ -15038,6 +17285,12 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15038
17285
  const finalTitle = draft.decision.title || draft.postTitle;
15039
17286
 
15040
17287
  let verification;
17288
+ // Captured so the post-verify history record knows which post/comment the
17289
+ // challenge_text was attached to. `draft.postId` only exists for replies;
17290
+ // for `original_post` drafts the id only shows up in the POST /posts
17291
+ // response.
17292
+ let createdPostId = null;
17293
+ let createdCommentId = null;
15041
17294
 
15042
17295
  if (draft.draftType === "original_post") {
15043
17296
  const result = await mb("/posts", {
@@ -15051,6 +17304,7 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15051
17304
  });
15052
17305
  const post = result?.post || null;
15053
17306
  verification = post?.verification || null;
17307
+ createdPostId = post?.id || null;
15054
17308
  console.log(`[moltbook-draft-post] Posted original post (id=${post?.id})`);
15055
17309
 
15056
17310
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
@@ -15066,6 +17320,8 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15066
17320
  });
15067
17321
  const comment = result?.comment || null;
15068
17322
  verification = comment?.verification || null;
17323
+ createdPostId = draft.postId || null;
17324
+ createdCommentId = comment?.id || null;
15069
17325
  console.log(`[moltbook-draft-post] Posted reply (commentId=${comment?.id}) to post ${draft.postId}`);
15070
17326
 
15071
17327
  const scoutState = rollScoutDayIfNeeded(await readScoutState());
@@ -15080,41 +17336,79 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15080
17336
  }
15081
17337
 
15082
17338
  // Solve verification puzzle if present.
17339
+ //
17340
+ // Every attempt — success, server rejection, or complete failure — gets
17341
+ // recorded via `recordPuzzleAttempt` so we can build a corpus of
17342
+ // challenge_text at `~/.viveworker/moltbook-verify-history.jsonl`. This is
17343
+ // the only way to reconstruct failing puzzles; Moltbook only returns
17344
+ // `challenge_text` in the initial POST response.
15083
17345
  if (verification) {
15084
- let answer = solveVerificationPuzzle(verification.challenge_text);
15085
- const source = answer != null ? "solver" : null;
15086
- if (answer == null) {
15087
- answer = await solvePuzzleWithLLM(verification.challenge_text);
17346
+ const solverAnswer = solveVerificationPuzzle(verification.challenge_text);
17347
+ let submittedAnswer = solverAnswer;
17348
+ let llmAnswer = null;
17349
+ let outcome = "unknown";
17350
+ let verifyError = null;
17351
+
17352
+ if (submittedAnswer == null) {
17353
+ llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
17354
+ submittedAnswer = llmAnswer;
15088
17355
  }
15089
- if (answer != null) {
17356
+
17357
+ if (submittedAnswer != null) {
15090
17358
  try {
15091
17359
  await mb("/verify", {
15092
17360
  method: "POST",
15093
- body: JSON.stringify({ verification_code: verification.verification_code, answer }),
17361
+ body: JSON.stringify({ verification_code: verification.verification_code, answer: submittedAnswer }),
15094
17362
  });
15095
- console.log(`[moltbook-draft-verify] Verified with answer ${answer}`);
15096
- } catch (verifyError) {
17363
+ console.log(`[moltbook-draft-verify] Verified with answer ${submittedAnswer}`);
17364
+ outcome = solverAnswer != null && llmAnswer == null ? "solver-verified" : "llm-verified";
17365
+ } catch (err) {
17366
+ verifyError = err.message;
15097
17367
  // Wrong answer from solver — retry with LLM.
15098
- if (/incorrect/i.test(verifyError.message) && source === "solver") {
15099
- const llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
15100
- if (llmAnswer && llmAnswer !== answer) {
17368
+ if (/incorrect/i.test(err.message) && solverAnswer != null && llmAnswer == null) {
17369
+ llmAnswer = await solvePuzzleWithLLM(verification.challenge_text);
17370
+ if (llmAnswer && llmAnswer !== solverAnswer) {
15101
17371
  try {
15102
17372
  await mb("/verify", {
15103
17373
  method: "POST",
15104
17374
  body: JSON.stringify({ verification_code: verification.verification_code, answer: llmAnswer }),
15105
17375
  });
15106
17376
  console.log(`[moltbook-draft-verify] Verified with LLM retry answer ${llmAnswer}`);
17377
+ submittedAnswer = llmAnswer;
17378
+ verifyError = null;
17379
+ outcome = "solver-incorrect-llm-verified";
15107
17380
  } catch (retryError) {
15108
17381
  console.error(`[moltbook-draft-verify] LLM retry failed: ${retryError.message}`);
17382
+ verifyError = retryError.message;
17383
+ outcome = "both-incorrect";
15109
17384
  }
17385
+ } else {
17386
+ outcome = "solver-incorrect-llm-agreed-or-null";
15110
17387
  }
15111
17388
  } else {
15112
- console.error(`[moltbook-draft-verify] Failed: ${verifyError.message}`);
17389
+ console.error(`[moltbook-draft-verify] Failed: ${err.message}`);
17390
+ outcome = solverAnswer != null ? "solver-rejected" : "llm-rejected";
15113
17391
  }
15114
17392
  }
15115
17393
  } else {
15116
17394
  console.error(`[moltbook-draft-verify] Could not solve puzzle for draft ${draft.token}`);
15117
- }
17395
+ outcome = "both-failed-to-solve";
17396
+ }
17397
+
17398
+ // Best-effort corpus write. Never throws.
17399
+ await recordPuzzleAttempt({
17400
+ draftToken: draft.token,
17401
+ draftType: draft.draftType,
17402
+ postId: createdPostId,
17403
+ commentId: createdCommentId,
17404
+ challenge_text: verification.challenge_text,
17405
+ verification_code: verification.verification_code,
17406
+ solverAnswer,
17407
+ llmAnswer,
17408
+ submittedAnswer,
17409
+ outcome,
17410
+ verifyError,
17411
+ });
15118
17412
  }
15119
17413
 
15120
17414
  // Push notification for successful post.
@@ -15122,6 +17416,8 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15122
17416
  const pushTitle = draft.draftType === "original_post" ? finalTitle : `Reply → ${draft.postTitle || "Moltbook"}`;
15123
17417
  await deliverWebPushItem({
15124
17418
  config, state, kind: "moltbook_draft", token: draft.token,
17419
+ tab: "inbox",
17420
+ subtab: "completed",
15125
17421
  stableId: `moltbook_draft_posted:${draft.sourceId}`,
15126
17422
  title: "Moltbook posted",
15127
17423
  body: truncate(singleLine(pushTitle), 160),
@@ -15154,6 +17450,9 @@ function buildConfig(cli) {
15154
17450
  a2aShareUrl: stripTrailingSlash(
15155
17451
  process.env.VIVEWORKER_SHARE_URL || "https://share.viveworker.com"
15156
17452
  ),
17453
+ hazbaseApiUrl: stripTrailingSlash(process.env.HAZBASE_API_URL || "https://api.hazbase.com"),
17454
+ hazbaseClientKey: cleanText(process.env.HAZBASE_CLIENT_KEY || "viveworker-internal"),
17455
+ hazbaseDeviceLabel: cleanText(process.env.HAZBASE_DEVICE_LABEL || "viveworker"),
15157
17456
  webUiEnabled: boolEnv("WEB_UI_ENABLED", true),
15158
17457
  authRequired: boolEnv("AUTH_REQUIRED", true),
15159
17458
  webPushEnabled: boolEnv("WEB_PUSH_ENABLED", false),
@@ -15241,6 +17540,110 @@ function buildConfig(cli) {
15241
17540
  };
15242
17541
  }
15243
17542
 
17543
+
17544
+ function normalizeHazbaseState(raw) {
17545
+ const value = raw && typeof raw === "object" ? raw : {};
17546
+ return {
17547
+ email: cleanText(value.email ?? ""),
17548
+ accessToken: cleanText(value.accessToken ?? ""),
17549
+ sessionId: cleanText(value.sessionId ?? ""),
17550
+ userId: cleanText(value.userId ?? ""),
17551
+ deviceBindingId: cleanText(value.deviceBindingId ?? ""),
17552
+ credentialId: cleanText(value.credentialId ?? ""),
17553
+ highTrustToken: cleanText(value.highTrustToken ?? ""),
17554
+ highTrustExpiresAt: cleanText(value.highTrustExpiresAt ?? ""),
17555
+ accounts: Array.isArray(value.accounts) ? value.accounts : [],
17556
+ };
17557
+ }
17558
+
17559
+ function hazbasePasskeyLocalHostError() {
17560
+ const error = new Error("Open viveworker on its .local hostname to use hazBase passkeys.");
17561
+ error.code = "hazbase-passkey-local-host-required";
17562
+ return error;
17563
+ }
17564
+
17565
+ function deriveHazbasePartnerPasskeyOrigin(req, config) {
17566
+ const rawOrigin = cleanText(req.headers.origin || "");
17567
+ const rawHost = cleanText(req.headers.host || "");
17568
+ const candidateOrigin = rawOrigin || (rawHost ? `https://${rawHost}` : "");
17569
+ if (!candidateOrigin) {
17570
+ throw hazbasePasskeyLocalHostError();
17571
+ }
17572
+
17573
+ let parsed;
17574
+ try {
17575
+ parsed = new URL(candidateOrigin);
17576
+ } catch {
17577
+ throw hazbasePasskeyLocalHostError();
17578
+ }
17579
+
17580
+ const hostname = cleanText(parsed.hostname || "").toLowerCase();
17581
+ if (parsed.protocol !== "https:" || !hostname || net.isIP(hostname) || !hostname.endsWith(".local")) {
17582
+ throw hazbasePasskeyLocalHostError();
17583
+ }
17584
+
17585
+ return {
17586
+ origin: `${parsed.protocol}//${parsed.host}`.toLowerCase(),
17587
+ rpId: hostname,
17588
+ clientKey: config.hazbaseClientKey || "viveworker-internal",
17589
+ };
17590
+ }
17591
+
17592
+ function configureHazbaseClient(config) {
17593
+ try {
17594
+ setHazbaseClientKey(config.hazbaseClientKey || "viveworker-internal");
17595
+ setHazbaseApiEndpoint(config.hazbaseApiUrl || "https://api.hazbase.com");
17596
+ } catch (error) {
17597
+ console.error(`[hazbase-config] ${error.message}`);
17598
+ }
17599
+ }
17600
+
17601
+ async function fetchHazbaseMetadata(config, endpoint, timeoutMs = HAZBASE_METADATA_TIMEOUT_MS) {
17602
+ const baseUrl = stripTrailingSlash(config?.hazbaseApiUrl || "");
17603
+ if (!baseUrl) return null;
17604
+ const timeout = Math.max(250, Number(timeoutMs) || HAZBASE_METADATA_TIMEOUT_MS);
17605
+ const controller = new AbortController();
17606
+ const timer = setTimeout(() => controller.abort(), timeout);
17607
+ timer.unref?.();
17608
+ try {
17609
+ const response = await fetch(`${baseUrl}${endpoint}`, {
17610
+ method: "GET",
17611
+ headers: { Accept: "application/json" },
17612
+ signal: controller.signal,
17613
+ });
17614
+ if (!response.ok) {
17615
+ const errorText = await response.text().catch(() => "");
17616
+ throw new Error(`${endpoint} failed: ${cleanText(errorText || response.statusText || "upstream error")}`);
17617
+ }
17618
+ const payload = await response.json().catch(() => null);
17619
+ return payload?.data ?? payload ?? null;
17620
+ } catch (error) {
17621
+ if (error?.name === "AbortError") {
17622
+ throw new Error(`${endpoint} timed out after ${timeout}ms`);
17623
+ }
17624
+ throw error;
17625
+ } finally {
17626
+ clearTimeout(timer);
17627
+ }
17628
+ }
17629
+
17630
+ function mergeHazbaseAccountSummaries(existing, incoming) {
17631
+ const merged = new Map();
17632
+ for (const entry of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(incoming) ? incoming : [])]) {
17633
+ if (!entry || !entry.smartAccountAddress) continue;
17634
+ const key = `${Number(entry.chainId) || 0}:${String(entry.smartAccountAddress).toLowerCase()}`;
17635
+ merged.set(key, entry);
17636
+ }
17637
+ return [...merged.values()];
17638
+ }
17639
+
17640
+ function resolveHazbaseAccountForChain(accounts, chainId) {
17641
+ const normalizedChainId = Number(chainId || 0);
17642
+ if (!normalizedChainId) return null;
17643
+ const list = Array.isArray(accounts) ? accounts : [];
17644
+ return list.find((entry) => Number(entry?.chainId) === normalizedChainId && cleanText(entry?.smartAccountAddress || "")) || null;
17645
+ }
17646
+
15244
17647
  function validateConfig(config) {
15245
17648
  let publicBaseUrl = null;
15246
17649
  try {
@@ -15396,6 +17799,23 @@ async function loadState(stateFile) {
15396
17799
  try {
15397
17800
  const raw = await fs.readFile(stateFile, "utf8");
15398
17801
  const parsed = JSON.parse(raw);
17802
+ const hasExplicitWriteLaneState =
17803
+ typeof parsed.autoPilotWriteLaneContent === "boolean" ||
17804
+ typeof parsed.autoPilotWriteLaneUiTests === "boolean" ||
17805
+ typeof parsed.autoPilotWriteLaneSource === "boolean";
17806
+ const legacyTrustedWritesEnabled = parsed.autoPilotTrustedWrites === true || parsed.autoPilotTrustedWritesShadow === true;
17807
+ const autoPilotWriteLaneContent =
17808
+ typeof parsed.autoPilotWriteLaneContent === "boolean"
17809
+ ? parsed.autoPilotWriteLaneContent === true
17810
+ : !hasExplicitWriteLaneState && legacyTrustedWritesEnabled;
17811
+ const autoPilotWriteLaneUiTests =
17812
+ typeof parsed.autoPilotWriteLaneUiTests === "boolean"
17813
+ ? parsed.autoPilotWriteLaneUiTests === true
17814
+ : false;
17815
+ const autoPilotWriteLaneSource =
17816
+ typeof parsed.autoPilotWriteLaneSource === "boolean"
17817
+ ? parsed.autoPilotWriteLaneSource === true
17818
+ : false;
15399
17819
  return {
15400
17820
  fileOffsets: parsed.fileOffsets ?? {},
15401
17821
  seenEvents: parsed.seenEvents ?? {},
@@ -15421,7 +17841,14 @@ async function loadState(stateFile) {
15421
17841
  pairingConsumedAt: Number(parsed.pairingConsumedAt) || 0,
15422
17842
  pairingConsumedCredential: cleanText(parsed.pairingConsumedCredential ?? ""),
15423
17843
  claudeAwayMode: parsed.claudeAwayMode === true,
17844
+ autoPilotTrustedReads: parsed.autoPilotTrustedReads === true,
17845
+ autoPilotTrustedWrites: autoPilotWriteLaneContent || autoPilotWriteLaneUiTests || autoPilotWriteLaneSource,
17846
+ autoPilotTrustedWritesShadow: false,
17847
+ autoPilotWriteLaneContent,
17848
+ autoPilotWriteLaneUiTests,
17849
+ autoPilotWriteLaneSource,
15424
17850
  a2aAcceptPublicTasks: parsed.a2aAcceptPublicTasks === true,
17851
+ hazbase: normalizeHazbaseState(parsed.hazbase),
15425
17852
  };
15426
17853
  } catch {
15427
17854
  return {
@@ -15449,6 +17876,13 @@ async function loadState(stateFile) {
15449
17876
  pairingConsumedAt: 0,
15450
17877
  pairingConsumedCredential: "",
15451
17878
  claudeAwayMode: false,
17879
+ autoPilotTrustedReads: false,
17880
+ autoPilotTrustedWrites: false,
17881
+ autoPilotTrustedWritesShadow: false,
17882
+ autoPilotWriteLaneContent: false,
17883
+ autoPilotWriteLaneUiTests: false,
17884
+ autoPilotWriteLaneSource: false,
17885
+ hazbase: normalizeHazbaseState(null),
15452
17886
  };
15453
17887
  }
15454
17888
  }
@@ -15473,7 +17907,11 @@ async function syncClaudeAwayModeSentinel(config, enabled) {
15473
17907
  }
15474
17908
 
15475
17909
  async function saveState(stateFile, state) {
15476
- const output = JSON.stringify(state, null, 2);
17910
+ // No indent. The state file is ~8 MB and humans don't read it; pretty-print
17911
+ // just costs a few extra ms of stringify and ~10% more bytes on disk. The
17912
+ // single newline at the end keeps diffs/hexdumps working on the off chance
17913
+ // someone cats the file.
17914
+ const output = JSON.stringify(state);
15477
17915
  await fs.mkdir(path.dirname(stateFile), { recursive: true });
15478
17916
  await fs.writeFile(stateFile, `${output}\n`, "utf8");
15479
17917
  }
@@ -16081,6 +18519,7 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
16081
18519
  ) {
16082
18520
  runtime.recentCodeEvents = nextCodeEvents;
16083
18521
  state.recentCodeEvents = nextCodeEvents;
18522
+ invalidateDiffThreadGroupsCache();
16084
18523
  changed = true;
16085
18524
  }
16086
18525
 
@@ -16271,8 +18710,8 @@ function extractTitleOnlyJsonTitle(value) {
16271
18710
  }
16272
18711
 
16273
18712
  function preferTitleOnlyJsonThreadLabel(rawThreadLabel, conversationId, ...candidates) {
16274
- const preferredThreadLabel = sanitizeResolvedThreadLabel(rawThreadLabel, conversationId);
16275
- if (preferredThreadLabel) {
18713
+ const preferredThreadLabel = sanitizeResolvedThreadLabel(rawThreadLabel, conversationId);
18714
+ if (preferredThreadLabel && !isFallbackConversationLabel(preferredThreadLabel, conversationId)) {
16276
18715
  return preferredThreadLabel;
16277
18716
  }
16278
18717
  for (const candidate of candidates) {
@@ -16280,8 +18719,12 @@ function preferTitleOnlyJsonThreadLabel(rawThreadLabel, conversationId, ...candi
16280
18719
  if (titleOnlyJsonTitle) {
16281
18720
  return titleOnlyJsonTitle;
16282
18721
  }
18722
+ const derivedSdkCliLabel = deriveClaudeSdkCliThreadLabel(candidate, conversationId);
18723
+ if (derivedSdkCliLabel) {
18724
+ return derivedSdkCliLabel;
18725
+ }
16283
18726
  }
16284
- return cleanText(rawThreadLabel || "");
18727
+ return cleanText(preferredThreadLabel || rawThreadLabel || "");
16285
18728
  }
16286
18729
 
16287
18730
  function stripMarkdownLinks(value) {
@@ -16353,6 +18796,25 @@ function shortId(value) {
16353
18796
  return text.length > 8 ? text.slice(0, 8) : text;
16354
18797
  }
16355
18798
 
18799
+ function isFallbackTimelineTitle(rawTitle, kind, threadId) {
18800
+ const normalizedTitle = cleanText(rawTitle || "");
18801
+ const normalizedKind = cleanText(kind || "");
18802
+ const normalizedThreadId = cleanText(threadId || "");
18803
+ if (!normalizedTitle) {
18804
+ return true;
18805
+ }
18806
+ if (normalizedThreadId && normalizedTitle === shortId(normalizedThreadId)) {
18807
+ return true;
18808
+ }
18809
+ if (normalizedTitle === kindTitle(DEFAULT_LOCALE, normalizedKind)) {
18810
+ return true;
18811
+ }
18812
+ if (normalizedThreadId && normalizedTitle === formatTitle(kindTitle(DEFAULT_LOCALE, normalizedKind), shortId(normalizedThreadId))) {
18813
+ return true;
18814
+ }
18815
+ return false;
18816
+ }
18817
+
16356
18818
  function sleep(ms) {
16357
18819
  return new Promise((resolve) => setTimeout(resolve, ms));
16358
18820
  }
@@ -16507,6 +18969,7 @@ async function main() {
16507
18969
  migratedRecentCodeEventsStateChanged ||
16508
18970
  restoredPendingUserInputStateChanged ||
16509
18971
  backfilledMoltbookInboxChanged ||
18972
+ recoveredMissingProviderStateChanged ||
16510
18973
  refreshResolvedThreadLabels({ config, runtime, state })
16511
18974
  ) {
16512
18975
  await saveState(config.stateFile, state);
@@ -16613,7 +19076,16 @@ async function main() {
16613
19076
  try {
16614
19077
  const dirty = await scanOnce({ config, runtime, state });
16615
19078
  if (dirty) {
16616
- await saveState(config.stateFile, state);
19079
+ // Fire-and-forget via the debouncer rather than awaiting — the
19080
+ // ~8 MB state file used to block the scan loop for ~50-100 ms on
19081
+ // every dirty iteration, which directly delays the next rollout /
19082
+ // history.jsonl read and therefore how quickly new user_message
19083
+ // entries reach the phone. runtime/state.recentTimelineEntries are
19084
+ // already updated synchronously inside recordTimelineEntry, so the
19085
+ // /api/timeline response sees fresh data the moment the client
19086
+ // next polls, regardless of when the disk write lands. Pending
19087
+ // writes are drained in the `finally` block below on SIGINT/SIGTERM.
19088
+ scheduleSaveState(config, state);
16617
19089
  }
16618
19090
  } catch (error) {
16619
19091
  console.error(`[scan-error] ${error.message}`);
@@ -16690,5 +19162,10 @@ async function main() {
16690
19162
  if (approvalServer) {
16691
19163
  await stopHttpServer(approvalServer);
16692
19164
  }
19165
+
19166
+ // Drain any state mutations that were queued via scheduleSaveState but
19167
+ // not yet written to disk — otherwise SIGINT/SIGTERM mid-debounce would
19168
+ // silently drop the last ~500 ms of settings/notification changes.
19169
+ await flushPendingStateWrite(config, state);
16693
19170
  }
16694
19171
  }