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.
- package/README.md +23 -0
- package/package.json +1 -1
- package/scripts/viveworker-bridge.mjs +1853 -121
- package/web/app.css +228 -9
- package/web/app.js +878 -26
- package/web/i18n.js +114 -0
- package/web/sw.js +33 -15
|
@@ -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
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
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
|
|
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
|
-
|
|
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: "
|
|
10913
|
-
stableId: `
|
|
10914
|
-
token:
|
|
10915
|
-
title:
|
|
10916
|
-
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
|
|
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
|
|
10927
|
-
|
|
10928
|
-
|
|
10929
|
-
|
|
10930
|
-
|
|
10931
|
-
|
|
10932
|
-
|
|
10933
|
-
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
10938
|
-
|
|
10939
|
-
|
|
10940
|
-
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
|
|
10946
|
-
|
|
10947
|
-
|
|
10948
|
-
|
|
10949
|
-
|
|
10950
|
-
|
|
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
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
|
|
10958
|
-
|
|
10959
|
-
|
|
10960
|
-
|
|
10961
|
-
|
|
10962
|
-
|
|
10963
|
-
|
|
10964
|
-
|
|
10965
|
-
|
|
10966
|
-
|
|
10967
|
-
|
|
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
|
-
|
|
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
|
|
10985
|
-
|
|
10986
|
-
|
|
10987
|
-
|
|
10988
|
-
|
|
10989
|
-
|
|
10990
|
-
|
|
10991
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
12877
|
+
const entry = await loadWebAssetEntry(filePath);
|
|
12878
|
+
const wantsGzip = entry.gzip && clientAcceptsGzip(req);
|
|
11354
12879
|
res.statusCode = 200;
|
|
11355
|
-
res.setHeader("Content-Type",
|
|
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
|
-
|
|
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
|
-
|
|
11637
|
-
|
|
11638
|
-
|
|
11639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|