viveworker 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ 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";
16
17
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "../web/i18n.js";
17
18
  import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
@@ -39,6 +40,152 @@ const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
39
40
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
40
41
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
41
42
 
43
+ // Shared memo for buildDiffThreadGroups. Each call spawns 3 git subprocesses
44
+ // per tracked repo (`git diff --name-status`, `git status --porcelain`,
45
+ // `git diff`) plus a `git rev-parse` per unique dir — roughly 10+ subprocess
46
+ // spawns for a typical multi-repo state. Both /api/inbox/diff and
47
+ // /api/items/diff_thread/:token call this function; when the user taps a
48
+ // detail right after the inbox finished loading, the computation is
49
+ // duplicated unnecessarily. Short TTL + explicit invalidation when
50
+ // recentCodeEvents mutates keeps the result fresh without the recompute cost.
51
+ // Hoisted here so code-event migration at startup can call
52
+ // invalidateDiffThreadGroupsCache() before any of the use sites below
53
+ // execute, avoiding a temporal-dead-zone ReferenceError on `let`.
54
+ const DIFF_THREAD_GROUPS_CACHE_TTL_MS = 3000;
55
+ let diffThreadGroupsCache = null;
56
+ let diffThreadGroupsPending = null;
57
+
58
+ function invalidateDiffThreadGroupsCache() {
59
+ diffThreadGroupsCache = null;
60
+ // Don't touch diffThreadGroupsPending — an in-flight build reflects the
61
+ // state at the time it started, and callers racing the rebuild would
62
+ // otherwise pile up their own subprocess spawns.
63
+ }
64
+
65
+ async function getDiffThreadGroupsCached(runtime, state, config) {
66
+ const now = Date.now();
67
+ if (
68
+ diffThreadGroupsCache &&
69
+ now - diffThreadGroupsCache.computedAtMs < DIFF_THREAD_GROUPS_CACHE_TTL_MS
70
+ ) {
71
+ return diffThreadGroupsCache.groups;
72
+ }
73
+ if (diffThreadGroupsPending) {
74
+ return diffThreadGroupsPending;
75
+ }
76
+ diffThreadGroupsPending = (async () => {
77
+ try {
78
+ const groups = await buildDiffThreadGroups(runtime, state, config);
79
+ diffThreadGroupsCache = { groups, computedAtMs: Date.now() };
80
+ return groups;
81
+ } finally {
82
+ diffThreadGroupsPending = null;
83
+ }
84
+ })();
85
+ return diffThreadGroupsPending;
86
+ }
87
+
88
+ // Coalesced state persistence. The full state JSON is ~8 MB on disk (dominated
89
+ // by `recentCodeEvents` and `recentTimelineEntries` caches); JSON.stringify
90
+ // blocks the event loop ~70 ms and fs.writeFile takes another ~80 ms. Before
91
+ // this helper, every tiny mutation (push subscribe/unsubscribe/test, locale
92
+ // change, claude-away toggle, A2A toggles, …) awaited a ~150 ms
93
+ // serialize+write before the HTTP response fired — and the event-loop block
94
+ // delayed every concurrent request on the same server too. That's what made
95
+ // the settings/notification buttons feel sluggish: the click → visible
96
+ // response round-trip paid the cost of re-serializing 5 MB of code-event
97
+ // history every time.
98
+ //
99
+ // `scheduleSaveState` queues a write within SAVE_STATE_DEBOUNCE_MS and returns
100
+ // synchronously, so HTTP handlers can respond immediately after mutating
101
+ // in-memory state. Multiple mutations within the window coalesce into one
102
+ // write. `flushPendingStateWrite` drains on graceful shutdown.
103
+ //
104
+ // Durability trade-off: at most ~SAVE_STATE_MAX_DELAY_MS of in-memory
105
+ // mutations can be lost on a hard crash. Callers that require stronger
106
+ // guarantees (pairing, revocation, the periodic scan loop) still `await
107
+ // saveState(...)` directly — only the hot settings/notification paths opt in
108
+ // to the coalescer.
109
+ const SAVE_STATE_DEBOUNCE_MS = 500;
110
+ const SAVE_STATE_MAX_DELAY_MS = 1500;
111
+ let saveStateTimer = null;
112
+ let saveStateFirstScheduleAtMs = 0;
113
+ let saveStateInFlight = null;
114
+ let saveStateDirty = false;
115
+
116
+ function scheduleSaveState(config, state) {
117
+ if (!config?.stateFile) return;
118
+ if (saveStateInFlight) {
119
+ // An in-flight write already reflects the current (or earlier) state.
120
+ // Mark dirty so the .finally handler starts another write when it
121
+ // finishes — overlapping writes would just stomp on each other.
122
+ saveStateDirty = true;
123
+ return;
124
+ }
125
+ const now = Date.now();
126
+ if (!saveStateFirstScheduleAtMs) {
127
+ saveStateFirstScheduleAtMs = now;
128
+ }
129
+ if (saveStateTimer) {
130
+ clearTimeout(saveStateTimer);
131
+ }
132
+ const elapsed = now - saveStateFirstScheduleAtMs;
133
+ const remaining = SAVE_STATE_MAX_DELAY_MS - elapsed;
134
+ const delay = Math.max(0, Math.min(SAVE_STATE_DEBOUNCE_MS, remaining));
135
+ saveStateTimer = setTimeout(() => {
136
+ saveStateTimer = null;
137
+ saveStateFirstScheduleAtMs = 0;
138
+ startSaveStateFlush(config, state);
139
+ }, delay);
140
+ }
141
+
142
+ function startSaveStateFlush(config, state) {
143
+ saveStateDirty = false;
144
+ const promise = (async () => {
145
+ try {
146
+ await saveState(config.stateFile, state);
147
+ } catch (error) {
148
+ console.error(`[save-state-error] ${error.message}`);
149
+ }
150
+ })();
151
+ saveStateInFlight = promise;
152
+ promise.finally(() => {
153
+ if (saveStateInFlight === promise) {
154
+ saveStateInFlight = null;
155
+ }
156
+ if (saveStateDirty) {
157
+ // A mutation arrived mid-flight. Start the follow-up write
158
+ // synchronously — we've already held the original debounce window,
159
+ // so tacking on another 500 ms would defeat coalescing. Going
160
+ // through scheduleSaveState() instead would also introduce a timer
161
+ // that flushPendingStateWrite() would have to know about.
162
+ saveStateDirty = false;
163
+ startSaveStateFlush(config, state);
164
+ }
165
+ });
166
+ }
167
+
168
+ async function flushPendingStateWrite(config, state) {
169
+ // Promote any pending debounce timer to an immediate flush.
170
+ if (saveStateTimer) {
171
+ clearTimeout(saveStateTimer);
172
+ saveStateTimer = null;
173
+ saveStateFirstScheduleAtMs = 0;
174
+ startSaveStateFlush(config, state);
175
+ }
176
+ // Drain the in-flight write plus any cascades triggered by saveStateDirty.
177
+ // Bounded so a pathological re-entrancy bug can't hang shutdown.
178
+ for (let iter = 0; iter < 4 && (saveStateInFlight || saveStateDirty); iter += 1) {
179
+ if (saveStateInFlight) {
180
+ await saveStateInFlight.catch(() => {});
181
+ }
182
+ if (saveStateDirty && !saveStateInFlight) {
183
+ saveStateDirty = false;
184
+ startSaveStateFlush(config, state);
185
+ }
186
+ }
187
+ }
188
+
42
189
  const cli = parseCliArgs(process.argv.slice(2));
43
190
  const envFile = resolveEnvFile(cli.envFile);
44
191
  loadEnvFile(envFile);
@@ -263,8 +410,23 @@ function clearDeviceLocaleOverride(state, deviceId) {
263
410
  return true;
264
411
  }
265
412
 
413
+ function autoPilotWriteLaneState(state) {
414
+ return {
415
+ content: state?.autoPilotWriteLaneContent === true,
416
+ uiTests: state?.autoPilotWriteLaneUiTests === true,
417
+ source: state?.autoPilotWriteLaneSource === true,
418
+ };
419
+ }
420
+
421
+ function hasAnyAutoPilotWriteLaneEnabled(state) {
422
+ const lanes = autoPilotWriteLaneState(state);
423
+ return lanes.content || lanes.uiTests || lanes.source;
424
+ }
425
+
266
426
  function buildSessionLocalePayload(config, state, deviceId) {
267
427
  const resolved = resolveDeviceLocaleInfo(config, state, deviceId);
428
+ const writeLanes = autoPilotWriteLaneState(state);
429
+ const trustedWritesEnabled = hasAnyAutoPilotWriteLaneEnabled(state);
268
430
  return {
269
431
  locale: resolved.locale,
270
432
  localeSource: resolved.source,
@@ -273,6 +435,12 @@ function buildSessionLocalePayload(config, state, deviceId) {
273
435
  deviceDetectedLocale: resolved.detectedLocale || null,
274
436
  deviceOverrideLocale: resolved.overrideLocale || null,
275
437
  claudeAwayMode: state?.claudeAwayMode === true,
438
+ autoPilotTrustedReads: state?.autoPilotTrustedReads === true,
439
+ autoPilotTrustedWrites: trustedWritesEnabled,
440
+ autoPilotTrustedWritesShadow: false,
441
+ autoPilotWriteLaneContent: writeLanes.content,
442
+ autoPilotWriteLaneUiTests: writeLanes.uiTests,
443
+ autoPilotWriteLaneSource: writeLanes.source,
276
444
  moltbookEnabled: Boolean(config.moltbookApiKey),
277
445
  a2aEnabled: Boolean(config.a2aApiKey),
278
446
  a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
@@ -770,6 +938,425 @@ function tokenizeShellWords(commandText) {
770
938
  return tokens;
771
939
  }
772
940
 
941
+ function isPathWithinRoot(rootPath, candidatePath) {
942
+ const normalizedRoot = cleanText(rootPath || "");
943
+ const normalizedCandidate = cleanText(candidatePath || "");
944
+ if (!normalizedRoot || !normalizedCandidate) {
945
+ return false;
946
+ }
947
+ const relativePath = path.relative(normalizedRoot, normalizedCandidate);
948
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
949
+ }
950
+
951
+ function isSensitiveCommandPath(candidatePath) {
952
+ const normalized = cleanText(candidatePath || "");
953
+ if (!normalized) {
954
+ return true;
955
+ }
956
+ const lower = normalized.toLowerCase();
957
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
958
+ const basename = segments[segments.length - 1] || "";
959
+ if (segments.some((segment) => [".ssh", ".aws", ".gnupg", ".azure", ".kube"].includes(segment))) {
960
+ return true;
961
+ }
962
+ if (basename === ".npmrc" || basename === ".netrc" || basename === ".env" || basename.startsWith(".env.")) {
963
+ return true;
964
+ }
965
+ if (basename.endsWith(".pem") || basename.endsWith(".key") || basename.endsWith(".p12") || basename.endsWith(".pfx")) {
966
+ return true;
967
+ }
968
+ if (/^id_[a-z0-9._-]+$/iu.test(basename) || basename.includes("secret") || basename.includes("credential")) {
969
+ return true;
970
+ }
971
+ return false;
972
+ }
973
+
974
+ function resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }) {
975
+ const rawToken = cleanText(token || "");
976
+ const normalizedCwd = cleanText(cwd || "");
977
+ const normalizedWorkspaceRoot = cleanText(workspaceRoot || normalizedCwd);
978
+ if (!rawToken || !normalizedCwd || !normalizedWorkspaceRoot) {
979
+ return null;
980
+ }
981
+ if (rawToken === "-" || rawToken.startsWith("~") || rawToken.startsWith("http://") || rawToken.startsWith("https://")) {
982
+ return null;
983
+ }
984
+ const resolved = path.resolve(normalizedCwd, rawToken);
985
+ if (!isPathWithinRoot(normalizedWorkspaceRoot, resolved)) {
986
+ return null;
987
+ }
988
+ if (isSensitiveCommandPath(resolved)) {
989
+ return null;
990
+ }
991
+ return resolved;
992
+ }
993
+
994
+ function isGitObjectPathToken(token) {
995
+ const normalized = cleanText(token || "");
996
+ if (!normalized || normalized.startsWith("-")) {
997
+ return false;
998
+ }
999
+ return /^[^:\s][^:\s]*:.+/u.test(normalized);
1000
+ }
1001
+
1002
+ function isLiteralGitPathspecToken(token) {
1003
+ const normalized = cleanText(token || "");
1004
+ if (!normalized || normalized === "--" || normalized.startsWith(":(") || normalized.startsWith(":/")) {
1005
+ return false;
1006
+ }
1007
+ if (/[*?[\]{}]/u.test(normalized)) {
1008
+ return false;
1009
+ }
1010
+ return !isGitObjectPathToken(normalized);
1011
+ }
1012
+
1013
+ function isAllowedGitMetadataOnlyFlag(token) {
1014
+ const normalized = cleanText(token || "");
1015
+ if (!normalized) {
1016
+ return false;
1017
+ }
1018
+ return [
1019
+ "--stat",
1020
+ "--shortstat",
1021
+ "--numstat",
1022
+ "--name-only",
1023
+ "--name-status",
1024
+ "--summary",
1025
+ "--compact-summary",
1026
+ "--oneline",
1027
+ "--no-patch",
1028
+ "-s",
1029
+ ].includes(normalized);
1030
+ }
1031
+
1032
+ function hasDisallowedGitPatchOutput(tokens) {
1033
+ return tokens.some((token) => {
1034
+ const normalized = cleanText(token || "");
1035
+ if (!normalized) {
1036
+ return false;
1037
+ }
1038
+ return (
1039
+ normalized === "-p" ||
1040
+ normalized === "--patch" ||
1041
+ normalized === "--patch-with-stat" ||
1042
+ normalized === "--raw" ||
1043
+ normalized === "--cc" ||
1044
+ normalized === "--combined-all-paths" ||
1045
+ normalized === "--binary" ||
1046
+ normalized === "--word-diff" ||
1047
+ normalized.startsWith("--word-diff=") ||
1048
+ normalized === "-U" ||
1049
+ normalized.startsWith("-U") ||
1050
+ normalized === "--unified" ||
1051
+ normalized.startsWith("--unified=")
1052
+ );
1053
+ });
1054
+ }
1055
+
1056
+ function extractGitPathspecTokens(tokens) {
1057
+ const separatorIndex = tokens.indexOf("--");
1058
+ if (separatorIndex < 0) {
1059
+ return [];
1060
+ }
1061
+ return tokens.slice(separatorIndex + 1).filter(Boolean);
1062
+ }
1063
+
1064
+ function resolveTrustedDiffSectionPath({ token, cwd, workspaceRoot }) {
1065
+ const normalizedToken = cleanText(token || "");
1066
+ if (!normalizedToken) {
1067
+ return null;
1068
+ }
1069
+ const preferredWorkspace = resolveTrustedReadTargetPath({
1070
+ token: normalizedToken,
1071
+ cwd: workspaceRoot,
1072
+ workspaceRoot,
1073
+ });
1074
+ if (preferredWorkspace) {
1075
+ return preferredWorkspace;
1076
+ }
1077
+ return resolveTrustedReadTargetPath({
1078
+ token: normalizedToken,
1079
+ cwd,
1080
+ workspaceRoot,
1081
+ });
1082
+ }
1083
+
1084
+ function extractTrustedReadFileRefs(command, tokens, cwd, workspaceRoot) {
1085
+ const normalizedCommand = cleanText(command || "");
1086
+ if (!normalizedCommand) {
1087
+ return null;
1088
+ }
1089
+
1090
+ if (normalizedCommand === "pwd" || (normalizedCommand === "git" && cleanText(tokens[1] || "") === "status")) {
1091
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1092
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1093
+ }
1094
+
1095
+ if (normalizedCommand === "git") {
1096
+ const gitSubcommand = cleanText(tokens[1] || "");
1097
+ if (!["diff", "show"].includes(gitSubcommand)) {
1098
+ return null;
1099
+ }
1100
+ if (tokens.some((token) => token === "-C" || token.startsWith("-C") || token === "--output" || token.startsWith("--output="))) {
1101
+ return null;
1102
+ }
1103
+ const commandArgs = tokens.slice(2);
1104
+ if (commandArgs.some((token) => isGitObjectPathToken(token))) {
1105
+ return null;
1106
+ }
1107
+ const explicitPathspecs = extractGitPathspecTokens(tokens);
1108
+ if (explicitPathspecs.length > 0) {
1109
+ if (explicitPathspecs.some((token) => !isLiteralGitPathspecToken(token))) {
1110
+ return null;
1111
+ }
1112
+ const resolvedPaths = explicitPathspecs
1113
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1114
+ .filter(Boolean);
1115
+ return resolvedPaths.length === explicitPathspecs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1116
+ }
1117
+ if (hasDisallowedGitPatchOutput(commandArgs)) {
1118
+ return null;
1119
+ }
1120
+ const metadataOnly = commandArgs.some((token) => isAllowedGitMetadataOnlyFlag(token));
1121
+ if (!metadataOnly) {
1122
+ return null;
1123
+ }
1124
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1125
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1126
+ }
1127
+
1128
+ if (normalizedCommand === "ls") {
1129
+ const pathArgs = tokens.slice(1).filter((token) => !String(token || "").startsWith("-"));
1130
+ if (pathArgs.length === 0) {
1131
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1132
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1133
+ }
1134
+ const resolvedPaths = pathArgs
1135
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1136
+ .filter(Boolean);
1137
+ return resolvedPaths.length === pathArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1138
+ }
1139
+
1140
+ if (normalizedCommand === "find") {
1141
+ if (tokens.some((token) => ["-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprintf", "-fls"].includes(token))) {
1142
+ return null;
1143
+ }
1144
+ const pathArgs = [];
1145
+ for (const token of tokens.slice(1)) {
1146
+ if (!token || token === "--") continue;
1147
+ if (token === "!" || token === "(" || token === ")") break;
1148
+ if (token.startsWith("-")) break;
1149
+ pathArgs.push(token);
1150
+ }
1151
+ const effectivePaths = pathArgs.length ? pathArgs : ["."];
1152
+ const resolvedPaths = effectivePaths
1153
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1154
+ .filter(Boolean);
1155
+ return resolvedPaths.length === effectivePaths.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1156
+ }
1157
+
1158
+ if (normalizedCommand === "sed") {
1159
+ if (!tokens.includes("-n") || tokens.includes("-i") || tokens.includes("--in-place")) {
1160
+ return null;
1161
+ }
1162
+ let script = "";
1163
+ const fileArgs = [];
1164
+ for (let index = 1; index < tokens.length; index += 1) {
1165
+ const token = tokens[index];
1166
+ if (!token) continue;
1167
+ if (!script) {
1168
+ if (token === "-e") {
1169
+ script = tokens[index + 1] || "";
1170
+ index += 1;
1171
+ continue;
1172
+ }
1173
+ if (token.startsWith("-")) {
1174
+ continue;
1175
+ }
1176
+ script = token;
1177
+ continue;
1178
+ }
1179
+ if (!token.startsWith("-")) {
1180
+ fileArgs.push(token);
1181
+ }
1182
+ }
1183
+ if (!script || !/^[0-9,$; p-]+$/u.test(script) || fileArgs.length === 0) {
1184
+ return null;
1185
+ }
1186
+ const resolvedPaths = fileArgs
1187
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1188
+ .filter(Boolean);
1189
+ return resolvedPaths.length === fileArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1190
+ }
1191
+
1192
+ if (normalizedCommand === "rg") {
1193
+ let seenPattern = false;
1194
+ const fileArgs = [];
1195
+ for (const token of tokens.slice(1)) {
1196
+ if (!seenPattern) {
1197
+ if (token === "--") {
1198
+ seenPattern = true;
1199
+ continue;
1200
+ }
1201
+ if (String(token || "").startsWith("-")) {
1202
+ continue;
1203
+ }
1204
+ seenPattern = true;
1205
+ continue;
1206
+ }
1207
+ if (!String(token || "").startsWith("-")) {
1208
+ fileArgs.push(token);
1209
+ }
1210
+ }
1211
+ const effectivePaths = fileArgs.length ? fileArgs : ["."];
1212
+ const resolvedPaths = effectivePaths
1213
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1214
+ .filter(Boolean);
1215
+ return resolvedPaths.length === effectivePaths.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1216
+ }
1217
+
1218
+ if (["cat", "nl", "head", "tail", "wc"].includes(normalizedCommand)) {
1219
+ const fileArgs = tokens.slice(1).filter((token) => !String(token || "").startsWith("-"));
1220
+ if (fileArgs.length === 0) {
1221
+ return null;
1222
+ }
1223
+ const resolvedPaths = fileArgs
1224
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1225
+ .filter(Boolean);
1226
+ return resolvedPaths.length === fileArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1227
+ }
1228
+
1229
+ return null;
1230
+ }
1231
+
1232
+ function stripAllowedTrustedReadRedirections(tokens) {
1233
+ const filtered = [];
1234
+ for (let index = 0; index < tokens.length; index += 1) {
1235
+ const token = String(tokens[index] || "");
1236
+ if (!token) {
1237
+ continue;
1238
+ }
1239
+ if (token === "2>" || token === "2>>") {
1240
+ const target = String(tokens[index + 1] || "");
1241
+ if (target !== "/dev/null") {
1242
+ return null;
1243
+ }
1244
+ index += 1;
1245
+ continue;
1246
+ }
1247
+ if (/^2>>?\/dev\/null$/u.test(token)) {
1248
+ continue;
1249
+ }
1250
+ if (token.includes(">") || token.includes("<")) {
1251
+ return null;
1252
+ }
1253
+ filtered.push(token);
1254
+ }
1255
+ return filtered;
1256
+ }
1257
+
1258
+ function isAllowedTrustedReadPostProcessStage(stageTokens) {
1259
+ if (!Array.isArray(stageTokens) || stageTokens.length === 0) {
1260
+ return false;
1261
+ }
1262
+ const [command, ...args] = stageTokens.map((token) => cleanText(token || ""));
1263
+ if (!command) {
1264
+ return false;
1265
+ }
1266
+ if (command === "head" || command === "tail") {
1267
+ if (args.length === 0) {
1268
+ return true;
1269
+ }
1270
+ if (args.length === 1 && /^-\d+$/u.test(args[0])) {
1271
+ return true;
1272
+ }
1273
+ if (args.length === 2 && args[0] === "-n" && /^\d+$/u.test(args[1])) {
1274
+ return true;
1275
+ }
1276
+ return false;
1277
+ }
1278
+ if (command === "wc") {
1279
+ return args.length === 0 || (args.length === 1 && args[0] === "-l");
1280
+ }
1281
+ return false;
1282
+ }
1283
+
1284
+ function normalizeTrustedReadTokens(tokens) {
1285
+ const strippedTokens = stripAllowedTrustedReadRedirections(tokens);
1286
+ if (!strippedTokens || strippedTokens.length === 0) {
1287
+ return null;
1288
+ }
1289
+ if (strippedTokens.some((token) => {
1290
+ const value = String(token || "");
1291
+ return (
1292
+ ["||", "&&", ";", "&"].includes(value) ||
1293
+ value.includes("`") ||
1294
+ value.includes("$(")
1295
+ );
1296
+ })) {
1297
+ return null;
1298
+ }
1299
+ const stages = [];
1300
+ let currentStage = [];
1301
+ for (const token of strippedTokens) {
1302
+ if (token === "|") {
1303
+ if (currentStage.length === 0) {
1304
+ return null;
1305
+ }
1306
+ stages.push(currentStage);
1307
+ currentStage = [];
1308
+ continue;
1309
+ }
1310
+ currentStage.push(token);
1311
+ }
1312
+ if (currentStage.length === 0) {
1313
+ return null;
1314
+ }
1315
+ stages.push(currentStage);
1316
+ if (stages.length === 0) {
1317
+ return null;
1318
+ }
1319
+ if (stages.slice(1).some((stageTokens) => !isAllowedTrustedReadPostProcessStage(stageTokens))) {
1320
+ return null;
1321
+ }
1322
+ return stages[0];
1323
+ }
1324
+
1325
+ function evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot }) {
1326
+ const normalizedCwd = cleanText(cwd || "");
1327
+ const normalizedWorkspaceRoot = cleanText(workspaceRoot || normalizedCwd);
1328
+ const normalizedCommandText = unwrapShellCommand(commandText);
1329
+ const rawTokens = tokenizeShellWords(normalizedCommandText);
1330
+ if (!normalizedCommandText || !normalizedCwd || !normalizedWorkspaceRoot || rawTokens.length === 0) {
1331
+ return null;
1332
+ }
1333
+
1334
+ if (!isPathWithinRoot(normalizedWorkspaceRoot, path.resolve(normalizedCwd))) {
1335
+ return null;
1336
+ }
1337
+
1338
+ const tokens = normalizeTrustedReadTokens(rawTokens);
1339
+ if (!tokens || tokens.length === 0) {
1340
+ return null;
1341
+ }
1342
+
1343
+ const command = cleanText(tokens[0]);
1344
+ if (!command || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(command)) {
1345
+ return null;
1346
+ }
1347
+
1348
+ const fileRefs = extractTrustedReadFileRefs(command, tokens, normalizedCwd, normalizedWorkspaceRoot);
1349
+ if (!fileRefs) {
1350
+ return null;
1351
+ }
1352
+
1353
+ return {
1354
+ command,
1355
+ commandText: normalizedCommandText,
1356
+ fileRefs,
1357
+ };
1358
+ }
1359
+
773
1360
  function unwrapShellCommand(commandText) {
774
1361
  const normalized = String(commandText || "").trim();
775
1362
  if (!normalized) {
@@ -848,6 +1435,14 @@ function extractReadFileRefsFromCommand(commandText) {
848
1435
  return [];
849
1436
  }
850
1437
 
1438
+ function autoPilotApprovalMessage(locale, commandText) {
1439
+ const prefix = t(locale, "server.message.autoPilotTrustedReadApproved");
1440
+ const commandBlock = cleanText(commandText || "")
1441
+ ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${cleanText(commandText || "")}\n\`\`\``
1442
+ : "";
1443
+ return [prefix, commandBlock].filter(Boolean).join("\n\n");
1444
+ }
1445
+
851
1446
  function extractUpdatedFileRefsByType(outputText, patchText = "") {
852
1447
  const parsedSections = parseApplyPatchSections(patchText);
853
1448
  if (parsedSections.length > 0) {
@@ -2305,10 +2900,41 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2305
2900
  const perProviderCount = {};
2306
2901
  const knownProviders = new Set(["codex", "claude", "moltbook", "a2a", "viveworker"]);
2307
2902
  const saturatedProviders = new Set();
2903
+ // Content-based dedup for user_message entries: the same logical user turn
2904
+ // can surface through two independent readers (rollout session files and
2905
+ // ~/.codex/history.jsonl) that compute different stableIds
2906
+ // (`user_message:<threadId>:<turn_id|ISO-ts>` vs
2907
+ // `user_message:<threadId>:<integer-id|unix-seconds>`), so the stableId-only
2908
+ // filter above doesn't catch them. Collapse entries with the same
2909
+ // (threadId, messageText) pair when their createdAtMs are within 60 s —
2910
+ // wide enough to absorb rollout-vs-history-file flush skew (we've seen
2911
+ // several seconds in the wild) but narrow enough not to collapse a genuine
2912
+ // repeat (e.g. user retries the same "run tests" command minutes apart).
2913
+ // Literal here rather than a named const because this function is first
2914
+ // called at top-level module init (line ~302) which runs before any const
2915
+ // declared below would leave the TDZ.
2916
+ const userMessageDedupWindowMs = 60_000;
2917
+ const userMessageIndex = new Map();
2308
2918
  for (const item of normalized) {
2309
2919
  if (seen.has(item.stableId)) {
2310
2920
  continue;
2311
2921
  }
2922
+ if (item.kind === "user_message") {
2923
+ const threadKey = cleanText(item.threadId || "");
2924
+ const textKey = cleanText(item.messageText || "");
2925
+ if (threadKey && textKey) {
2926
+ const key = `${threadKey}\u0001${textKey}`;
2927
+ const existingMs = userMessageIndex.get(key);
2928
+ const itemMs = Number(item.createdAtMs || 0);
2929
+ if (
2930
+ existingMs !== undefined &&
2931
+ Math.abs(existingMs - itemMs) <= userMessageDedupWindowMs
2932
+ ) {
2933
+ continue;
2934
+ }
2935
+ userMessageIndex.set(key, itemMs);
2936
+ }
2937
+ }
2312
2938
  const prov = item.provider || "codex";
2313
2939
  if (saturatedProviders.has(prov)) {
2314
2940
  continue;
@@ -2352,6 +2978,7 @@ function migrateRecentCodeEventsState({ config, runtime, state }) {
2352
2978
  const previousItems = Array.isArray(state.recentCodeEvents) ? normalizeCodeEvents(state.recentCodeEvents, config.maxCodeEvents) : [];
2353
2979
  runtime.recentCodeEvents = nextItems;
2354
2980
  state.recentCodeEvents = nextItems;
2981
+ invalidateDiffThreadGroupsCache();
2355
2982
  return JSON.stringify(nextItems) !== JSON.stringify(previousItems);
2356
2983
  }
2357
2984
 
@@ -2563,6 +3190,9 @@ function recordCodeEvent({ config, runtime, state, entry }) {
2563
3190
  );
2564
3191
  runtime.recentCodeEvents = nextItems;
2565
3192
  state.recentCodeEvents = nextItems;
3193
+ if (changed) {
3194
+ invalidateDiffThreadGroupsCache();
3195
+ }
2566
3196
  return changed;
2567
3197
  }
2568
3198
 
@@ -2614,6 +3244,9 @@ function syncRecentCodeEventsFromTimeline({ config, runtime, state }) {
2614
3244
  );
2615
3245
  runtime.recentCodeEvents = nextItems;
2616
3246
  state.recentCodeEvents = nextItems;
3247
+ if (changed) {
3248
+ invalidateDiffThreadGroupsCache();
3249
+ }
2617
3250
  return changed;
2618
3251
  }
2619
3252
 
@@ -5134,11 +5767,33 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
5134
5767
  request,
5135
5768
  approval: existing,
5136
5769
  });
5137
- if (changed) {
5138
- const fileKeys = isPlainObject(existing.rawParams) ? Object.keys(existing.rawParams).join(",") : "";
5139
- console.log(
5140
- `[native-approval-refresh] ${requestKey} | files=${normalizeTimelineFileRefs(existing.fileRefs ?? []).length} | diff=${existing.diffAvailable || Boolean(existing.diffText) ? "yes" : "no"} | keys=${fileKeys || "none"}`
5141
- );
5770
+ const existingAutoPilotWriteCandidate =
5771
+ existing.kind === "file"
5772
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval: existing })
5773
+ : null;
5774
+ if (existingAutoPilotWriteCandidate) {
5775
+ try {
5776
+ await autoApproveTrustedWrite({
5777
+ config,
5778
+ runtime,
5779
+ state,
5780
+ approval: existing,
5781
+ candidate: existingAutoPilotWriteCandidate,
5782
+ });
5783
+ continue;
5784
+ } catch (error) {
5785
+ existing.resolved = false;
5786
+ existing.resolving = false;
5787
+ runtime.nativeApprovalsByRequestKey.set(requestKey, existing);
5788
+ runtime.nativeApprovalsByToken.set(existing.token, existing);
5789
+ console.error(`[auto-pilot-write-error] ${requestKey} | ${error.message}`);
5790
+ }
5791
+ }
5792
+ if (changed) {
5793
+ const fileKeys = isPlainObject(existing.rawParams) ? Object.keys(existing.rawParams).join(",") : "";
5794
+ console.log(
5795
+ `[native-approval-refresh] ${requestKey} | files=${normalizeTimelineFileRefs(existing.fileRefs ?? []).length} | diff=${existing.diffAvailable || Boolean(existing.diffText) ? "yes" : "no"} | keys=${fileKeys || "none"}`
5796
+ );
5142
5797
  }
5143
5798
  continue;
5144
5799
  }
@@ -5156,6 +5811,57 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
5156
5811
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
5157
5812
  runtime.nativeApprovalsByToken.set(approval.token, approval);
5158
5813
 
5814
+ const nativeAutoPilotMatch =
5815
+ approval.kind === "command"
5816
+ ? maybeAutoApproveTrustedRead({
5817
+ state,
5818
+ commandText: approval.rawParams?.command ?? approval.rawParams?.cmd ?? "",
5819
+ cwd: approval.rawParams?.cwd ?? approval.rawParams?.grantRoot ?? "",
5820
+ workspaceRoot: approval.rawParams?.grantRoot ?? approval.rawParams?.cwd ?? "",
5821
+ })
5822
+ : null;
5823
+ if (nativeAutoPilotMatch) {
5824
+ try {
5825
+ await autoApproveTrustedRead({
5826
+ config,
5827
+ runtime,
5828
+ state,
5829
+ approval,
5830
+ policyMatch: nativeAutoPilotMatch,
5831
+ });
5832
+ continue;
5833
+ } catch (error) {
5834
+ approval.resolved = false;
5835
+ approval.resolving = false;
5836
+ runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
5837
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
5838
+ console.error(`[auto-pilot-error] ${requestKey} | ${error.message}`);
5839
+ }
5840
+ }
5841
+
5842
+ const nativeAutoPilotWriteCandidate =
5843
+ approval.kind === "file"
5844
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval })
5845
+ : null;
5846
+ if (nativeAutoPilotWriteCandidate) {
5847
+ try {
5848
+ await autoApproveTrustedWrite({
5849
+ config,
5850
+ runtime,
5851
+ state,
5852
+ approval,
5853
+ candidate: nativeAutoPilotWriteCandidate,
5854
+ });
5855
+ continue;
5856
+ } catch (error) {
5857
+ approval.resolved = false;
5858
+ approval.resolving = false;
5859
+ runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
5860
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
5861
+ console.error(`[auto-pilot-write-error] ${requestKey} | ${error.message}`);
5862
+ }
5863
+ }
5864
+
5159
5865
  if (previousKeys.has(requestKey)) {
5160
5866
  const fileKeys = isPlainObject(approval.rawParams) ? Object.keys(approval.rawParams).join(",") : "";
5161
5867
  console.log(
@@ -5633,6 +6339,8 @@ async function buildNativeApprovalPayload({ config, runtime, conversationId, req
5633
6339
  ownerClientId: runtime.threadOwnerClientIds.get(conversationId) ?? null,
5634
6340
  approvalIds,
5635
6341
  rawParams,
6342
+ cwd: cleanText(rawParams?.cwd ?? rawParams?.grantRoot ?? ""),
6343
+ workspaceRoot: cleanText(rawParams?.grantRoot ?? rawParams?.cwd ?? ""),
5636
6344
  fileRefs: normalizeTimelineFileRefs(mergedDelta?.fileRefs ?? []),
5637
6345
  diffText: normalizeTimelineDiffText(mergedDelta?.diffText ?? ""),
5638
6346
  diffAvailable: Boolean(mergedDelta?.diffAvailable),
@@ -5669,6 +6377,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5669
6377
  ownerClientId: approval.ownerClientId,
5670
6378
  approvalIds: approval.approvalIds,
5671
6379
  rawParams: approval.rawParams,
6380
+ cwd: approval.cwd,
6381
+ workspaceRoot: approval.workspaceRoot,
5672
6382
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
5673
6383
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
5674
6384
  diffAvailable: Boolean(approval.diffAvailable),
@@ -5688,6 +6398,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5688
6398
  approval.ownerClientId = payload.ownerClientId;
5689
6399
  approval.approvalIds = payload.approvalIds;
5690
6400
  approval.rawParams = payload.rawParams;
6401
+ approval.cwd = payload.cwd;
6402
+ approval.workspaceRoot = payload.workspaceRoot;
5691
6403
  approval.fileRefs = payload.fileRefs;
5692
6404
  approval.diffText = payload.diffText;
5693
6405
  approval.diffAvailable = payload.diffAvailable;
@@ -5707,6 +6419,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5707
6419
  ownerClientId: approval.ownerClientId,
5708
6420
  approvalIds: approval.approvalIds,
5709
6421
  rawParams: approval.rawParams,
6422
+ cwd: approval.cwd,
6423
+ workspaceRoot: approval.workspaceRoot,
5710
6424
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
5711
6425
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
5712
6426
  diffAvailable: Boolean(approval.diffAvailable),
@@ -8724,6 +9438,8 @@ function markDevicePaired(state, config, deviceId, metadata = {}, now = Date.now
8724
9438
  return JSON.stringify(previous ?? null) !== JSON.stringify(next);
8725
9439
  }
8726
9440
 
9441
+ const TOUCH_DEVICE_TRUST_DEBOUNCE_MS = 60_000;
9442
+
8727
9443
  function touchDeviceTrust(state, config, deviceId, now = Date.now()) {
8728
9444
  const normalizedDeviceId = cleanText(deviceId || "");
8729
9445
  const current = getActiveDeviceTrustRecord(state, config, normalizedDeviceId, now);
@@ -8731,6 +9447,20 @@ function touchDeviceTrust(state, config, deviceId, now = Date.now()) {
8731
9447
  return false;
8732
9448
  }
8733
9449
 
9450
+ // Debounce: `lastAuthenticatedAtMs` is bumped to `now` on every
9451
+ // authenticated request. Without this guard, every `/api/session`
9452
+ // (and `/api/bootstrap`) call flags the record as "changed" and the
9453
+ // caller runs a full `saveState`, which synchronously stringifies an
9454
+ // 8+ MB state.json on the main thread and starves the event loop —
9455
+ // driving TLS handshake latency for any in-flight connection into the
9456
+ // seconds range. Skip the write when we've touched it recently; the
9457
+ // next real change (pairing, revocation, locale update, etc.) still
9458
+ // goes through unchanged.
9459
+ const previousTouch = Number(current.lastAuthenticatedAtMs) || 0;
9460
+ if (previousTouch > 0 && now - previousTouch < TOUCH_DEVICE_TRUST_DEBOUNCE_MS) {
9461
+ return false;
9462
+ }
9463
+
8734
9464
  const next = {
8735
9465
  ...current,
8736
9466
  lastAuthenticatedAtMs: now,
@@ -8853,6 +9583,22 @@ function buildDeviceSummary({ config, state, deviceId, record, currentDeviceId,
8853
9583
  };
8854
9584
  }
8855
9585
 
9586
+ // Shared between `/api/session` and `/api/bootstrap` so both return an
9587
+ // identical session shape.
9588
+ function buildSessionPayload({ config, state, session }) {
9589
+ return {
9590
+ authenticated: Boolean(session.authenticated),
9591
+ expiresAtMs: Number(session.expiresAtMs) || 0,
9592
+ pairingAvailable: isPairingAvailableForState(config, state),
9593
+ webPushEnabled: config.webPushEnabled,
9594
+ httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
9595
+ appVersion: appPackageVersion,
9596
+ deviceId: session.deviceId || null,
9597
+ temporaryPairing: session.temporaryPairing === true,
9598
+ ...buildSessionLocalePayload(config, state, session.deviceId),
9599
+ };
9600
+ }
9601
+
8856
9602
  function buildDevicesResponse({ config, state, session, locale }) {
8857
9603
  return {
8858
9604
  devices: activeTrustedDevices(state, config).map(({ deviceId, record }) =>
@@ -9608,7 +10354,7 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
9608
10354
  }
9609
10355
 
9610
10356
  async function buildDiffInboxItems(runtime, state, config, locale) {
9611
- return (await buildDiffThreadGroups(runtime, state, config)).map((group) => ({
10357
+ return (await getDiffThreadGroupsCached(runtime, state, config)).map((group) => ({
9612
10358
  kind: "diff_thread",
9613
10359
  token: group.token,
9614
10360
  threadId: group.threadId,
@@ -9767,14 +10513,24 @@ async function buildDiffThreadGroups(runtime, state, config) {
9767
10513
  .sort((left, right) => Number(right.latestChangedAtMs ?? 0) - Number(left.latestChangedAtMs ?? 0));
9768
10514
  }
9769
10515
 
9770
- async function buildInboxResponse(runtime, state, config, locale) {
10516
+ // Split into two halves so `/api/inbox` (pending + completed) returns
10517
+ // without waiting on the diff build, which spawns `git` subprocesses per
10518
+ // tracked repo inside `buildCurrentUnstagedChangesForRepo`. The diff half
10519
+ // is served from `/api/inbox/diff` and fetched separately by the PWA so
10520
+ // it doesn't block first paint of the inbox/completed lists.
10521
+ function buildInboxFastResponse(runtime, state, config, locale) {
9771
10522
  return {
9772
10523
  pending: buildPendingInboxItems(runtime, state, config, locale),
9773
- diff: await buildDiffInboxItems(runtime, state, config, locale),
9774
10524
  completed: buildCompletedInboxItems(runtime, state, config, locale),
9775
10525
  };
9776
10526
  }
9777
10527
 
10528
+ async function buildInboxDiffResponse(runtime, state, config, locale) {
10529
+ return {
10530
+ diff: await buildDiffInboxItems(runtime, state, config, locale),
10531
+ };
10532
+ }
10533
+
9778
10534
  function buildOperationalTimelineEntries(runtime, state, config, locale) {
9779
10535
  const now = Date.now();
9780
10536
  const items = [];
@@ -9988,9 +10744,10 @@ function buildTimelineResponse(runtime, state, config, locale) {
9988
10744
  };
9989
10745
  }
9990
10746
 
9991
- function buildPendingApprovalDetail(runtime, approval, locale) {
10747
+ function buildPendingApprovalDetail(runtime, state, approval, locale) {
9992
10748
  const previousContext = buildPreviousApprovalContext(runtime, approval);
9993
10749
  const approvalKind = cleanText(approval.kind || "");
10750
+ const autoPilotReview = buildAutoPilotManualReview(runtime, state, approval, locale);
9994
10751
  const detail = {
9995
10752
  kind: "approval",
9996
10753
  approvalKind,
@@ -10007,6 +10764,7 @@ function buildPendingApprovalDetail(runtime, approval, locale) {
10007
10764
  diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
10008
10765
  diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
10009
10766
  previousContext,
10767
+ autoPilotReview,
10010
10768
  readOnly: approval.readOnly === true,
10011
10769
  actions: approval.readOnly === true
10012
10770
  ? []
@@ -10031,6 +10789,193 @@ function buildPendingApprovalDetail(runtime, approval, locale) {
10031
10789
  return detail;
10032
10790
  }
10033
10791
 
10792
+ function buildAutoPilotManualReview(runtime, state, approval, locale) {
10793
+ if (!isPlainObject(approval) || approval.readOnly === true) {
10794
+ return null;
10795
+ }
10796
+ if (cleanText(approval.kind || "") !== "file") {
10797
+ return null;
10798
+ }
10799
+ return buildAutoPilotWriteManualReview(runtime, state, approval, locale);
10800
+ }
10801
+
10802
+ function buildAutoPilotWriteManualReview(runtime, state, approval, locale) {
10803
+ const writesEnabled = isAutoPilotTrustedWritesEnabled(state);
10804
+ if (!writesEnabled) {
10805
+ return {
10806
+ title: t(locale, "detail.autoPilot.manualTitle"),
10807
+ body: t(locale, "detail.autoPilot.writeDisabled"),
10808
+ };
10809
+ }
10810
+
10811
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
10812
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
10813
+ if (!cwd || !workspaceRoot) {
10814
+ return {
10815
+ title: t(locale, "detail.autoPilot.manualTitle"),
10816
+ body: t(locale, "detail.autoPilot.writeMissingWorkspace"),
10817
+ };
10818
+ }
10819
+
10820
+ const diffText = normalizeTimelineDiffText(approval?.diffText ?? "");
10821
+ if (!diffText) {
10822
+ return {
10823
+ title: t(locale, "detail.autoPilot.manualTitle"),
10824
+ body: t(locale, "detail.autoPilot.writeMissingDiff"),
10825
+ };
10826
+ }
10827
+
10828
+ const rawFileRefs = normalizeTimelineFileRefs(approval?.fileRefs ?? []);
10829
+ if (rawFileRefs.length === 0) {
10830
+ return {
10831
+ title: t(locale, "detail.autoPilot.manualTitle"),
10832
+ body: t(locale, "detail.autoPilot.writeMissingFiles"),
10833
+ };
10834
+ }
10835
+
10836
+ const resolvedFileRefs = resolveTrustedWriteFileRefs({
10837
+ fileRefs: approval?.fileRefs ?? [],
10838
+ cwd,
10839
+ workspaceRoot,
10840
+ });
10841
+ if (!resolvedFileRefs) {
10842
+ return {
10843
+ title: t(locale, "detail.autoPilot.manualTitle"),
10844
+ body: t(locale, "detail.autoPilot.writeInvalidFiles"),
10845
+ };
10846
+ }
10847
+
10848
+ const counts = diffLineCounts(diffText);
10849
+ const totalChangedLines =
10850
+ Math.max(0, Number(counts.addedLines) || 0) +
10851
+ Math.max(0, Number(counts.removedLines) || 0);
10852
+ if (totalChangedLines === 0) {
10853
+ return {
10854
+ title: t(locale, "detail.autoPilot.manualTitle"),
10855
+ body: t(locale, "detail.autoPilot.writeMissingDiff"),
10856
+ };
10857
+ }
10858
+ if (totalChangedLines > 120) {
10859
+ return {
10860
+ title: t(locale, "detail.autoPilot.manualTitle"),
10861
+ body: t(locale, "detail.autoPilot.writeDiffTooLarge"),
10862
+ };
10863
+ }
10864
+
10865
+ const diffSections = splitUnifiedDiffTextByFile(diffText);
10866
+ if (diffSections.length === 0) {
10867
+ return {
10868
+ title: t(locale, "detail.autoPilot.manualTitle"),
10869
+ body: t(locale, "detail.autoPilot.writeDiffUnreadable"),
10870
+ };
10871
+ }
10872
+ if (diffSections.length > 3) {
10873
+ return {
10874
+ title: t(locale, "detail.autoPilot.manualTitle"),
10875
+ body: t(locale, "detail.autoPilot.writeDiffTooLarge"),
10876
+ };
10877
+ }
10878
+
10879
+ if (
10880
+ /^(?: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)
10881
+ ) {
10882
+ return {
10883
+ title: t(locale, "detail.autoPilot.manualTitle"),
10884
+ body: t(locale, "detail.autoPilot.writeDangerousDiff"),
10885
+ };
10886
+ }
10887
+
10888
+ const diffSectionRefs = resolveTrustedWriteDiffSectionFileRefs({
10889
+ diffSections,
10890
+ cwd,
10891
+ workspaceRoot,
10892
+ });
10893
+ if (!diffSectionRefs || !sameNormalizedFileRefSet(diffSectionRefs, resolvedFileRefs)) {
10894
+ return {
10895
+ title: t(locale, "detail.autoPilot.manualTitle"),
10896
+ body: t(locale, "detail.autoPilot.writeDiffMismatch"),
10897
+ };
10898
+ }
10899
+
10900
+ const lanes = autoPilotWriteLaneState(state);
10901
+ const isContentOnly = resolvedFileRefs.every((fileRef) => isAutoPilotContentWritePath(fileRef));
10902
+ if (isContentOnly) {
10903
+ if (!lanes.content) {
10904
+ return {
10905
+ title: t(locale, "detail.autoPilot.manualTitle"),
10906
+ body: t(locale, "detail.autoPilot.writeContentDisabled"),
10907
+ };
10908
+ }
10909
+ return {
10910
+ title: t(locale, "detail.autoPilot.manualTitle"),
10911
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
10912
+ };
10913
+ }
10914
+
10915
+ const isUiTestsOnly = resolvedFileRefs.every((fileRef) => isAutoPilotUiTestWritePath(fileRef));
10916
+ if (isUiTestsOnly) {
10917
+ if (!lanes.uiTests) {
10918
+ return {
10919
+ title: t(locale, "detail.autoPilot.manualTitle"),
10920
+ body: t(locale, "detail.autoPilot.writeUiDisabled"),
10921
+ };
10922
+ }
10923
+ if (totalChangedLines > 80) {
10924
+ return {
10925
+ title: t(locale, "detail.autoPilot.manualTitle"),
10926
+ body: t(locale, "detail.autoPilot.writeUiTooLarge"),
10927
+ };
10928
+ }
10929
+ if (hasUnsafeUiOrTestWriteDiff(diffText)) {
10930
+ return {
10931
+ title: t(locale, "detail.autoPilot.manualTitle"),
10932
+ body: t(locale, "detail.autoPilot.writeUiUnsafe"),
10933
+ };
10934
+ }
10935
+ return {
10936
+ title: t(locale, "detail.autoPilot.manualTitle"),
10937
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
10938
+ };
10939
+ }
10940
+
10941
+ const isSourceOnly = resolvedFileRefs.every((fileRef) => isAutoPilotSourceWritePath(fileRef));
10942
+ if (isSourceOnly) {
10943
+ if (!lanes.source) {
10944
+ return {
10945
+ title: t(locale, "detail.autoPilot.manualTitle"),
10946
+ body: t(locale, "detail.autoPilot.writeSourceDisabled"),
10947
+ };
10948
+ }
10949
+ if (totalChangedLines > 40) {
10950
+ return {
10951
+ title: t(locale, "detail.autoPilot.manualTitle"),
10952
+ body: t(locale, "detail.autoPilot.writeSourceTooLarge"),
10953
+ };
10954
+ }
10955
+ if (hasUnsafeSourceWriteDiff(diffText)) {
10956
+ return {
10957
+ title: t(locale, "detail.autoPilot.manualTitle"),
10958
+ body: t(locale, "detail.autoPilot.writeSourceUnsafe"),
10959
+ };
10960
+ }
10961
+ if (!hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs })) {
10962
+ return {
10963
+ title: t(locale, "detail.autoPilot.manualTitle"),
10964
+ body: t(locale, "detail.autoPilot.writeSourceContinuity"),
10965
+ };
10966
+ }
10967
+ return {
10968
+ title: t(locale, "detail.autoPilot.manualTitle"),
10969
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
10970
+ };
10971
+ }
10972
+
10973
+ return {
10974
+ title: t(locale, "detail.autoPilot.manualTitle"),
10975
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
10976
+ };
10977
+ }
10978
+
10034
10979
  function buildPreviousApprovalContext(runtime, approval) {
10035
10980
  const threadId = cleanText(approval?.conversationId || "");
10036
10981
  const approvalCreatedAtMs = Number(approval?.createdAtMs) || 0;
@@ -10895,102 +11840,628 @@ async function handlePlanDecision({ config, runtime, state, planRequest, decisio
10895
11840
  }
10896
11841
  }
10897
11842
 
10898
- planRequest.resolved = true;
10899
- planRequest.resolving = false;
10900
- planRequest.isLiveRequestActive = false;
10901
- planRequest.lastSeenAtMs = Date.now();
10902
- planRequest.expiresAtMs = Date.now() + config.planRequestTtlMs;
10903
- let stateChanged = clearPlanTurnActive(state, planRequest.turnKey);
10904
- stateChanged = markPlanTurnSuppressed(state, planRequest.turnKey, config.maxSeenEvents) || stateChanged;
10905
- state.dismissedPlanRequests[planRequest.requestKey] = Date.now();
10906
- trimSeenEvents(state.dismissedPlanRequests, config.maxSeenEvents);
10907
- stateChanged = storePendingPlanRequest(state, planRequest) || stateChanged;
10908
- stateChanged = recordActionHistoryItem({
11843
+ planRequest.resolved = true;
11844
+ planRequest.resolving = false;
11845
+ planRequest.isLiveRequestActive = false;
11846
+ planRequest.lastSeenAtMs = Date.now();
11847
+ planRequest.expiresAtMs = Date.now() + config.planRequestTtlMs;
11848
+ let stateChanged = clearPlanTurnActive(state, planRequest.turnKey);
11849
+ stateChanged = markPlanTurnSuppressed(state, planRequest.turnKey, config.maxSeenEvents) || stateChanged;
11850
+ state.dismissedPlanRequests[planRequest.requestKey] = Date.now();
11851
+ trimSeenEvents(state.dismissedPlanRequests, config.maxSeenEvents);
11852
+ stateChanged = storePendingPlanRequest(state, planRequest) || stateChanged;
11853
+ stateChanged = recordActionHistoryItem({
11854
+ config,
11855
+ runtime,
11856
+ state,
11857
+ kind: "plan",
11858
+ stableId: `plan:${planRequest.requestKey}:${planRequest.lastSeenAtMs}`,
11859
+ token: planRequest.token,
11860
+ title: planRequest.title,
11861
+ messageText: `${planDecisionMessage(decision, config.defaultLocale)}\n\n${planRequest.messageText}`,
11862
+ summary: planDecisionMessage(decision, config.defaultLocale),
11863
+ outcome: decision === "implement" ? "implemented" : "dismissed",
11864
+ }) || stateChanged;
11865
+ if (stateChanged) {
11866
+ await saveState(config.stateFile, state);
11867
+ }
11868
+ console.log(`[plan-decision] ${planRequest.requestKey} | ${decision} | ${decisionTransport}`);
11869
+ }
11870
+
11871
+ function claudeToolFingerprint(toolName, toolInput) {
11872
+ if (!toolInput || typeof toolInput !== "object") return "";
11873
+ if (toolName === "Bash") {
11874
+ return String(toolInput.command || "").slice(0, 500);
11875
+ }
11876
+ if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") {
11877
+ return String(toolInput.file_path || toolInput.path || "");
11878
+ }
11879
+ if (toolName === "AskUserQuestion") {
11880
+ // PostToolUse may include the user's `answers` in toolInput, which would
11881
+ // change a naive JSON.stringify fingerprint. Hash only the question texts.
11882
+ try {
11883
+ const qs = Array.isArray(toolInput.questions) ? toolInput.questions : [];
11884
+ return JSON.stringify(qs.map((q) => String(q?.question || q?.header || ""))).slice(0, 500);
11885
+ } catch {
11886
+ return "";
11887
+ }
11888
+ }
11889
+ if (toolName === "ExitPlanMode") {
11890
+ return String(toolInput.plan || "").slice(0, 500);
11891
+ }
11892
+ try {
11893
+ return JSON.stringify(toolInput).slice(0, 500);
11894
+ } catch {
11895
+ return "";
11896
+ }
11897
+ }
11898
+
11899
+ function formatClaudeQuestionAnswers(questions, answers) {
11900
+ if (!Array.isArray(questions) || !Array.isArray(answers)) return "";
11901
+ const lines = [];
11902
+ for (let i = 0; i < questions.length; i++) {
11903
+ const q = questions[i] || {};
11904
+ const ans = answers[i] || {};
11905
+ const questionText = String(q.question || q.header || "").trim();
11906
+ if (!questionText) continue;
11907
+ const options = Array.isArray(q.options) ? q.options : [];
11908
+ const optionIndices = Array.isArray(ans.optionIndices) ? ans.optionIndices : [];
11909
+ const labels = optionIndices
11910
+ .map((idx) => options[idx]?.label)
11911
+ .filter((label) => typeof label === "string" && label.length > 0);
11912
+ const note = typeof ans.note === "string" ? ans.note.trim() : "";
11913
+ let answerLine = "";
11914
+ if (labels.length > 0) {
11915
+ answerLine = labels.join(", ");
11916
+ }
11917
+ if (note) {
11918
+ answerLine = answerLine ? `${answerLine} (note: ${note})` : note;
11919
+ }
11920
+ if (!answerLine) {
11921
+ answerLine = "(no answer)";
11922
+ }
11923
+ lines.push(`Q${i + 1}: ${questionText}`);
11924
+ lines.push(`A: ${answerLine}`);
11925
+ }
11926
+ return lines.join("\n");
11927
+ }
11928
+
11929
+ function findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerprint) {
11930
+ let match = null;
11931
+ for (const approval of runtime.nativeApprovalsByToken.values()) {
11932
+ if (approval.resolved) continue;
11933
+ if (approval.conversationId !== threadId) continue;
11934
+ if (approval.claudeToolName && toolName && approval.claudeToolName !== toolName) continue;
11935
+ if (approval.claudeToolFingerprint && fingerprint && approval.claudeToolFingerprint !== fingerprint) continue;
11936
+ if (!match || approval.createdAtMs > match.createdAtMs) match = approval;
11937
+ }
11938
+ return match;
11939
+ }
11940
+
11941
+ function maybeAutoApproveTrustedRead({ state, commandText, cwd, workspaceRoot }) {
11942
+ if (state?.autoPilotTrustedReads !== true) {
11943
+ return null;
11944
+ }
11945
+ return evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot });
11946
+ }
11947
+
11948
+ function isAutoPilotTrustedWritesEnabled(state) {
11949
+ return hasAnyAutoPilotWriteLaneEnabled(state);
11950
+ }
11951
+
11952
+ function isDeniedTrustedWritePath(candidatePath) {
11953
+ const normalized = cleanText(candidatePath || "");
11954
+ if (!normalized) {
11955
+ return true;
11956
+ }
11957
+ const lower = normalized.toLowerCase();
11958
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
11959
+ const basename = segments[segments.length - 1] || "";
11960
+ if (isSensitiveCommandPath(normalized)) {
11961
+ return true;
11962
+ }
11963
+ if (segments.some((segment) => [".github", ".gitlab", ".terraform", ".claude", ".husky", ".vscode"].includes(segment))) {
11964
+ return true;
11965
+ }
11966
+ if (
11967
+ [
11968
+ "package.json",
11969
+ "package-lock.json",
11970
+ "pnpm-lock.yaml",
11971
+ "yarn.lock",
11972
+ "bun.lockb",
11973
+ "cargo.toml",
11974
+ "cargo.lock",
11975
+ "gemfile",
11976
+ "gemfile.lock",
11977
+ "podfile",
11978
+ "podfile.lock",
11979
+ "composer.json",
11980
+ "composer.lock",
11981
+ "pipfile",
11982
+ "pipfile.lock",
11983
+ "poetry.lock",
11984
+ "requirements.txt",
11985
+ "dockerfile",
11986
+ "wrangler.toml",
11987
+ "tsconfig.json",
11988
+ "tsconfig.tsbuildinfo",
11989
+ ].includes(basename)
11990
+ ) {
11991
+ return true;
11992
+ }
11993
+ return false;
11994
+ }
11995
+
11996
+ function isAutoPilotContentWritePath(candidatePath) {
11997
+ const normalized = cleanText(candidatePath || "");
11998
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
11999
+ return false;
12000
+ }
12001
+ const lower = normalized.toLowerCase();
12002
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
12003
+ const basename = segments[segments.length - 1] || "";
12004
+ const extension = path.extname(basename);
12005
+ if ([".md", ".mdx", ".txt", ".rst", ".adoc"].includes(extension)) {
12006
+ return true;
12007
+ }
12008
+ if (["license", "notice", "copying", "readme", "changelog", "contributing"].includes(path.basename(basename, extension))) {
12009
+ return true;
12010
+ }
12011
+ if (segments.includes("i18n") && [".js", ".ts", ".json", ".yaml", ".yml"].includes(extension)) {
12012
+ return true;
12013
+ }
12014
+ if (segments.includes("messages") && extension === ".json") {
12015
+ return true;
12016
+ }
12017
+ return false;
12018
+ }
12019
+
12020
+ function isAutoPilotUiTestWritePath(candidatePath) {
12021
+ const normalized = cleanText(candidatePath || "");
12022
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
12023
+ return false;
12024
+ }
12025
+ const lower = normalized.toLowerCase();
12026
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
12027
+ const basename = segments[segments.length - 1] || "";
12028
+ const extension = path.extname(basename);
12029
+ if ([".css", ".scss", ".sass", ".less", ".styl", ".pcss"].includes(extension)) {
12030
+ return true;
12031
+ }
12032
+ if (segments.includes("__tests__") || /\.(test|spec)\.[cm]?[jt]sx?$/u.test(basename)) {
12033
+ return true;
12034
+ }
12035
+ if ((segments.includes("web") || segments.includes("components")) && [".js", ".jsx", ".ts", ".tsx", ".html"].includes(extension)) {
12036
+ return true;
12037
+ }
12038
+ return false;
12039
+ }
12040
+
12041
+ function isAutoPilotSourceWritePath(candidatePath) {
12042
+ const normalized = cleanText(candidatePath || "");
12043
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
12044
+ return false;
12045
+ }
12046
+ if (isAutoPilotContentWritePath(normalized) || isAutoPilotUiTestWritePath(normalized)) {
12047
+ return false;
12048
+ }
12049
+ const extension = path.extname(normalized.toLowerCase());
12050
+ return [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"].includes(extension);
12051
+ }
12052
+
12053
+ function extractAddedDiffLines(diffText) {
12054
+ return normalizeTimelineDiffText(diffText)
12055
+ .split("\n")
12056
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"));
12057
+ }
12058
+
12059
+ function addedDiffLinesContain(diffText, pattern) {
12060
+ return extractAddedDiffLines(diffText).some((line) => pattern.test(line.slice(1)));
12061
+ }
12062
+
12063
+ function hasUnsafeUiOrTestWriteDiff(diffText) {
12064
+ const patterns = [
12065
+ /\bprocess\.env\b/u,
12066
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
12067
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
12068
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
12069
+ /\bcrypto\b/u,
12070
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
12071
+ ];
12072
+ return (
12073
+ addedDiffLinesContain(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
12074
+ addedDiffLinesContain(diffText, /\brequire\s*\(/u) ||
12075
+ patterns.some((pattern) => addedDiffLinesContain(diffText, pattern))
12076
+ );
12077
+ }
12078
+
12079
+ function hasUnsafeSourceWriteDiff(diffText) {
12080
+ const patterns = [
12081
+ /\bprocess\.env\b/u,
12082
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
12083
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
12084
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
12085
+ /\b(?:net|tls|dgram|http2?)\b/u,
12086
+ /\bcrypto\b/u,
12087
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
12088
+ ];
12089
+ return (
12090
+ addedDiffLinesContain(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
12091
+ addedDiffLinesContain(diffText, /\brequire\s*\(/u) ||
12092
+ patterns.some((pattern) => addedDiffLinesContain(diffText, pattern))
12093
+ );
12094
+ }
12095
+
12096
+ const AUTO_PILOT_SOURCE_CONTINUITY_WINDOW_MS = 15 * 60 * 1000;
12097
+
12098
+ function isReadContinuityTimelineEntry(entry) {
12099
+ if (!isPlainObject(entry)) {
12100
+ return false;
12101
+ }
12102
+ if (cleanText(entry.kind || "") === "file_event" && cleanText(entry.fileEventType || "") === "read") {
12103
+ return true;
12104
+ }
12105
+ return (
12106
+ cleanText(entry.kind || "") === "approval" &&
12107
+ cleanText(entry.outcome || "") === "approved" &&
12108
+ !normalizeTimelineDiffText(entry.diffText ?? "") &&
12109
+ normalizeTimelineFileRefs(entry.fileRefs ?? []).length > 0
12110
+ );
12111
+ }
12112
+
12113
+ function resolveRecentReadContinuityFileRefs({ entry, cwd, workspaceRoot }) {
12114
+ const normalizedRefs = normalizeTimelineFileRefs(entry?.fileRefs ?? []);
12115
+ if (normalizedRefs.length === 0) {
12116
+ return [];
12117
+ }
12118
+ const resolvedRefs = [];
12119
+ for (const fileRef of normalizedRefs) {
12120
+ const resolved = resolveTrustedReadTargetPath({
12121
+ token: fileRef,
12122
+ cwd,
12123
+ workspaceRoot,
12124
+ });
12125
+ if (resolved) {
12126
+ resolvedRefs.push(resolved);
12127
+ }
12128
+ }
12129
+ return normalizeTimelineFileRefs(resolvedRefs);
12130
+ }
12131
+
12132
+ function hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs }) {
12133
+ const normalizedTargets = normalizeTimelineFileRefs(resolvedFileRefs ?? []);
12134
+ if (normalizedTargets.length !== 1) {
12135
+ return false;
12136
+ }
12137
+ const targetFileRef = normalizedTargets[0];
12138
+ const conversationId = cleanText(approval?.conversationId || "");
12139
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
12140
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
12141
+ if (!cwd || !workspaceRoot) {
12142
+ return false;
12143
+ }
12144
+ const timelineEntries =
12145
+ Array.isArray(runtime?.recentTimelineEntries) && runtime.recentTimelineEntries.length > 0
12146
+ ? runtime.recentTimelineEntries
12147
+ : normalizeTimelineEntries(state?.recentTimelineEntries ?? [], 40);
12148
+ const now = Date.now();
12149
+ for (const entry of timelineEntries) {
12150
+ if (!isReadContinuityTimelineEntry(entry)) {
12151
+ continue;
12152
+ }
12153
+ const createdAtMs = Number(entry?.createdAtMs) || 0;
12154
+ if (!createdAtMs || now - createdAtMs > AUTO_PILOT_SOURCE_CONTINUITY_WINDOW_MS) {
12155
+ continue;
12156
+ }
12157
+ const entryThreadId = cleanText(entry?.threadId || extractConversationIdFromStableId(entry?.stableId) || "");
12158
+ if (conversationId && entryThreadId && entryThreadId !== conversationId) {
12159
+ continue;
12160
+ }
12161
+ const entryFileRefs = resolveRecentReadContinuityFileRefs({
12162
+ entry,
12163
+ cwd,
12164
+ workspaceRoot,
12165
+ });
12166
+ if (entryFileRefs.includes(targetFileRef)) {
12167
+ return true;
12168
+ }
12169
+ }
12170
+ return false;
12171
+ }
12172
+
12173
+ function classifyTrustedWriteLane({ runtime, state, approval, resolvedFileRefs, diffText, totalChangedLines }) {
12174
+ const lanes = autoPilotWriteLaneState(state);
12175
+ if (
12176
+ lanes.content &&
12177
+ resolvedFileRefs.length >= 1 &&
12178
+ resolvedFileRefs.length <= 3 &&
12179
+ totalChangedLines <= 120 &&
12180
+ resolvedFileRefs.every((fileRef) => isAutoPilotContentWritePath(fileRef))
12181
+ ) {
12182
+ return "content";
12183
+ }
12184
+ if (
12185
+ lanes.uiTests &&
12186
+ resolvedFileRefs.length >= 1 &&
12187
+ resolvedFileRefs.length <= 2 &&
12188
+ totalChangedLines <= 80 &&
12189
+ resolvedFileRefs.every((fileRef) => isAutoPilotUiTestWritePath(fileRef)) &&
12190
+ !hasUnsafeUiOrTestWriteDiff(diffText)
12191
+ ) {
12192
+ return "ui_tests";
12193
+ }
12194
+ if (
12195
+ lanes.source &&
12196
+ resolvedFileRefs.length === 1 &&
12197
+ totalChangedLines <= 40 &&
12198
+ resolvedFileRefs.every((fileRef) => isAutoPilotSourceWritePath(fileRef)) &&
12199
+ !hasUnsafeSourceWriteDiff(diffText) &&
12200
+ hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs })
12201
+ ) {
12202
+ return "source";
12203
+ }
12204
+ return "";
12205
+ }
12206
+
12207
+ function resolveTrustedWriteFileRefs({ fileRefs, cwd, workspaceRoot }) {
12208
+ const normalizedRefs = normalizeTimelineFileRefs(fileRefs ?? []);
12209
+ if (normalizedRefs.length === 0 || normalizedRefs.length > 3) {
12210
+ return null;
12211
+ }
12212
+ const resolvedRefs = [];
12213
+ for (const fileRef of normalizedRefs) {
12214
+ const resolved = resolveTrustedReadTargetPath({
12215
+ token: fileRef,
12216
+ cwd,
12217
+ workspaceRoot,
12218
+ });
12219
+ if (!resolved || isDeniedTrustedWritePath(resolved)) {
12220
+ return null;
12221
+ }
12222
+ resolvedRefs.push(resolved);
12223
+ }
12224
+ return normalizeTimelineFileRefs(resolvedRefs);
12225
+ }
12226
+
12227
+ function sameNormalizedFileRefSet(leftRefs, rightRefs) {
12228
+ const left = normalizeTimelineFileRefs(leftRefs ?? []);
12229
+ const right = normalizeTimelineFileRefs(rightRefs ?? []);
12230
+ if (left.length !== right.length) {
12231
+ return false;
12232
+ }
12233
+ const rightSet = new Set(right);
12234
+ return left.every((value) => rightSet.has(value));
12235
+ }
12236
+
12237
+ function resolveTrustedWriteDiffSectionFileRefs({ diffSections, cwd, workspaceRoot }) {
12238
+ const resolvedSections = [];
12239
+ for (const section of diffSections) {
12240
+ const paths = extractUnifiedDiffSectionPaths(section);
12241
+ const sectionFileRef = cleanText(paths.newFileRef || paths.oldFileRef || "");
12242
+ if (!sectionFileRef) {
12243
+ return null;
12244
+ }
12245
+ if (paths.oldFileRef && paths.newFileRef && cleanText(paths.oldFileRef) !== cleanText(paths.newFileRef)) {
12246
+ return null;
12247
+ }
12248
+ const resolved = resolveTrustedDiffSectionPath({
12249
+ token: sectionFileRef,
12250
+ cwd,
12251
+ workspaceRoot,
12252
+ });
12253
+ if (!resolved || isDeniedTrustedWritePath(resolved)) {
12254
+ return null;
12255
+ }
12256
+ resolvedSections.push(resolved);
12257
+ }
12258
+ return normalizeTimelineFileRefs(resolvedSections);
12259
+ }
12260
+
12261
+ function evaluateTrustedWriteApprovalCandidate({ runtime, state, approval }) {
12262
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
12263
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
12264
+ const resolvedFileRefs = resolveTrustedWriteFileRefs({
12265
+ fileRefs: approval?.fileRefs ?? [],
12266
+ cwd,
12267
+ workspaceRoot,
12268
+ });
12269
+ const diffText = normalizeTimelineDiffText(approval?.diffText ?? "");
12270
+ if (!cwd || !workspaceRoot || !resolvedFileRefs || !diffText) {
12271
+ return null;
12272
+ }
12273
+ const counts = diffLineCounts(diffText);
12274
+ const totalChangedLines =
12275
+ Math.max(0, Number(counts.addedLines) || 0) +
12276
+ Math.max(0, Number(counts.removedLines) || 0);
12277
+ if (totalChangedLines === 0 || totalChangedLines > 120) {
12278
+ return null;
12279
+ }
12280
+ const diffSections = splitUnifiedDiffTextByFile(diffText);
12281
+ if (diffSections.length === 0 || diffSections.length > 3) {
12282
+ return null;
12283
+ }
12284
+ if (
12285
+ /^(?: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)
12286
+ ) {
12287
+ return null;
12288
+ }
12289
+ const diffSectionRefs = resolveTrustedWriteDiffSectionFileRefs({
12290
+ diffSections,
12291
+ cwd,
12292
+ workspaceRoot,
12293
+ });
12294
+ if (!diffSectionRefs || !sameNormalizedFileRefSet(diffSectionRefs, resolvedFileRefs)) {
12295
+ return null;
12296
+ }
12297
+ const lane = classifyTrustedWriteLane({
12298
+ runtime,
12299
+ state: state ?? approval?.stateSnapshot ?? null,
12300
+ approval,
12301
+ resolvedFileRefs: diffSectionRefs,
12302
+ diffText,
12303
+ totalChangedLines,
12304
+ });
12305
+ if (!lane) {
12306
+ return null;
12307
+ }
12308
+ return {
12309
+ fileRefs: diffSectionRefs,
12310
+ diffText,
12311
+ diffAddedLines: Math.max(0, Number(counts.addedLines) || 0),
12312
+ diffRemovedLines: Math.max(0, Number(counts.removedLines) || 0),
12313
+ totalChangedLines,
12314
+ lane,
12315
+ };
12316
+ }
12317
+
12318
+ function maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval }) {
12319
+ if (!isAutoPilotTrustedWritesEnabled(state)) {
12320
+ return null;
12321
+ }
12322
+ if (cleanText(approval?.kind || "") !== "file") {
12323
+ return null;
12324
+ }
12325
+ return evaluateTrustedWriteApprovalCandidate({
12326
+ runtime,
12327
+ state,
12328
+ approval: { ...approval, stateSnapshot: state },
12329
+ });
12330
+ }
12331
+
12332
+ function autoPilotTrustedWriteMessage(locale, approval, candidate) {
12333
+ const prefix = t(locale, "server.message.autoPilotTrustedWriteApproved");
12334
+ const fileBlock = candidate?.fileRefs?.length
12335
+ ? `${t(locale, "server.message.filesLabel")}\n\`\`\`text\n${candidate.fileRefs.join("\n")}\n\`\`\``
12336
+ : "";
12337
+ const sizeLine =
12338
+ candidate && Number.isFinite(candidate.diffAddedLines) && Number.isFinite(candidate.diffRemovedLines)
12339
+ ? `${t(locale, "server.message.diffSummaryLabel")} +${candidate.diffAddedLines} / -${candidate.diffRemovedLines}`
12340
+ : "";
12341
+ const originalMessage = cleanText(approval?.messageText || "");
12342
+ return [prefix, sizeLine, fileBlock, originalMessage].filter(Boolean).join("\n\n");
12343
+ }
12344
+
12345
+ async function recordAutoApprovedTrustedRead({
12346
+ config,
12347
+ runtime,
12348
+ state,
12349
+ approval,
12350
+ policyMatch,
12351
+ }) {
12352
+ const stateChanged = recordActionHistoryItem({
10909
12353
  config,
10910
12354
  runtime,
10911
12355
  state,
10912
- kind: "plan",
10913
- stableId: `plan:${planRequest.requestKey}:${planRequest.lastSeenAtMs}`,
10914
- token: planRequest.token,
10915
- title: planRequest.title,
10916
- messageText: `${planDecisionMessage(decision, config.defaultLocale)}\n\n${planRequest.messageText}`,
10917
- summary: planDecisionMessage(decision, config.defaultLocale),
10918
- outcome: decision === "implement" ? "implemented" : "dismissed",
10919
- }) || stateChanged;
12356
+ kind: "approval",
12357
+ stableId: `approval:${approval.requestKey}:autopilot`,
12358
+ token: approval.token,
12359
+ title: approval.title,
12360
+ threadLabel: approval.threadLabel || "",
12361
+ messageText: autoPilotApprovalMessage(config.defaultLocale, policyMatch.commandText),
12362
+ summary: t(config.defaultLocale, "server.message.autoPilotTrustedReadSummary"),
12363
+ fileRefs: normalizeTimelineFileRefs(policyMatch.fileRefs ?? approval.fileRefs ?? []),
12364
+ diffText: "",
12365
+ diffSource: "",
12366
+ diffAvailable: false,
12367
+ diffAddedLines: 0,
12368
+ diffRemovedLines: 0,
12369
+ outcome: "approved",
12370
+ provider: approval.provider || "codex",
12371
+ });
10920
12372
  if (stateChanged) {
10921
12373
  await saveState(config.stateFile, state);
10922
12374
  }
10923
- console.log(`[plan-decision] ${planRequest.requestKey} | ${decision} | ${decisionTransport}`);
10924
12375
  }
10925
12376
 
10926
- function claudeToolFingerprint(toolName, toolInput) {
10927
- if (!toolInput || typeof toolInput !== "object") return "";
10928
- if (toolName === "Bash") {
10929
- return String(toolInput.command || "").slice(0, 500);
10930
- }
10931
- if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") {
10932
- return String(toolInput.file_path || toolInput.path || "");
10933
- }
10934
- if (toolName === "AskUserQuestion") {
10935
- // PostToolUse may include the user's `answers` in toolInput, which would
10936
- // change a naive JSON.stringify fingerprint. Hash only the question texts.
10937
- try {
10938
- const qs = Array.isArray(toolInput.questions) ? toolInput.questions : [];
10939
- return JSON.stringify(qs.map((q) => String(q?.question || q?.header || ""))).slice(0, 500);
10940
- } catch {
10941
- return "";
10942
- }
10943
- }
10944
- if (toolName === "ExitPlanMode") {
10945
- return String(toolInput.plan || "").slice(0, 500);
10946
- }
10947
- try {
10948
- return JSON.stringify(toolInput).slice(0, 500);
10949
- } catch {
10950
- return "";
12377
+ async function recordAutoApprovedTrustedWrite({
12378
+ config,
12379
+ runtime,
12380
+ state,
12381
+ approval,
12382
+ candidate,
12383
+ }) {
12384
+ const stateChanged = recordActionHistoryItem({
12385
+ config,
12386
+ runtime,
12387
+ state,
12388
+ kind: "approval",
12389
+ stableId: `approval:${approval.requestKey}:autopilot-write:${candidate.lane || "content"}`,
12390
+ token: approval.token,
12391
+ title: approval.title,
12392
+ threadLabel: approval.threadLabel || "",
12393
+ messageText: autoPilotTrustedWriteMessage(config.defaultLocale, approval, candidate),
12394
+ summary: t(config.defaultLocale, "server.message.autoPilotTrustedWriteSummary"),
12395
+ fileRefs: candidate.fileRefs,
12396
+ diffText: candidate.diffText,
12397
+ diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
12398
+ diffAvailable: true,
12399
+ diffAddedLines: candidate.diffAddedLines,
12400
+ diffRemovedLines: candidate.diffRemovedLines,
12401
+ outcome: "approved",
12402
+ provider: approval.provider || "codex",
12403
+ });
12404
+ if (stateChanged) {
12405
+ await saveState(config.stateFile, state);
10951
12406
  }
10952
12407
  }
10953
12408
 
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}`);
12409
+ async function autoApproveTrustedRead({
12410
+ config,
12411
+ runtime,
12412
+ state,
12413
+ approval,
12414
+ policyMatch,
12415
+ }) {
12416
+ if (approval.provider === "claude" && approval.resolveClaudeWaiter) {
12417
+ approval.resolveClaudeWaiter({
12418
+ permissionDecision: "allow",
12419
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedReadReason"),
12420
+ });
12421
+ } else if (approval.provider !== "claude") {
12422
+ await runtime.ipcClient?.sendApprovalDecision(approval, "accept");
10980
12423
  }
10981
- return lines.join("\n");
12424
+ approval.resolved = true;
12425
+ approval.resolving = false;
12426
+ runtime.nativeApprovalsByToken.delete(approval.token);
12427
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
12428
+ await recordAutoApprovedTrustedRead({
12429
+ config,
12430
+ runtime,
12431
+ state,
12432
+ approval,
12433
+ policyMatch,
12434
+ });
12435
+ console.log(`[auto-pilot-approved] ${approval.requestKey} | ${policyMatch.command}`);
10982
12436
  }
10983
12437
 
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;
12438
+ async function autoApproveTrustedWrite({
12439
+ config,
12440
+ runtime,
12441
+ state,
12442
+ approval,
12443
+ candidate,
12444
+ }) {
12445
+ if (approval.provider === "claude" && approval.resolveClaudeWaiter) {
12446
+ approval.resolveClaudeWaiter({
12447
+ permissionDecision: "allow",
12448
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedWriteReason"),
12449
+ });
12450
+ } else if (approval.provider !== "claude") {
12451
+ await runtime.ipcClient?.sendApprovalDecision(approval, "accept");
10992
12452
  }
10993
- return match;
12453
+ approval.resolved = true;
12454
+ approval.resolving = false;
12455
+ runtime.nativeApprovalsByToken.delete(approval.token);
12456
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
12457
+ await recordAutoApprovedTrustedWrite({
12458
+ config,
12459
+ runtime,
12460
+ state,
12461
+ approval,
12462
+ candidate,
12463
+ });
12464
+ console.log(`[auto-pilot-approved-write] ${approval.requestKey} | files=${candidate.fileRefs.length} | lines=${candidate.totalChangedLines}`);
10994
12465
  }
10995
12466
 
10996
12467
  async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
@@ -11047,7 +12518,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
11047
12518
 
11048
12519
  async function buildApiItemDetail({ config, runtime, state, kind, token, locale }) {
11049
12520
  if (kind === "diff_thread") {
11050
- const group = (await buildDiffThreadGroups(runtime, state, config)).find((entry) => entry.token === token);
12521
+ const group = (await getDiffThreadGroupsCached(runtime, state, config)).find((entry) => entry.token === token);
11051
12522
  return group ? buildDiffThreadDetail(group, locale) : null;
11052
12523
  }
11053
12524
  if (kind === "file_event") {
@@ -11113,7 +12584,7 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
11113
12584
  if (kind === "approval") {
11114
12585
  const approval = runtime.nativeApprovalsByToken.get(token);
11115
12586
  if (approval) {
11116
- return buildPendingApprovalDetail(runtime, approval, locale);
12587
+ return buildPendingApprovalDetail(runtime, state, approval, locale);
11117
12588
  }
11118
12589
  const historicalApproval = historyItemByToken(runtime, kind, token);
11119
12590
  return historicalApproval ? buildHistoryDetail(historicalApproval, locale, runtime) : null;
@@ -11343,21 +12814,81 @@ function contentTypeForFile(filePath) {
11343
12814
  }
11344
12815
  }
11345
12816
 
11346
- async function serveWebAsset(res, urlPath) {
12817
+ // Per-file cache of {raw, gzip, mtimeMs}. Gzip of the ~464KB app shell
12818
+ // (app.js 278KB + app.css 76KB + i18n.js 110KB) takes a few ms to compute,
12819
+ // and without this the bridge would recompress on every request. Keyed by
12820
+ // resolved filePath so /app and /index.html share a cache slot.
12821
+ const WEB_ASSET_CACHE = new Map();
12822
+ const GZIPPABLE_EXTENSIONS = new Set([
12823
+ ".js",
12824
+ ".mjs",
12825
+ ".css",
12826
+ ".html",
12827
+ ".htm",
12828
+ ".json",
12829
+ ".webmanifest",
12830
+ ".svg",
12831
+ ".txt",
12832
+ ".map",
12833
+ ]);
12834
+ const MIN_GZIP_BYTES = 512;
12835
+
12836
+ async function loadWebAssetEntry(filePath) {
12837
+ const stat = await fs.stat(filePath);
12838
+ const cached = WEB_ASSET_CACHE.get(filePath);
12839
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
12840
+ return cached;
12841
+ }
12842
+ const raw = await fs.readFile(filePath);
12843
+ const extension = path.extname(filePath).toLowerCase();
12844
+ let gzip = null;
12845
+ if (GZIPPABLE_EXTENSIONS.has(extension) && raw.length >= MIN_GZIP_BYTES) {
12846
+ try {
12847
+ gzip = zlib.gzipSync(raw, { level: 6 });
12848
+ } catch {
12849
+ gzip = null;
12850
+ }
12851
+ }
12852
+ const entry = {
12853
+ raw,
12854
+ gzip,
12855
+ mtimeMs: stat.mtimeMs,
12856
+ size: stat.size,
12857
+ contentType: contentTypeForFile(filePath),
12858
+ };
12859
+ WEB_ASSET_CACHE.set(filePath, entry);
12860
+ return entry;
12861
+ }
12862
+
12863
+ function clientAcceptsGzip(req) {
12864
+ const header = req?.headers?.["accept-encoding"];
12865
+ if (!header) return false;
12866
+ const value = Array.isArray(header) ? header.join(",") : String(header);
12867
+ return /\bgzip\b/i.test(value);
12868
+ }
12869
+
12870
+ async function serveWebAsset(res, urlPath, req) {
11347
12871
  const filePath = resolveWebAsset(urlPath);
11348
12872
  if (!filePath) {
11349
12873
  return false;
11350
12874
  }
11351
12875
 
11352
12876
  try {
11353
- const body = await fs.readFile(filePath);
12877
+ const entry = await loadWebAssetEntry(filePath);
12878
+ const wantsGzip = entry.gzip && clientAcceptsGzip(req);
11354
12879
  res.statusCode = 200;
11355
- res.setHeader("Content-Type", contentTypeForFile(filePath));
12880
+ res.setHeader("Content-Type", entry.contentType);
11356
12881
  res.setHeader("Cache-Control", "no-store, max-age=0");
11357
12882
  if (urlPath === "/sw.js") {
11358
12883
  res.setHeader("Service-Worker-Allowed", "/");
11359
12884
  }
11360
- res.end(body);
12885
+ if (wantsGzip) {
12886
+ res.setHeader("Content-Encoding", "gzip");
12887
+ res.setHeader("Vary", "Accept-Encoding");
12888
+ res.end(entry.gzip);
12889
+ } else {
12890
+ res.end(entry.raw);
12891
+ }
11361
12892
  return true;
11362
12893
  } catch {
11363
12894
  return false;
@@ -11492,7 +13023,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11492
13023
  url.pathname === "/sw.js" ||
11493
13024
  url.pathname.startsWith("/icons/")
11494
13025
  ) {
11495
- const served = await serveWebAsset(res, url.pathname);
13026
+ const served = await serveWebAsset(res, url.pathname, req);
11496
13027
  if (served) {
11497
13028
  return;
11498
13029
  }
@@ -11632,16 +13163,41 @@ function createNativeApprovalServer({ config, runtime, state }) {
11632
13163
  if (session.authenticated && session.restoredFromDevice) {
11633
13164
  setSessionCookie(res, config);
11634
13165
  }
13166
+ return writeJson(res, 200, buildSessionPayload({ config, state, session }));
13167
+ }
13168
+
13169
+ // Collapses the PWA's boot-time fan-out (session + inbox + timeline
13170
+ // + devices) into a single HTTPS round-trip. iOS Safari tears down
13171
+ // connections aggressively, so each parallel fetch pays its own
13172
+ // TLS handshake — and `JSON.stringify(state)` hiccups on other
13173
+ // requests make those handshakes bursty. One endpoint = one
13174
+ // handshake + one response, which drops the "Completed list is
13175
+ // blank for several seconds after launch" latency floor.
13176
+ if (url.pathname === "/api/bootstrap" && req.method === "GET") {
13177
+ const session = readSession(req, config, state);
13178
+ const localeInfo = resolveDeviceLocaleInfo(config, state, session.deviceId);
13179
+ if (session.authenticated && session.deviceId) {
13180
+ const trustChanged = touchDeviceTrust(state, config, session.deviceId);
13181
+ const metadataChanged = updateCurrentDeviceSnapshot(state, config, session.deviceId, {
13182
+ userAgent: requestUserAgent(req),
13183
+ lastLocale: localeInfo.locale,
13184
+ });
13185
+ if (trustChanged || metadataChanged) {
13186
+ await saveState(config.stateFile, state);
13187
+ }
13188
+ }
13189
+ if (session.authenticated && session.restoredFromDevice) {
13190
+ setSessionCookie(res, config);
13191
+ }
13192
+ const sessionPayload = buildSessionPayload({ config, state, session });
13193
+ if (!session.authenticated) {
13194
+ return writeJson(res, 200, { session: sessionPayload });
13195
+ }
11635
13196
  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),
13197
+ session: sessionPayload,
13198
+ inbox: buildInboxFastResponse(runtime, state, config, localeInfo.locale),
13199
+ timeline: buildTimelineResponse(runtime, state, config, localeInfo.locale),
13200
+ devices: buildDevicesResponse({ config, state, session, locale: localeInfo.locale }),
11645
13201
  });
11646
13202
  }
11647
13203
 
@@ -11756,7 +13312,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11756
13312
  lastLocale: resolveDeviceLocaleInfo(config, state, session.deviceId).locale,
11757
13313
  }) || changed;
11758
13314
  if (changed) {
11759
- await saveState(config.stateFile, state);
13315
+ scheduleSaveState(config, state);
11760
13316
  }
11761
13317
  return writeJson(res, 200, {
11762
13318
  ok: true,
@@ -11778,12 +13334,80 @@ function createNativeApprovalServer({ config, runtime, state }) {
11778
13334
  const enabled = payload?.enabled === true;
11779
13335
  if (state.claudeAwayMode !== enabled) {
11780
13336
  state.claudeAwayMode = enabled;
11781
- await saveState(config.stateFile, state);
13337
+ scheduleSaveState(config, state);
11782
13338
  }
11783
13339
  await syncClaudeAwayModeSentinel(config, enabled);
11784
13340
  return writeJson(res, 200, { ok: true, enabled });
11785
13341
  }
11786
13342
 
13343
+ if (url.pathname === "/api/settings/auto-pilot" && req.method === "POST") {
13344
+ const session = requireMutatingApiSession(req, res, config, state);
13345
+ if (!session) {
13346
+ return;
13347
+ }
13348
+ let payload;
13349
+ try {
13350
+ payload = await parseJsonBody(req);
13351
+ } catch {
13352
+ return writeJson(res, 400, { error: "invalid-json-body" });
13353
+ }
13354
+ const trustedReadsEnabled =
13355
+ typeof payload?.trustedReadsEnabled === "boolean"
13356
+ ? payload.trustedReadsEnabled === true
13357
+ : state.autoPilotTrustedReads === true;
13358
+ const hasLaneUpdate =
13359
+ typeof payload?.writeLaneContentEnabled === "boolean" ||
13360
+ typeof payload?.writeLaneUiTestsEnabled === "boolean" ||
13361
+ typeof payload?.writeLaneSourceEnabled === "boolean";
13362
+ const legacyTrustedWritesEnabled =
13363
+ typeof payload?.trustedWritesEnabled === "boolean"
13364
+ ? payload.trustedWritesEnabled === true
13365
+ : typeof payload?.trustedWritesShadowEnabled === "boolean"
13366
+ ? payload.trustedWritesShadowEnabled === true
13367
+ : null;
13368
+ const currentLanes = autoPilotWriteLaneState(state);
13369
+ const writeLaneContentEnabled =
13370
+ typeof payload?.writeLaneContentEnabled === "boolean"
13371
+ ? payload.writeLaneContentEnabled === true
13372
+ : !hasLaneUpdate && legacyTrustedWritesEnabled != null
13373
+ ? legacyTrustedWritesEnabled
13374
+ : currentLanes.content;
13375
+ const writeLaneUiTestsEnabled =
13376
+ typeof payload?.writeLaneUiTestsEnabled === "boolean"
13377
+ ? payload.writeLaneUiTestsEnabled === true
13378
+ : currentLanes.uiTests;
13379
+ const writeLaneSourceEnabled =
13380
+ typeof payload?.writeLaneSourceEnabled === "boolean"
13381
+ ? payload.writeLaneSourceEnabled === true
13382
+ : currentLanes.source;
13383
+ const trustedWritesEnabled =
13384
+ writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled;
13385
+ if (
13386
+ state.autoPilotTrustedReads !== trustedReadsEnabled ||
13387
+ state.autoPilotWriteLaneContent !== writeLaneContentEnabled ||
13388
+ state.autoPilotWriteLaneUiTests !== writeLaneUiTestsEnabled ||
13389
+ state.autoPilotWriteLaneSource !== writeLaneSourceEnabled ||
13390
+ isAutoPilotTrustedWritesEnabled(state) !== trustedWritesEnabled
13391
+ ) {
13392
+ state.autoPilotTrustedReads = trustedReadsEnabled;
13393
+ state.autoPilotTrustedWrites = trustedWritesEnabled;
13394
+ state.autoPilotTrustedWritesShadow = false;
13395
+ state.autoPilotWriteLaneContent = writeLaneContentEnabled;
13396
+ state.autoPilotWriteLaneUiTests = writeLaneUiTestsEnabled;
13397
+ state.autoPilotWriteLaneSource = writeLaneSourceEnabled;
13398
+ await saveState(config.stateFile, state);
13399
+ }
13400
+ return writeJson(res, 200, {
13401
+ ok: true,
13402
+ trustedReadsEnabled,
13403
+ trustedWritesEnabled,
13404
+ trustedWritesShadowEnabled: false,
13405
+ writeLaneContentEnabled,
13406
+ writeLaneUiTestsEnabled,
13407
+ writeLaneSourceEnabled,
13408
+ });
13409
+ }
13410
+
11787
13411
  if (url.pathname === "/api/settings/a2a-executor" && req.method === "POST") {
11788
13412
  const session = requireMutatingApiSession(req, res, config, state);
11789
13413
  if (!session) return;
@@ -11797,7 +13421,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11797
13421
  const preference = valid.has(payload?.preference) ? payload.preference : "auto";
11798
13422
  if (state.a2aExecutorPreference !== preference) {
11799
13423
  state.a2aExecutorPreference = preference;
11800
- await saveState(config.stateFile, state);
13424
+ scheduleSaveState(config, state);
11801
13425
  }
11802
13426
  return writeJson(res, 200, { ok: true, preference });
11803
13427
  }
@@ -11975,11 +13599,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11975
13599
  const accept = body?.accept === true;
11976
13600
  config.a2aAcceptPublicTasks = accept;
11977
13601
  state.a2aAcceptPublicTasks = accept;
11978
- try {
11979
- await saveState(config.stateFile, state);
11980
- } catch (error) {
11981
- console.error(`[a2a-public-tasks-save] ${error.message}`);
11982
- }
13602
+ scheduleSaveState(config, state);
11983
13603
  // Re-register with relay to propagate the flag.
11984
13604
  if (config.a2aRelayUrl && config.a2aRelayUserId) {
11985
13605
  try {
@@ -12393,7 +14013,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
12393
14013
  lastLocale: resolveDeviceLocaleInfo(config, state, session.deviceId).locale,
12394
14014
  });
12395
14015
  if (changed || metadataChanged) {
12396
- await saveState(config.stateFile, state);
14016
+ scheduleSaveState(config, state);
12397
14017
  }
12398
14018
  return writeJson(res, 200, {
12399
14019
  ok: true,
@@ -12416,7 +14036,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
12416
14036
  changed = deletePushSubscriptionsForDevice(state, session.deviceId) || changed;
12417
14037
  }
12418
14038
  if (changed) {
12419
- await saveState(config.stateFile, state);
14039
+ scheduleSaveState(config, state);
12420
14040
  }
12421
14041
  return writeJson(res, 200, { ok: true, subscribed: false });
12422
14042
  }
@@ -12428,13 +14048,13 @@ function createNativeApprovalServer({ config, runtime, state }) {
12428
14048
  }
12429
14049
  try {
12430
14050
  await sendPushTestToDevice({ config, state, session });
12431
- await saveState(config.stateFile, state);
14051
+ scheduleSaveState(config, state);
12432
14052
  return writeJson(res, 200, { ok: true });
12433
14053
  } catch (error) {
12434
14054
  const statusCode = Number(error?.statusCode) || 0;
12435
14055
  if (statusCode === 404 || statusCode === 410) {
12436
14056
  deletePushSubscriptionsForDevice(state, session.deviceId);
12437
- await saveState(config.stateFile, state);
14057
+ scheduleSaveState(config, state);
12438
14058
  return writeJson(res, 410, { error: "push-subscription-expired" });
12439
14059
  }
12440
14060
  return writeJson(res, 500, {
@@ -12640,6 +14260,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12640
14260
  diffSource: String(body.diffSource || "claude_permission_request"),
12641
14261
  diffAddedLines: Math.max(0, Number(body.diffAddedLines) || 0),
12642
14262
  diffRemovedLines: Math.max(0, Number(body.diffRemovedLines) || 0),
14263
+ cwd: body.cwd ? String(body.cwd) : "",
14264
+ workspaceRoot: body.cwd ? String(body.cwd) : "",
12643
14265
  createdAtMs: Number(body.createdAtMs) || Date.now(),
12644
14266
  resolved: false,
12645
14267
  resolving: false,
@@ -12653,6 +14275,59 @@ function createNativeApprovalServer({ config, runtime, state }) {
12653
14275
  readOnly: body.notifyOnly === true,
12654
14276
  };
12655
14277
 
14278
+ const claudeAutoPilotMatch =
14279
+ approval.kind === "command"
14280
+ ? maybeAutoApproveTrustedRead({
14281
+ state,
14282
+ commandText: body?.toolInput?.command ?? "",
14283
+ cwd: body.cwd ? String(body.cwd) : "",
14284
+ workspaceRoot: body.cwd ? String(body.cwd) : "",
14285
+ })
14286
+ : null;
14287
+ if (claudeAutoPilotMatch) {
14288
+ try {
14289
+ await recordAutoApprovedTrustedRead({
14290
+ config,
14291
+ runtime,
14292
+ state,
14293
+ approval,
14294
+ policyMatch: claudeAutoPilotMatch,
14295
+ });
14296
+ console.log(`[auto-pilot-approved] ${approval.requestKey} | ${claudeAutoPilotMatch.command}`);
14297
+ return writeJson(res, 200, {
14298
+ permissionDecision: "allow",
14299
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedReadReason"),
14300
+ });
14301
+ } catch (error) {
14302
+ console.error(`[auto-pilot-error] ${approval.requestKey} | ${error.message}`);
14303
+ }
14304
+ }
14305
+
14306
+ const claudeAutoPilotWriteCandidate =
14307
+ approval.kind === "file"
14308
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval })
14309
+ : null;
14310
+ if (claudeAutoPilotWriteCandidate) {
14311
+ try {
14312
+ await recordAutoApprovedTrustedWrite({
14313
+ config,
14314
+ runtime,
14315
+ state,
14316
+ approval,
14317
+ candidate: claudeAutoPilotWriteCandidate,
14318
+ });
14319
+ console.log(
14320
+ `[auto-pilot-approved-write] ${approval.requestKey} | files=${claudeAutoPilotWriteCandidate.fileRefs.length} | lines=${claudeAutoPilotWriteCandidate.totalChangedLines}`
14321
+ );
14322
+ return writeJson(res, 200, {
14323
+ permissionDecision: "allow",
14324
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedWriteReason"),
14325
+ });
14326
+ } catch (error) {
14327
+ console.error(`[auto-pilot-write-error] ${approval.requestKey} | ${error.message}`);
14328
+ }
14329
+ }
14330
+
12656
14331
  runtime.nativeApprovalsByToken.set(token, approval);
12657
14332
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
12658
14333
 
@@ -13089,7 +14764,16 @@ function createNativeApprovalServer({ config, runtime, state }) {
13089
14764
  return;
13090
14765
  }
13091
14766
  const locale = resolveDeviceLocaleInfo(config, state, session.deviceId).locale;
13092
- return writeJson(res, 200, await buildInboxResponse(runtime, state, config, locale));
14767
+ return writeJson(res, 200, buildInboxFastResponse(runtime, state, config, locale));
14768
+ }
14769
+
14770
+ if (url.pathname === "/api/inbox/diff" && req.method === "GET") {
14771
+ const session = requireApiSession(req, res, config, state);
14772
+ if (!session) {
14773
+ return;
14774
+ }
14775
+ const locale = resolveDeviceLocaleInfo(config, state, session.deviceId).locale;
14776
+ return writeJson(res, 200, await buildInboxDiffResponse(runtime, state, config, locale));
13093
14777
  }
13094
14778
 
13095
14779
  if (url.pathname === "/api/timeline" && req.method === "GET") {
@@ -15443,6 +17127,23 @@ async function loadState(stateFile) {
15443
17127
  try {
15444
17128
  const raw = await fs.readFile(stateFile, "utf8");
15445
17129
  const parsed = JSON.parse(raw);
17130
+ const hasExplicitWriteLaneState =
17131
+ typeof parsed.autoPilotWriteLaneContent === "boolean" ||
17132
+ typeof parsed.autoPilotWriteLaneUiTests === "boolean" ||
17133
+ typeof parsed.autoPilotWriteLaneSource === "boolean";
17134
+ const legacyTrustedWritesEnabled = parsed.autoPilotTrustedWrites === true || parsed.autoPilotTrustedWritesShadow === true;
17135
+ const autoPilotWriteLaneContent =
17136
+ typeof parsed.autoPilotWriteLaneContent === "boolean"
17137
+ ? parsed.autoPilotWriteLaneContent === true
17138
+ : !hasExplicitWriteLaneState && legacyTrustedWritesEnabled;
17139
+ const autoPilotWriteLaneUiTests =
17140
+ typeof parsed.autoPilotWriteLaneUiTests === "boolean"
17141
+ ? parsed.autoPilotWriteLaneUiTests === true
17142
+ : false;
17143
+ const autoPilotWriteLaneSource =
17144
+ typeof parsed.autoPilotWriteLaneSource === "boolean"
17145
+ ? parsed.autoPilotWriteLaneSource === true
17146
+ : false;
15446
17147
  return {
15447
17148
  fileOffsets: parsed.fileOffsets ?? {},
15448
17149
  seenEvents: parsed.seenEvents ?? {},
@@ -15468,6 +17169,12 @@ async function loadState(stateFile) {
15468
17169
  pairingConsumedAt: Number(parsed.pairingConsumedAt) || 0,
15469
17170
  pairingConsumedCredential: cleanText(parsed.pairingConsumedCredential ?? ""),
15470
17171
  claudeAwayMode: parsed.claudeAwayMode === true,
17172
+ autoPilotTrustedReads: parsed.autoPilotTrustedReads === true,
17173
+ autoPilotTrustedWrites: autoPilotWriteLaneContent || autoPilotWriteLaneUiTests || autoPilotWriteLaneSource,
17174
+ autoPilotTrustedWritesShadow: false,
17175
+ autoPilotWriteLaneContent,
17176
+ autoPilotWriteLaneUiTests,
17177
+ autoPilotWriteLaneSource,
15471
17178
  a2aAcceptPublicTasks: parsed.a2aAcceptPublicTasks === true,
15472
17179
  };
15473
17180
  } catch {
@@ -15496,6 +17203,12 @@ async function loadState(stateFile) {
15496
17203
  pairingConsumedAt: 0,
15497
17204
  pairingConsumedCredential: "",
15498
17205
  claudeAwayMode: false,
17206
+ autoPilotTrustedReads: false,
17207
+ autoPilotTrustedWrites: false,
17208
+ autoPilotTrustedWritesShadow: false,
17209
+ autoPilotWriteLaneContent: false,
17210
+ autoPilotWriteLaneUiTests: false,
17211
+ autoPilotWriteLaneSource: false,
15499
17212
  };
15500
17213
  }
15501
17214
  }
@@ -15520,7 +17233,11 @@ async function syncClaudeAwayModeSentinel(config, enabled) {
15520
17233
  }
15521
17234
 
15522
17235
  async function saveState(stateFile, state) {
15523
- const output = JSON.stringify(state, null, 2);
17236
+ // No indent. The state file is ~8 MB and humans don't read it; pretty-print
17237
+ // just costs a few extra ms of stringify and ~10% more bytes on disk. The
17238
+ // single newline at the end keeps diffs/hexdumps working on the off chance
17239
+ // someone cats the file.
17240
+ const output = JSON.stringify(state);
15524
17241
  await fs.mkdir(path.dirname(stateFile), { recursive: true });
15525
17242
  await fs.writeFile(stateFile, `${output}\n`, "utf8");
15526
17243
  }
@@ -16128,6 +17845,7 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
16128
17845
  ) {
16129
17846
  runtime.recentCodeEvents = nextCodeEvents;
16130
17847
  state.recentCodeEvents = nextCodeEvents;
17848
+ invalidateDiffThreadGroupsCache();
16131
17849
  changed = true;
16132
17850
  }
16133
17851
 
@@ -16660,7 +18378,16 @@ async function main() {
16660
18378
  try {
16661
18379
  const dirty = await scanOnce({ config, runtime, state });
16662
18380
  if (dirty) {
16663
- await saveState(config.stateFile, state);
18381
+ // Fire-and-forget via the debouncer rather than awaiting — the
18382
+ // ~8 MB state file used to block the scan loop for ~50-100 ms on
18383
+ // every dirty iteration, which directly delays the next rollout /
18384
+ // history.jsonl read and therefore how quickly new user_message
18385
+ // entries reach the phone. runtime/state.recentTimelineEntries are
18386
+ // already updated synchronously inside recordTimelineEntry, so the
18387
+ // /api/timeline response sees fresh data the moment the client
18388
+ // next polls, regardless of when the disk write lands. Pending
18389
+ // writes are drained in the `finally` block below on SIGINT/SIGTERM.
18390
+ scheduleSaveState(config, state);
16664
18391
  }
16665
18392
  } catch (error) {
16666
18393
  console.error(`[scan-error] ${error.message}`);
@@ -16737,5 +18464,10 @@ async function main() {
16737
18464
  if (approvalServer) {
16738
18465
  await stopHttpServer(approvalServer);
16739
18466
  }
18467
+
18468
+ // Drain any state mutations that were queued via scheduleSaveState but
18469
+ // not yet written to disk — otherwise SIGINT/SIGTERM mid-debounce would
18470
+ // silently drop the last ~500 ms of settings/notification changes.
18471
+ await flushPendingStateWrite(config, state);
16740
18472
  }
16741
18473
  }