viveworker 0.7.0-beta.1 → 0.7.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,9 @@ import process from "node:process";
12
12
  import { createInterface } from "node:readline";
13
13
  import { inspect } from "node:util";
14
14
  import { fileURLToPath } from "node:url";
15
+ import zlib from "node:zlib";
15
16
  import webPush from "web-push";
17
+ import * as hazbaseAuth from "@hazbase/auth";
16
18
  import { DEFAULT_LOCALE, SUPPORTED_LOCALES, localeDisplayName, normalizeLocale, resolveLocalePreference, t } from "../web/i18n.js";
17
19
  import { generatePairingCredentials, shouldRotatePairing, upsertEnvText } from "./lib/pairing.mjs";
18
20
  import { renderMarkdownHtml } from "./lib/markdown-render.mjs";
@@ -24,6 +26,21 @@ const __filename = fileURLToPath(import.meta.url);
24
26
  const __dirname = path.dirname(__filename);
25
27
  const workspaceRoot = path.resolve(__dirname, "..");
26
28
  const webRoot = path.join(workspaceRoot, "web");
29
+ const {
30
+ bootstrapPasskeyAccount,
31
+ completePasskeyAssertion,
32
+ completePasskeyRegistration,
33
+ listSupportedChains,
34
+ requestEmailOtp: requestHazbaseEmailOtp,
35
+ requestPasskeyAssertionChallenge,
36
+ requestPasskeyRegistrationChallenge,
37
+ setApiEndpoint: setHazbaseApiEndpoint,
38
+ setClientKey: setHazbaseClientKey,
39
+ verifyEmailOtp: verifyHazbaseEmailOtp,
40
+ } = hazbaseAuth;
41
+ const listSupportedPayments = typeof hazbaseAuth.listSupportedPayments === "function"
42
+ ? hazbaseAuth.listSupportedPayments.bind(hazbaseAuth)
43
+ : async () => ({ networks: [], defaultNetwork: "base-sepolia" });
27
44
  const appPackageVersion = readPackageVersion();
28
45
  const sessionCookieName = "viveworker_session";
29
46
  const deviceCookieName = "viveworker_device";
@@ -38,6 +55,155 @@ const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
38
55
  const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
39
56
  const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
40
57
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
58
+ const HAZBASE_METADATA_TIMEOUT_MS = 1500;
59
+ const NPM_VERSION_CHECK_TIMEOUT_MS = 2500;
60
+ const NPM_VERSION_CHECK_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
61
+
62
+ // Shared memo for buildDiffThreadGroups. Each call spawns 3 git subprocesses
63
+ // per tracked repo (`git diff --name-status`, `git status --porcelain`,
64
+ // `git diff`) plus a `git rev-parse` per unique dir — roughly 10+ subprocess
65
+ // spawns for a typical multi-repo state. Both /api/inbox/diff and
66
+ // /api/items/diff_thread/:token call this function; when the user taps a
67
+ // detail right after the inbox finished loading, the computation is
68
+ // duplicated unnecessarily. Short TTL + explicit invalidation when
69
+ // recentCodeEvents mutates keeps the result fresh without the recompute cost.
70
+ // Hoisted here so code-event migration at startup can call
71
+ // invalidateDiffThreadGroupsCache() before any of the use sites below
72
+ // execute, avoiding a temporal-dead-zone ReferenceError on `let`.
73
+ const DIFF_THREAD_GROUPS_CACHE_TTL_MS = 3000;
74
+ let diffThreadGroupsCache = null;
75
+ let diffThreadGroupsPending = null;
76
+
77
+ function invalidateDiffThreadGroupsCache() {
78
+ diffThreadGroupsCache = null;
79
+ // Don't touch diffThreadGroupsPending — an in-flight build reflects the
80
+ // state at the time it started, and callers racing the rebuild would
81
+ // otherwise pile up their own subprocess spawns.
82
+ }
83
+
84
+ async function getDiffThreadGroupsCached(runtime, state, config) {
85
+ const now = Date.now();
86
+ if (
87
+ diffThreadGroupsCache &&
88
+ now - diffThreadGroupsCache.computedAtMs < DIFF_THREAD_GROUPS_CACHE_TTL_MS
89
+ ) {
90
+ return diffThreadGroupsCache.groups;
91
+ }
92
+ if (diffThreadGroupsPending) {
93
+ return diffThreadGroupsPending;
94
+ }
95
+ diffThreadGroupsPending = (async () => {
96
+ try {
97
+ const groups = await buildDiffThreadGroups(runtime, state, config);
98
+ diffThreadGroupsCache = { groups, computedAtMs: Date.now() };
99
+ return groups;
100
+ } finally {
101
+ diffThreadGroupsPending = null;
102
+ }
103
+ })();
104
+ return diffThreadGroupsPending;
105
+ }
106
+
107
+ // Coalesced state persistence. The full state JSON is ~8 MB on disk (dominated
108
+ // by `recentCodeEvents` and `recentTimelineEntries` caches); JSON.stringify
109
+ // blocks the event loop ~70 ms and fs.writeFile takes another ~80 ms. Before
110
+ // this helper, every tiny mutation (push subscribe/unsubscribe/test, locale
111
+ // change, claude-away toggle, A2A toggles, …) awaited a ~150 ms
112
+ // serialize+write before the HTTP response fired — and the event-loop block
113
+ // delayed every concurrent request on the same server too. That's what made
114
+ // the settings/notification buttons feel sluggish: the click → visible
115
+ // response round-trip paid the cost of re-serializing 5 MB of code-event
116
+ // history every time.
117
+ //
118
+ // `scheduleSaveState` queues a write within SAVE_STATE_DEBOUNCE_MS and returns
119
+ // synchronously, so HTTP handlers can respond immediately after mutating
120
+ // in-memory state. Multiple mutations within the window coalesce into one
121
+ // write. `flushPendingStateWrite` drains on graceful shutdown.
122
+ //
123
+ // Durability trade-off: at most ~SAVE_STATE_MAX_DELAY_MS of in-memory
124
+ // mutations can be lost on a hard crash. Callers that require stronger
125
+ // guarantees (pairing, revocation, the periodic scan loop) still `await
126
+ // saveState(...)` directly — only the hot settings/notification paths opt in
127
+ // to the coalescer.
128
+ const SAVE_STATE_DEBOUNCE_MS = 500;
129
+ const SAVE_STATE_MAX_DELAY_MS = 1500;
130
+ let saveStateTimer = null;
131
+ let saveStateFirstScheduleAtMs = 0;
132
+ let saveStateInFlight = null;
133
+ let saveStateDirty = false;
134
+
135
+ function scheduleSaveState(config, state) {
136
+ if (!config?.stateFile) return;
137
+ if (saveStateInFlight) {
138
+ // An in-flight write already reflects the current (or earlier) state.
139
+ // Mark dirty so the .finally handler starts another write when it
140
+ // finishes — overlapping writes would just stomp on each other.
141
+ saveStateDirty = true;
142
+ return;
143
+ }
144
+ const now = Date.now();
145
+ if (!saveStateFirstScheduleAtMs) {
146
+ saveStateFirstScheduleAtMs = now;
147
+ }
148
+ if (saveStateTimer) {
149
+ clearTimeout(saveStateTimer);
150
+ }
151
+ const elapsed = now - saveStateFirstScheduleAtMs;
152
+ const remaining = SAVE_STATE_MAX_DELAY_MS - elapsed;
153
+ const delay = Math.max(0, Math.min(SAVE_STATE_DEBOUNCE_MS, remaining));
154
+ saveStateTimer = setTimeout(() => {
155
+ saveStateTimer = null;
156
+ saveStateFirstScheduleAtMs = 0;
157
+ startSaveStateFlush(config, state);
158
+ }, delay);
159
+ }
160
+
161
+ function startSaveStateFlush(config, state) {
162
+ saveStateDirty = false;
163
+ const promise = (async () => {
164
+ try {
165
+ await saveState(config.stateFile, state);
166
+ } catch (error) {
167
+ console.error(`[save-state-error] ${error.message}`);
168
+ }
169
+ })();
170
+ saveStateInFlight = promise;
171
+ promise.finally(() => {
172
+ if (saveStateInFlight === promise) {
173
+ saveStateInFlight = null;
174
+ }
175
+ if (saveStateDirty) {
176
+ // A mutation arrived mid-flight. Start the follow-up write
177
+ // synchronously — we've already held the original debounce window,
178
+ // so tacking on another 500 ms would defeat coalescing. Going
179
+ // through scheduleSaveState() instead would also introduce a timer
180
+ // that flushPendingStateWrite() would have to know about.
181
+ saveStateDirty = false;
182
+ startSaveStateFlush(config, state);
183
+ }
184
+ });
185
+ }
186
+
187
+ async function flushPendingStateWrite(config, state) {
188
+ // Promote any pending debounce timer to an immediate flush.
189
+ if (saveStateTimer) {
190
+ clearTimeout(saveStateTimer);
191
+ saveStateTimer = null;
192
+ saveStateFirstScheduleAtMs = 0;
193
+ startSaveStateFlush(config, state);
194
+ }
195
+ // Drain the in-flight write plus any cascades triggered by saveStateDirty.
196
+ // Bounded so a pathological re-entrancy bug can't hang shutdown.
197
+ for (let iter = 0; iter < 4 && (saveStateInFlight || saveStateDirty); iter += 1) {
198
+ if (saveStateInFlight) {
199
+ await saveStateInFlight.catch(() => {});
200
+ }
201
+ if (saveStateDirty && !saveStateInFlight) {
202
+ saveStateDirty = false;
203
+ startSaveStateFlush(config, state);
204
+ }
205
+ }
206
+ }
41
207
 
42
208
  const cli = parseCliArgs(process.argv.slice(2));
43
209
  const envFile = resolveEnvFile(cli.envFile);
@@ -47,6 +213,7 @@ await maybeRotateStartupPairingEnv(envFile);
47
213
 
48
214
  const config = buildConfig(cli);
49
215
  validateConfig(config);
216
+ configureHazbaseClient(config);
50
217
 
51
218
  const runtime = {
52
219
  fileStates: new Map(),
@@ -100,6 +267,11 @@ const runtime = {
100
267
  // deliberately NOT persisted via `state` since the cache is worthless after
101
268
  // a restart and would just bloat the state file.
102
269
  a2aShareStatusCache: null,
270
+ // Latest npm version cache. Checked only from the technical settings page
271
+ // and cached here so a weak network or repeated settings renders never
272
+ // hammer registry.npmjs.org.
273
+ npmVersionStatusCache: null,
274
+ npmVersionStatusPending: null,
103
275
  };
104
276
  const state = await loadState(config.stateFile);
105
277
  runtime.threadRegistry = await loadThreadRegistry(config.threadRegistryFile);
@@ -165,6 +337,7 @@ const backfilledAmbientSuggestionsStateChanged = backfillAmbientSuggestionsState
165
337
  const migratedRecentCodeEventsStateChanged = migrateRecentCodeEventsState({ config, runtime, state });
166
338
  const restoredTimelineImagePathsStateChanged = await backfillPersistedTimelineImagePaths({ config, runtime, state });
167
339
  const backfilledMoltbookInboxChanged = await backfillMoltbookInboxHistory({ config, runtime, state });
340
+ const recoveredMissingProviderStateChanged = await recoverMissingProviderStateFromBackup({ config, runtime, state });
168
341
  runtime.historyFileState.offset = Number(state.historyFileOffset) || 0;
169
342
  runtime.historyFileState.sourceFile = cleanText(state.historyFileSourceFile ?? "");
170
343
 
@@ -263,8 +436,23 @@ function clearDeviceLocaleOverride(state, deviceId) {
263
436
  return true;
264
437
  }
265
438
 
439
+ function autoPilotWriteLaneState(state) {
440
+ return {
441
+ content: state?.autoPilotWriteLaneContent === true,
442
+ uiTests: state?.autoPilotWriteLaneUiTests === true,
443
+ source: state?.autoPilotWriteLaneSource === true,
444
+ };
445
+ }
446
+
447
+ function hasAnyAutoPilotWriteLaneEnabled(state) {
448
+ const lanes = autoPilotWriteLaneState(state);
449
+ return lanes.content || lanes.uiTests || lanes.source;
450
+ }
451
+
266
452
  function buildSessionLocalePayload(config, state, deviceId) {
267
453
  const resolved = resolveDeviceLocaleInfo(config, state, deviceId);
454
+ const writeLanes = autoPilotWriteLaneState(state);
455
+ const trustedWritesEnabled = hasAnyAutoPilotWriteLaneEnabled(state);
268
456
  return {
269
457
  locale: resolved.locale,
270
458
  localeSource: resolved.source,
@@ -273,6 +461,12 @@ function buildSessionLocalePayload(config, state, deviceId) {
273
461
  deviceDetectedLocale: resolved.detectedLocale || null,
274
462
  deviceOverrideLocale: resolved.overrideLocale || null,
275
463
  claudeAwayMode: state?.claudeAwayMode === true,
464
+ autoPilotTrustedReads: state?.autoPilotTrustedReads === true,
465
+ autoPilotTrustedWrites: trustedWritesEnabled,
466
+ autoPilotTrustedWritesShadow: false,
467
+ autoPilotWriteLaneContent: writeLanes.content,
468
+ autoPilotWriteLaneUiTests: writeLanes.uiTests,
469
+ autoPilotWriteLaneSource: writeLanes.source,
276
470
  moltbookEnabled: Boolean(config.moltbookApiKey),
277
471
  a2aEnabled: Boolean(config.a2aApiKey),
278
472
  a2aRelayEnabled: Boolean(config.a2aRelayUrl && config.a2aRelayUserId),
@@ -770,6 +964,425 @@ function tokenizeShellWords(commandText) {
770
964
  return tokens;
771
965
  }
772
966
 
967
+ function isPathWithinRoot(rootPath, candidatePath) {
968
+ const normalizedRoot = cleanText(rootPath || "");
969
+ const normalizedCandidate = cleanText(candidatePath || "");
970
+ if (!normalizedRoot || !normalizedCandidate) {
971
+ return false;
972
+ }
973
+ const relativePath = path.relative(normalizedRoot, normalizedCandidate);
974
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
975
+ }
976
+
977
+ function isSensitiveCommandPath(candidatePath) {
978
+ const normalized = cleanText(candidatePath || "");
979
+ if (!normalized) {
980
+ return true;
981
+ }
982
+ const lower = normalized.toLowerCase();
983
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
984
+ const basename = segments[segments.length - 1] || "";
985
+ if (segments.some((segment) => [".ssh", ".aws", ".gnupg", ".azure", ".kube"].includes(segment))) {
986
+ return true;
987
+ }
988
+ if (basename === ".npmrc" || basename === ".netrc" || basename === ".env" || basename.startsWith(".env.")) {
989
+ return true;
990
+ }
991
+ if (basename.endsWith(".pem") || basename.endsWith(".key") || basename.endsWith(".p12") || basename.endsWith(".pfx")) {
992
+ return true;
993
+ }
994
+ if (/^id_[a-z0-9._-]+$/iu.test(basename) || basename.includes("secret") || basename.includes("credential")) {
995
+ return true;
996
+ }
997
+ return false;
998
+ }
999
+
1000
+ function resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }) {
1001
+ const rawToken = cleanText(token || "");
1002
+ const normalizedCwd = cleanText(cwd || "");
1003
+ const normalizedWorkspaceRoot = cleanText(workspaceRoot || normalizedCwd);
1004
+ if (!rawToken || !normalizedCwd || !normalizedWorkspaceRoot) {
1005
+ return null;
1006
+ }
1007
+ if (rawToken === "-" || rawToken.startsWith("~") || rawToken.startsWith("http://") || rawToken.startsWith("https://")) {
1008
+ return null;
1009
+ }
1010
+ const resolved = path.resolve(normalizedCwd, rawToken);
1011
+ if (!isPathWithinRoot(normalizedWorkspaceRoot, resolved)) {
1012
+ return null;
1013
+ }
1014
+ if (isSensitiveCommandPath(resolved)) {
1015
+ return null;
1016
+ }
1017
+ return resolved;
1018
+ }
1019
+
1020
+ function isGitObjectPathToken(token) {
1021
+ const normalized = cleanText(token || "");
1022
+ if (!normalized || normalized.startsWith("-")) {
1023
+ return false;
1024
+ }
1025
+ return /^[^:\s][^:\s]*:.+/u.test(normalized);
1026
+ }
1027
+
1028
+ function isLiteralGitPathspecToken(token) {
1029
+ const normalized = cleanText(token || "");
1030
+ if (!normalized || normalized === "--" || normalized.startsWith(":(") || normalized.startsWith(":/")) {
1031
+ return false;
1032
+ }
1033
+ if (/[*?[\]{}]/u.test(normalized)) {
1034
+ return false;
1035
+ }
1036
+ return !isGitObjectPathToken(normalized);
1037
+ }
1038
+
1039
+ function isAllowedGitMetadataOnlyFlag(token) {
1040
+ const normalized = cleanText(token || "");
1041
+ if (!normalized) {
1042
+ return false;
1043
+ }
1044
+ return [
1045
+ "--stat",
1046
+ "--shortstat",
1047
+ "--numstat",
1048
+ "--name-only",
1049
+ "--name-status",
1050
+ "--summary",
1051
+ "--compact-summary",
1052
+ "--oneline",
1053
+ "--no-patch",
1054
+ "-s",
1055
+ ].includes(normalized);
1056
+ }
1057
+
1058
+ function hasDisallowedGitPatchOutput(tokens) {
1059
+ return tokens.some((token) => {
1060
+ const normalized = cleanText(token || "");
1061
+ if (!normalized) {
1062
+ return false;
1063
+ }
1064
+ return (
1065
+ normalized === "-p" ||
1066
+ normalized === "--patch" ||
1067
+ normalized === "--patch-with-stat" ||
1068
+ normalized === "--raw" ||
1069
+ normalized === "--cc" ||
1070
+ normalized === "--combined-all-paths" ||
1071
+ normalized === "--binary" ||
1072
+ normalized === "--word-diff" ||
1073
+ normalized.startsWith("--word-diff=") ||
1074
+ normalized === "-U" ||
1075
+ normalized.startsWith("-U") ||
1076
+ normalized === "--unified" ||
1077
+ normalized.startsWith("--unified=")
1078
+ );
1079
+ });
1080
+ }
1081
+
1082
+ function extractGitPathspecTokens(tokens) {
1083
+ const separatorIndex = tokens.indexOf("--");
1084
+ if (separatorIndex < 0) {
1085
+ return [];
1086
+ }
1087
+ return tokens.slice(separatorIndex + 1).filter(Boolean);
1088
+ }
1089
+
1090
+ function resolveTrustedDiffSectionPath({ token, cwd, workspaceRoot }) {
1091
+ const normalizedToken = cleanText(token || "");
1092
+ if (!normalizedToken) {
1093
+ return null;
1094
+ }
1095
+ const preferredWorkspace = resolveTrustedReadTargetPath({
1096
+ token: normalizedToken,
1097
+ cwd: workspaceRoot,
1098
+ workspaceRoot,
1099
+ });
1100
+ if (preferredWorkspace) {
1101
+ return preferredWorkspace;
1102
+ }
1103
+ return resolveTrustedReadTargetPath({
1104
+ token: normalizedToken,
1105
+ cwd,
1106
+ workspaceRoot,
1107
+ });
1108
+ }
1109
+
1110
+ function extractTrustedReadFileRefs(command, tokens, cwd, workspaceRoot) {
1111
+ const normalizedCommand = cleanText(command || "");
1112
+ if (!normalizedCommand) {
1113
+ return null;
1114
+ }
1115
+
1116
+ if (normalizedCommand === "pwd" || (normalizedCommand === "git" && cleanText(tokens[1] || "") === "status")) {
1117
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1118
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1119
+ }
1120
+
1121
+ if (normalizedCommand === "git") {
1122
+ const gitSubcommand = cleanText(tokens[1] || "");
1123
+ if (!["diff", "show"].includes(gitSubcommand)) {
1124
+ return null;
1125
+ }
1126
+ if (tokens.some((token) => token === "-C" || token.startsWith("-C") || token === "--output" || token.startsWith("--output="))) {
1127
+ return null;
1128
+ }
1129
+ const commandArgs = tokens.slice(2);
1130
+ if (commandArgs.some((token) => isGitObjectPathToken(token))) {
1131
+ return null;
1132
+ }
1133
+ const explicitPathspecs = extractGitPathspecTokens(tokens);
1134
+ if (explicitPathspecs.length > 0) {
1135
+ if (explicitPathspecs.some((token) => !isLiteralGitPathspecToken(token))) {
1136
+ return null;
1137
+ }
1138
+ const resolvedPaths = explicitPathspecs
1139
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1140
+ .filter(Boolean);
1141
+ return resolvedPaths.length === explicitPathspecs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1142
+ }
1143
+ if (hasDisallowedGitPatchOutput(commandArgs)) {
1144
+ return null;
1145
+ }
1146
+ const metadataOnly = commandArgs.some((token) => isAllowedGitMetadataOnlyFlag(token));
1147
+ if (!metadataOnly) {
1148
+ return null;
1149
+ }
1150
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1151
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1152
+ }
1153
+
1154
+ if (normalizedCommand === "ls") {
1155
+ const pathArgs = tokens.slice(1).filter((token) => !String(token || "").startsWith("-"));
1156
+ if (pathArgs.length === 0) {
1157
+ const resolved = resolveTrustedReadTargetPath({ token: ".", cwd, workspaceRoot });
1158
+ return resolved ? normalizeTimelineFileRefs([resolved]) : null;
1159
+ }
1160
+ const resolvedPaths = pathArgs
1161
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1162
+ .filter(Boolean);
1163
+ return resolvedPaths.length === pathArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1164
+ }
1165
+
1166
+ if (normalizedCommand === "find") {
1167
+ if (tokens.some((token) => ["-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprintf", "-fls"].includes(token))) {
1168
+ return null;
1169
+ }
1170
+ const pathArgs = [];
1171
+ for (const token of tokens.slice(1)) {
1172
+ if (!token || token === "--") continue;
1173
+ if (token === "!" || token === "(" || token === ")") break;
1174
+ if (token.startsWith("-")) break;
1175
+ pathArgs.push(token);
1176
+ }
1177
+ const effectivePaths = pathArgs.length ? pathArgs : ["."];
1178
+ const resolvedPaths = effectivePaths
1179
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1180
+ .filter(Boolean);
1181
+ return resolvedPaths.length === effectivePaths.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1182
+ }
1183
+
1184
+ if (normalizedCommand === "sed") {
1185
+ if (!tokens.includes("-n") || tokens.includes("-i") || tokens.includes("--in-place")) {
1186
+ return null;
1187
+ }
1188
+ let script = "";
1189
+ const fileArgs = [];
1190
+ for (let index = 1; index < tokens.length; index += 1) {
1191
+ const token = tokens[index];
1192
+ if (!token) continue;
1193
+ if (!script) {
1194
+ if (token === "-e") {
1195
+ script = tokens[index + 1] || "";
1196
+ index += 1;
1197
+ continue;
1198
+ }
1199
+ if (token.startsWith("-")) {
1200
+ continue;
1201
+ }
1202
+ script = token;
1203
+ continue;
1204
+ }
1205
+ if (!token.startsWith("-")) {
1206
+ fileArgs.push(token);
1207
+ }
1208
+ }
1209
+ if (!script || !/^[0-9,$; p-]+$/u.test(script) || fileArgs.length === 0) {
1210
+ return null;
1211
+ }
1212
+ const resolvedPaths = fileArgs
1213
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1214
+ .filter(Boolean);
1215
+ return resolvedPaths.length === fileArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1216
+ }
1217
+
1218
+ if (normalizedCommand === "rg") {
1219
+ let seenPattern = false;
1220
+ const fileArgs = [];
1221
+ for (const token of tokens.slice(1)) {
1222
+ if (!seenPattern) {
1223
+ if (token === "--") {
1224
+ seenPattern = true;
1225
+ continue;
1226
+ }
1227
+ if (String(token || "").startsWith("-")) {
1228
+ continue;
1229
+ }
1230
+ seenPattern = true;
1231
+ continue;
1232
+ }
1233
+ if (!String(token || "").startsWith("-")) {
1234
+ fileArgs.push(token);
1235
+ }
1236
+ }
1237
+ const effectivePaths = fileArgs.length ? fileArgs : ["."];
1238
+ const resolvedPaths = effectivePaths
1239
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1240
+ .filter(Boolean);
1241
+ return resolvedPaths.length === effectivePaths.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1242
+ }
1243
+
1244
+ if (["cat", "nl", "head", "tail", "wc"].includes(normalizedCommand)) {
1245
+ const fileArgs = tokens.slice(1).filter((token) => !String(token || "").startsWith("-"));
1246
+ if (fileArgs.length === 0) {
1247
+ return null;
1248
+ }
1249
+ const resolvedPaths = fileArgs
1250
+ .map((token) => resolveTrustedReadTargetPath({ token, cwd, workspaceRoot }))
1251
+ .filter(Boolean);
1252
+ return resolvedPaths.length === fileArgs.length ? normalizeTimelineFileRefs(resolvedPaths) : null;
1253
+ }
1254
+
1255
+ return null;
1256
+ }
1257
+
1258
+ function stripAllowedTrustedReadRedirections(tokens) {
1259
+ const filtered = [];
1260
+ for (let index = 0; index < tokens.length; index += 1) {
1261
+ const token = String(tokens[index] || "");
1262
+ if (!token) {
1263
+ continue;
1264
+ }
1265
+ if (token === "2>" || token === "2>>") {
1266
+ const target = String(tokens[index + 1] || "");
1267
+ if (target !== "/dev/null") {
1268
+ return null;
1269
+ }
1270
+ index += 1;
1271
+ continue;
1272
+ }
1273
+ if (/^2>>?\/dev\/null$/u.test(token)) {
1274
+ continue;
1275
+ }
1276
+ if (token.includes(">") || token.includes("<")) {
1277
+ return null;
1278
+ }
1279
+ filtered.push(token);
1280
+ }
1281
+ return filtered;
1282
+ }
1283
+
1284
+ function isAllowedTrustedReadPostProcessStage(stageTokens) {
1285
+ if (!Array.isArray(stageTokens) || stageTokens.length === 0) {
1286
+ return false;
1287
+ }
1288
+ const [command, ...args] = stageTokens.map((token) => cleanText(token || ""));
1289
+ if (!command) {
1290
+ return false;
1291
+ }
1292
+ if (command === "head" || command === "tail") {
1293
+ if (args.length === 0) {
1294
+ return true;
1295
+ }
1296
+ if (args.length === 1 && /^-\d+$/u.test(args[0])) {
1297
+ return true;
1298
+ }
1299
+ if (args.length === 2 && args[0] === "-n" && /^\d+$/u.test(args[1])) {
1300
+ return true;
1301
+ }
1302
+ return false;
1303
+ }
1304
+ if (command === "wc") {
1305
+ return args.length === 0 || (args.length === 1 && args[0] === "-l");
1306
+ }
1307
+ return false;
1308
+ }
1309
+
1310
+ function normalizeTrustedReadTokens(tokens) {
1311
+ const strippedTokens = stripAllowedTrustedReadRedirections(tokens);
1312
+ if (!strippedTokens || strippedTokens.length === 0) {
1313
+ return null;
1314
+ }
1315
+ if (strippedTokens.some((token) => {
1316
+ const value = String(token || "");
1317
+ return (
1318
+ ["||", "&&", ";", "&"].includes(value) ||
1319
+ value.includes("`") ||
1320
+ value.includes("$(")
1321
+ );
1322
+ })) {
1323
+ return null;
1324
+ }
1325
+ const stages = [];
1326
+ let currentStage = [];
1327
+ for (const token of strippedTokens) {
1328
+ if (token === "|") {
1329
+ if (currentStage.length === 0) {
1330
+ return null;
1331
+ }
1332
+ stages.push(currentStage);
1333
+ currentStage = [];
1334
+ continue;
1335
+ }
1336
+ currentStage.push(token);
1337
+ }
1338
+ if (currentStage.length === 0) {
1339
+ return null;
1340
+ }
1341
+ stages.push(currentStage);
1342
+ if (stages.length === 0) {
1343
+ return null;
1344
+ }
1345
+ if (stages.slice(1).some((stageTokens) => !isAllowedTrustedReadPostProcessStage(stageTokens))) {
1346
+ return null;
1347
+ }
1348
+ return stages[0];
1349
+ }
1350
+
1351
+ function evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot }) {
1352
+ const normalizedCwd = cleanText(cwd || "");
1353
+ const normalizedWorkspaceRoot = cleanText(workspaceRoot || normalizedCwd);
1354
+ const normalizedCommandText = unwrapShellCommand(commandText);
1355
+ const rawTokens = tokenizeShellWords(normalizedCommandText);
1356
+ if (!normalizedCommandText || !normalizedCwd || !normalizedWorkspaceRoot || rawTokens.length === 0) {
1357
+ return null;
1358
+ }
1359
+
1360
+ if (!isPathWithinRoot(normalizedWorkspaceRoot, path.resolve(normalizedCwd))) {
1361
+ return null;
1362
+ }
1363
+
1364
+ const tokens = normalizeTrustedReadTokens(rawTokens);
1365
+ if (!tokens || tokens.length === 0) {
1366
+ return null;
1367
+ }
1368
+
1369
+ const command = cleanText(tokens[0]);
1370
+ if (!command || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(command)) {
1371
+ return null;
1372
+ }
1373
+
1374
+ const fileRefs = extractTrustedReadFileRefs(command, tokens, normalizedCwd, normalizedWorkspaceRoot);
1375
+ if (!fileRefs) {
1376
+ return null;
1377
+ }
1378
+
1379
+ return {
1380
+ command,
1381
+ commandText: normalizedCommandText,
1382
+ fileRefs,
1383
+ };
1384
+ }
1385
+
773
1386
  function unwrapShellCommand(commandText) {
774
1387
  const normalized = String(commandText || "").trim();
775
1388
  if (!normalized) {
@@ -848,6 +1461,14 @@ function extractReadFileRefsFromCommand(commandText) {
848
1461
  return [];
849
1462
  }
850
1463
 
1464
+ function autoPilotApprovalMessage(locale, commandText) {
1465
+ const prefix = t(locale, "server.message.autoPilotTrustedReadApproved");
1466
+ const commandBlock = cleanText(commandText || "")
1467
+ ? `${t(locale, "server.message.commandLabel")}\n\`\`\`sh\n${cleanText(commandText || "")}\n\`\`\``
1468
+ : "";
1469
+ return [prefix, commandBlock].filter(Boolean).join("\n\n");
1470
+ }
1471
+
851
1472
  function extractUpdatedFileRefsByType(outputText, patchText = "") {
852
1473
  const parsedSections = parseApplyPatchSections(patchText);
853
1474
  if (parsedSections.length > 0) {
@@ -2195,6 +2816,7 @@ function providerDisplayName(locale, provider) {
2195
2816
  if (p === "claude") return t(locale, "common.claude");
2196
2817
  if (p === "moltbook") return "Moltbook";
2197
2818
  if (p === "a2a") return "A2A";
2819
+ if (p === "viveworker") return t(locale, "common.appName");
2198
2820
  return t(locale, "common.codex");
2199
2821
  }
2200
2822
 
@@ -2209,15 +2831,23 @@ function normalizeHistoryItems(rawItems, maxItems) {
2209
2831
  .sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0));
2210
2832
  const deduped = [];
2211
2833
  const seen = new Set();
2834
+ const perProviderCount = {};
2835
+ const knownProviders = new Set(["codex", "claude", "moltbook", "a2a", "viveworker"]);
2212
2836
  for (const item of normalized) {
2213
2837
  if (seen.has(item.stableId)) {
2214
2838
  continue;
2215
2839
  }
2840
+ const provider = normalizeProvider(item.provider);
2841
+ if (!knownProviders.has(provider)) {
2842
+ knownProviders.add(provider);
2843
+ }
2844
+ const providerCount = perProviderCount[provider] ?? 0;
2845
+ if (providerCount >= maxItems) {
2846
+ continue;
2847
+ }
2216
2848
  seen.add(item.stableId);
2217
2849
  deduped.push(item);
2218
- if (deduped.length >= maxItems) {
2219
- break;
2220
- }
2850
+ perProviderCount[provider] = providerCount + 1;
2221
2851
  }
2222
2852
  return deduped;
2223
2853
  }
@@ -2226,6 +2856,9 @@ function normalizeHistoryItem(raw) {
2226
2856
  if (!isPlainObject(raw)) {
2227
2857
  return null;
2228
2858
  }
2859
+ if (shouldHideInternalTimelineItem(raw)) {
2860
+ return null;
2861
+ }
2229
2862
 
2230
2863
  const stableId = cleanText(raw.stableId ?? raw.id ?? "");
2231
2864
  const kind = cleanText(raw.kind ?? "");
@@ -2237,8 +2870,10 @@ function normalizeHistoryItem(raw) {
2237
2870
  const threadLabel = skipHistoryThreadLabelRewrite
2238
2871
  ? rawThreadLabel
2239
2872
  : preferTitleOnlyJsonThreadLabel(rawThreadLabel, threadId, raw.messageText, raw.summary, raw.detailText, raw.message);
2873
+ const rawTitle = cleanText(raw.title ?? "");
2240
2874
  const title =
2241
- cleanText(raw.title ?? "") || (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2875
+ (!isFallbackTimelineTitle(rawTitle, kind, threadId) ? rawTitle : "") ||
2876
+ (threadLabel ? formatTitle(kindTitle(DEFAULT_LOCALE, kind), threadLabel) : kindTitle(DEFAULT_LOCALE, kind));
2242
2877
  const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
2243
2878
  const summary = normalizeNotificationText(raw.summary ?? "") || formatNotificationBody(messageText, 100) || "";
2244
2879
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
@@ -2248,7 +2883,7 @@ function normalizeHistoryItem(raw) {
2248
2883
 
2249
2884
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
2250
2885
 
2251
- return {
2886
+ const normalized = {
2252
2887
  stableId,
2253
2888
  token: cleanText(raw.token ?? "") || historyToken(stableId),
2254
2889
  kind,
@@ -2276,6 +2911,7 @@ function normalizeHistoryItem(raw) {
2276
2911
  ...(raw.instruction != null ? { instruction: cleanText(raw.instruction) } : {}),
2277
2912
  ...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
2278
2913
  };
2914
+ return shouldHideInternalTimelineItem(normalized) ? null : normalized;
2279
2915
  }
2280
2916
 
2281
2917
  function historyToken(stableId) {
@@ -2305,10 +2941,41 @@ function normalizeTimelineEntries(rawItems, maxItems) {
2305
2941
  const perProviderCount = {};
2306
2942
  const knownProviders = new Set(["codex", "claude", "moltbook", "a2a", "viveworker"]);
2307
2943
  const saturatedProviders = new Set();
2944
+ // Content-based dedup for user_message entries: the same logical user turn
2945
+ // can surface through two independent readers (rollout session files and
2946
+ // ~/.codex/history.jsonl) that compute different stableIds
2947
+ // (`user_message:<threadId>:<turn_id|ISO-ts>` vs
2948
+ // `user_message:<threadId>:<integer-id|unix-seconds>`), so the stableId-only
2949
+ // filter above doesn't catch them. Collapse entries with the same
2950
+ // (threadId, messageText) pair when their createdAtMs are within 60 s —
2951
+ // wide enough to absorb rollout-vs-history-file flush skew (we've seen
2952
+ // several seconds in the wild) but narrow enough not to collapse a genuine
2953
+ // repeat (e.g. user retries the same "run tests" command minutes apart).
2954
+ // Literal here rather than a named const because this function is first
2955
+ // called at top-level module init (line ~302) which runs before any const
2956
+ // declared below would leave the TDZ.
2957
+ const userMessageDedupWindowMs = 60_000;
2958
+ const userMessageIndex = new Map();
2308
2959
  for (const item of normalized) {
2309
2960
  if (seen.has(item.stableId)) {
2310
2961
  continue;
2311
2962
  }
2963
+ if (item.kind === "user_message") {
2964
+ const threadKey = cleanText(item.threadId || "");
2965
+ const textKey = cleanText(item.messageText || "");
2966
+ if (threadKey && textKey) {
2967
+ const key = `${threadKey}\u0001${textKey}`;
2968
+ const existingMs = userMessageIndex.get(key);
2969
+ const itemMs = Number(item.createdAtMs || 0);
2970
+ if (
2971
+ existingMs !== undefined &&
2972
+ Math.abs(existingMs - itemMs) <= userMessageDedupWindowMs
2973
+ ) {
2974
+ continue;
2975
+ }
2976
+ userMessageIndex.set(key, itemMs);
2977
+ }
2978
+ }
2312
2979
  const prov = item.provider || "codex";
2313
2980
  if (saturatedProviders.has(prov)) {
2314
2981
  continue;
@@ -2352,6 +3019,7 @@ function migrateRecentCodeEventsState({ config, runtime, state }) {
2352
3019
  const previousItems = Array.isArray(state.recentCodeEvents) ? normalizeCodeEvents(state.recentCodeEvents, config.maxCodeEvents) : [];
2353
3020
  runtime.recentCodeEvents = nextItems;
2354
3021
  state.recentCodeEvents = nextItems;
3022
+ invalidateDiffThreadGroupsCache();
2355
3023
  return JSON.stringify(nextItems) !== JSON.stringify(previousItems);
2356
3024
  }
2357
3025
 
@@ -2383,6 +3051,9 @@ function normalizeTimelineEntry(raw) {
2383
3051
  if (!isPlainObject(raw)) {
2384
3052
  return null;
2385
3053
  }
3054
+ if (shouldHideInternalTimelineItem(raw)) {
3055
+ return null;
3056
+ }
2386
3057
 
2387
3058
  const stableId = cleanText(raw.stableId ?? raw.id ?? "");
2388
3059
  const kind = cleanText(raw.kind ?? "");
@@ -2420,14 +3091,15 @@ function normalizeTimelineEntry(raw) {
2420
3091
  raw.detailText,
2421
3092
  raw.message
2422
3093
  );
3094
+ const rawTitle = cleanText(raw.title ?? "");
2423
3095
  const title =
2424
- cleanText(raw.title ?? "") ||
3096
+ (!isFallbackTimelineTitle(rawTitle, kind, threadId) ? rawTitle : "") ||
2425
3097
  (kind === "file_event" ? fileEventTitle(DEFAULT_LOCALE, fileEventType) : "") ||
2426
3098
  threadLabel ||
2427
3099
  kindTitle(DEFAULT_LOCALE, kind);
2428
3100
  const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
2429
3101
 
2430
- return {
3102
+ const normalized = {
2431
3103
  stableId,
2432
3104
  token: cleanText(raw.token ?? "") || historyToken(stableId),
2433
3105
  kind,
@@ -2473,6 +3145,7 @@ function normalizeTimelineEntry(raw) {
2473
3145
  ...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
2474
3146
  ...(suggestions.length > 0 ? { suggestions } : {}),
2475
3147
  };
3148
+ return shouldHideInternalTimelineItem(normalized) ? null : normalized;
2476
3149
  }
2477
3150
 
2478
3151
  function recordTimelineEntry({ config, runtime, state, entry }) {
@@ -2563,6 +3236,9 @@ function recordCodeEvent({ config, runtime, state, entry }) {
2563
3236
  );
2564
3237
  runtime.recentCodeEvents = nextItems;
2565
3238
  state.recentCodeEvents = nextItems;
3239
+ if (changed) {
3240
+ invalidateDiffThreadGroupsCache();
3241
+ }
2566
3242
  return changed;
2567
3243
  }
2568
3244
 
@@ -2614,6 +3290,9 @@ function syncRecentCodeEventsFromTimeline({ config, runtime, state }) {
2614
3290
  );
2615
3291
  runtime.recentCodeEvents = nextItems;
2616
3292
  state.recentCodeEvents = nextItems;
3293
+ if (changed) {
3294
+ invalidateDiffThreadGroupsCache();
3295
+ }
2617
3296
  return changed;
2618
3297
  }
2619
3298
 
@@ -2840,13 +3519,21 @@ function pendingChoiceStableId(userInputRequest) {
2840
3519
  return `choice:${userInputRequest.requestKey}`;
2841
3520
  }
2842
3521
 
2843
- function buildAppItemUrl(config, kind, token) {
3522
+ function buildAppItemUrl(config, kind, token, options = {}) {
2844
3523
  const url = new URL(`${config.nativeApprovalPublicBaseUrl}/app`);
2845
3524
  url.searchParams.set("item", `${kind}:${token}`);
3525
+ const tab = cleanText(options.tab || "");
3526
+ const subtab = cleanText(options.subtab || "");
3527
+ if (tab) {
3528
+ url.searchParams.set("tab", tab);
3529
+ }
3530
+ if (subtab) {
3531
+ url.searchParams.set("subtab", subtab);
3532
+ }
2846
3533
  return url.toString();
2847
3534
  }
2848
3535
 
2849
- function buildPushPayload({ config, kind, token, stableId, title, body }) {
3536
+ function buildPushPayload({ config, kind, token, stableId, title, body, tab = "", subtab = "" }) {
2850
3537
  return {
2851
3538
  title: withNotificationIcon(kind, title),
2852
3539
  body: formatNotificationBody(body, config.completionDetailThresholdChars) || body || title,
@@ -2855,7 +3542,7 @@ function buildPushPayload({ config, kind, token, stableId, title, body }) {
2855
3542
  kind,
2856
3543
  token,
2857
3544
  stableId,
2858
- url: buildAppItemUrl(config, kind, token),
3545
+ url: buildAppItemUrl(config, kind, token, { tab, subtab }),
2859
3546
  },
2860
3547
  };
2861
3548
  }
@@ -2975,7 +3662,7 @@ function pushDeliveryKey(deviceId, stableId) {
2975
3662
  return `${cleanText(deviceId || "")}:${cleanText(stableId || "")}`;
2976
3663
  }
2977
3664
 
2978
- async function deliverWebPushItem({ config, state, kind, token, stableId, title, body, buildLocalizedContent = null }) {
3665
+ async function deliverWebPushItem({ config, state, kind, token, stableId, title, body, tab = "", subtab = "", buildLocalizedContent = null }) {
2979
3666
  if (!config.webPushEnabled || config.dryRun) {
2980
3667
  return false;
2981
3668
  }
@@ -3010,6 +3697,8 @@ async function deliverWebPushItem({ config, state, kind, token, stableId, title,
3010
3697
  stableId,
3011
3698
  title: localizedContent?.title || title,
3012
3699
  body: localizedContent?.body || body,
3700
+ tab,
3701
+ subtab,
3013
3702
  })
3014
3703
  );
3015
3704
  await webPush.sendNotification(
@@ -3093,18 +3782,24 @@ async function scanOnce({ config, runtime, state }) {
3093
3782
  }
3094
3783
 
3095
3784
  if (config.webUiEnabled) {
3785
+ let claudeTranscriptChanged = false;
3096
3786
  if (now - runtime.lastClaudeScanAt >= config.directoryScanIntervalMs) {
3097
3787
  runtime.claudeKnownFiles = await listClaudeTranscriptFiles(config.claudeProjectsDir);
3098
3788
  runtime.lastClaudeScanAt = now;
3099
3789
  }
3790
+ let claudeSessionTitlesChanged = false;
3100
3791
  if (now - runtime.lastClaudeSessionTitleScanAt >= config.directoryScanIntervalMs) {
3101
- await refreshClaudeSessionTitles(runtime);
3792
+ claudeSessionTitlesChanged = await refreshClaudeSessionTitles(runtime);
3102
3793
  runtime.lastClaudeSessionTitleScanAt = now;
3103
3794
  }
3104
3795
  for (const filePath of runtime.claudeKnownFiles) {
3105
3796
  const changed = await processClaudeTranscriptFile({ filePath, config, runtime, state, now });
3797
+ claudeTranscriptChanged = claudeTranscriptChanged || changed;
3106
3798
  dirty = dirty || changed;
3107
3799
  }
3800
+ if (claudeTranscriptChanged || claudeSessionTitlesChanged) {
3801
+ dirty = refreshResolvedThreadLabels({ config, runtime, state }) || dirty;
3802
+ }
3108
3803
  }
3109
3804
 
3110
3805
  if (config.webUiEnabled) {
@@ -3330,6 +4025,7 @@ function fileEventCallIdFromStableId(stableId) {
3330
4025
  }
3331
4026
 
3332
4027
  async function refreshClaudeSessionTitles(runtime) {
4028
+ let changed = false;
3333
4029
  // Read ~/Library/Application Support/Claude/claude-code-sessions/*/*/local_*.json
3334
4030
  // Each file maps cliSessionId → title (auto-generated by Claude Desktop).
3335
4031
  const baseDir = path.join(
@@ -3367,7 +4063,10 @@ async function refreshClaudeSessionTitles(runtime) {
3367
4063
  const cliSessionId = cleanText(data?.cliSessionId || "");
3368
4064
  const title = cleanText(data?.title || "");
3369
4065
  if (cliSessionId && title) {
3370
- runtime.claudeSessionTitles.set(cliSessionId, title);
4066
+ if (runtime.claudeSessionTitles.get(cliSessionId) !== title) {
4067
+ runtime.claudeSessionTitles.set(cliSessionId, title);
4068
+ changed = true;
4069
+ }
3371
4070
  }
3372
4071
  } catch {
3373
4072
  // skip unreadable/invalid
@@ -3378,6 +4077,49 @@ async function refreshClaudeSessionTitles(runtime) {
3378
4077
  } catch {
3379
4078
  // base dir missing — Claude Desktop not installed
3380
4079
  }
4080
+
4081
+ for (const filePath of runtime.claudeKnownFiles ?? []) {
4082
+ let lines;
4083
+ try {
4084
+ const raw = await fs.readFile(filePath, "utf8");
4085
+ lines = raw.split("\n");
4086
+ } catch {
4087
+ continue;
4088
+ }
4089
+ for (const rawLine of lines) {
4090
+ const trimmed = rawLine.trim();
4091
+ if (!trimmed) continue;
4092
+ let record;
4093
+ try {
4094
+ record = JSON.parse(trimmed);
4095
+ } catch {
4096
+ continue;
4097
+ }
4098
+ if (record?.entrypoint !== "sdk-cli" || record?.type !== "user") {
4099
+ continue;
4100
+ }
4101
+ const threadId = cleanText(record?.sessionId || "");
4102
+ const message = record?.message || {};
4103
+ let text = "";
4104
+ if (typeof message.content === "string") {
4105
+ text = message.content;
4106
+ } else if (Array.isArray(message.content)) {
4107
+ for (const block of message.content) {
4108
+ if (block?.type === "text" && block.text) {
4109
+ text += block.text;
4110
+ }
4111
+ }
4112
+ }
4113
+ const derivedTitle = deriveClaudeSdkCliThreadLabel(text, threadId);
4114
+ if (threadId && derivedTitle && runtime.claudeSessionTitles.get(threadId) !== derivedTitle) {
4115
+ runtime.claudeSessionTitles.set(threadId, derivedTitle);
4116
+ changed = true;
4117
+ }
4118
+ break;
4119
+ }
4120
+ }
4121
+
4122
+ return changed;
3381
4123
  }
3382
4124
 
3383
4125
  async function listClaudeTranscriptFiles(claudeProjectsDir) {
@@ -3497,8 +4239,10 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3497
4239
  fileState.threadLabel = path.basename(record.cwd);
3498
4240
  }
3499
4241
 
3500
- // Only process Claude Desktop sessions
3501
- if (record.entrypoint !== "claude-desktop") continue;
4242
+ // Accept both Claude Desktop sessions and the current sdk-cli sessions
4243
+ // that Claude emits for agent-driven work. The latter regressed the
4244
+ // timeline/completed views because they were silently skipped here.
4245
+ if (record.entrypoint !== "claude-desktop" && record.entrypoint !== "sdk-cli") continue;
3502
4246
 
3503
4247
  const type = record.type;
3504
4248
  if (type !== "user" && type !== "assistant") continue;
@@ -3526,6 +4270,15 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3526
4270
  }
3527
4271
  text = cleanText(text);
3528
4272
  if (!text) continue;
4273
+ if (type === "user" && record.entrypoint === "sdk-cli") {
4274
+ const derivedThreadLabel = deriveClaudeSdkCliThreadLabel(text, threadId);
4275
+ if (derivedThreadLabel) {
4276
+ fileState.threadLabel = derivedThreadLabel;
4277
+ if (threadId) {
4278
+ runtime.claudeSessionTitles.set(threadId, derivedThreadLabel);
4279
+ }
4280
+ }
4281
+ }
3529
4282
  // Skip Claude Code internal system messages (task notifications, system
3530
4283
  // reminders, etc.) — these are XML-like tags injected into the
3531
4284
  // conversation that should not appear on the user-facing timeline.
@@ -3587,6 +4340,7 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3587
4340
  config,
3588
4341
  state,
3589
4342
  kind: "assistant_final",
4343
+ tab: "timeline",
3590
4344
  token: entry.token,
3591
4345
  stableId: entry.stableId,
3592
4346
  title: threadLabel || "Claude",
@@ -3608,6 +4362,129 @@ async function processClaudeTranscriptFile({ filePath, config, runtime, state, n
3608
4362
  return dirty;
3609
4363
  }
3610
4364
 
4365
+ async function loadRecoveryBackupState(stateFile) {
4366
+ const dir = path.dirname(stateFile);
4367
+ const base = path.basename(stateFile);
4368
+ let entries;
4369
+ try {
4370
+ entries = await fs.readdir(dir, { withFileTypes: true });
4371
+ } catch {
4372
+ return null;
4373
+ }
4374
+
4375
+ const candidates = [];
4376
+ for (const entry of entries) {
4377
+ if (!entry.isFile()) continue;
4378
+ if (!entry.name.startsWith(`${base}.bak-`)) continue;
4379
+ const filePath = path.join(dir, entry.name);
4380
+ try {
4381
+ const stat = await fs.stat(filePath);
4382
+ candidates.push({ filePath, mtimeMs: Number(stat.mtimeMs) || 0 });
4383
+ } catch {
4384
+ // ignore unreadable backup
4385
+ }
4386
+ }
4387
+
4388
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
4389
+ for (const candidate of candidates) {
4390
+ try {
4391
+ const raw = await fs.readFile(candidate.filePath, "utf8");
4392
+ return JSON.parse(raw);
4393
+ } catch {
4394
+ // try next backup
4395
+ }
4396
+ }
4397
+ return null;
4398
+ }
4399
+
4400
+ function providerSetForItems(items) {
4401
+ return new Set(
4402
+ (Array.isArray(items) ? items : [])
4403
+ .map((item) => normalizeProvider(item?.provider))
4404
+ .filter(Boolean)
4405
+ );
4406
+ }
4407
+
4408
+ function missingRecoveryProviders(items) {
4409
+ const present = providerSetForItems(items);
4410
+ return ["claude", "a2a"].filter((provider) => !present.has(provider));
4411
+ }
4412
+
4413
+ function selectRecoveryItems(items, missingProviders) {
4414
+ const wanted = new Set(missingProviders);
4415
+ return (Array.isArray(items) ? items : []).filter((item) => wanted.has(normalizeProvider(item?.provider)));
4416
+ }
4417
+
4418
+ async function recoverMissingProviderStateFromBackup({ config, runtime, state }) {
4419
+ const missingHistoryProviders = missingRecoveryProviders(state.recentHistoryItems ?? runtime.recentHistoryItems);
4420
+ const missingTimelineProviders = missingRecoveryProviders(state.recentTimelineEntries ?? runtime.recentTimelineEntries);
4421
+ const shouldRecoverCodeEvents = !Array.isArray(state.recentCodeEvents) || state.recentCodeEvents.length === 0;
4422
+
4423
+ if (missingHistoryProviders.length === 0 && missingTimelineProviders.length === 0 && !shouldRecoverCodeEvents) {
4424
+ return false;
4425
+ }
4426
+
4427
+ const backup = await loadRecoveryBackupState(config.stateFile);
4428
+ if (!backup) {
4429
+ return false;
4430
+ }
4431
+
4432
+ let changed = false;
4433
+
4434
+ if (missingHistoryProviders.length > 0) {
4435
+ const mergedHistory = normalizeHistoryItems(
4436
+ [
4437
+ ...(state.recentHistoryItems ?? runtime.recentHistoryItems ?? []),
4438
+ ...selectRecoveryItems(backup.recentHistoryItems, missingHistoryProviders),
4439
+ ],
4440
+ config.maxHistoryItems
4441
+ );
4442
+ if (JSON.stringify(mergedHistory) !== JSON.stringify(state.recentHistoryItems ?? runtime.recentHistoryItems ?? [])) {
4443
+ state.recentHistoryItems = mergedHistory;
4444
+ runtime.recentHistoryItems = mergedHistory;
4445
+ changed = true;
4446
+ }
4447
+ }
4448
+
4449
+ if (missingTimelineProviders.length > 0) {
4450
+ const mergedTimeline = normalizeTimelineEntries(
4451
+ [
4452
+ ...(state.recentTimelineEntries ?? runtime.recentTimelineEntries ?? []),
4453
+ ...selectRecoveryItems(backup.recentTimelineEntries, missingTimelineProviders),
4454
+ ],
4455
+ config.maxTimelineEntries
4456
+ );
4457
+ if (JSON.stringify(mergedTimeline) !== JSON.stringify(state.recentTimelineEntries ?? runtime.recentTimelineEntries ?? [])) {
4458
+ state.recentTimelineEntries = mergedTimeline;
4459
+ runtime.recentTimelineEntries = mergedTimeline;
4460
+ changed = true;
4461
+ }
4462
+ }
4463
+
4464
+ if (shouldRecoverCodeEvents) {
4465
+ const mergedCodeEvents = normalizeCodeEvents(
4466
+ [
4467
+ ...(state.recentCodeEvents ?? runtime.recentCodeEvents ?? []),
4468
+ ...(Array.isArray(backup.recentCodeEvents) ? backup.recentCodeEvents : []),
4469
+ ],
4470
+ config.maxCodeEvents
4471
+ );
4472
+ if (JSON.stringify(mergedCodeEvents) !== JSON.stringify(state.recentCodeEvents ?? runtime.recentCodeEvents ?? [])) {
4473
+ state.recentCodeEvents = mergedCodeEvents;
4474
+ runtime.recentCodeEvents = mergedCodeEvents;
4475
+ invalidateDiffThreadGroupsCache();
4476
+ changed = true;
4477
+ }
4478
+ }
4479
+
4480
+ if (changed) {
4481
+ console.log(
4482
+ `[state-recovery] recovered history=${missingHistoryProviders.join(",") || "none"} timeline=${missingTimelineProviders.join(",") || "none"} code=${shouldRecoverCodeEvents ? "yes" : "no"}`
4483
+ );
4484
+ }
4485
+ return changed;
4486
+ }
4487
+
3611
4488
  async function processSqliteCompletionLog({ config, runtime, state, now }) {
3612
4489
  const logsDbFile = cleanText(runtime.logsDbFile || config.codexLogsDbFile || "");
3613
4490
  if (!logsDbFile) {
@@ -4800,6 +5677,12 @@ async function processScannedEvent({ config, runtime, state, event }) {
4800
5677
  return false;
4801
5678
  }
4802
5679
 
5680
+ if (shouldSuppressInternalScannedEvent(event)) {
5681
+ state.seenEvents[event.id] = event.timestampMs || Date.now();
5682
+ trimSeenEvents(state.seenEvents, config.maxSeenEvents);
5683
+ return true;
5684
+ }
5685
+
4803
5686
  let dirty = false;
4804
5687
 
4805
5688
  if (event.kind === "task_complete") {
@@ -4826,6 +5709,8 @@ async function processScannedEvent({ config, runtime, state, event }) {
4826
5709
  config,
4827
5710
  state,
4828
5711
  kind: "completion",
5712
+ tab: "inbox",
5713
+ subtab: "completed",
4829
5714
  token: historyToken(event.id),
4830
5715
  stableId: event.id,
4831
5716
  title: event.title,
@@ -5134,6 +6019,28 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
5134
6019
  request,
5135
6020
  approval: existing,
5136
6021
  });
6022
+ const existingAutoPilotWriteCandidate =
6023
+ existing.kind === "file"
6024
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval: existing })
6025
+ : null;
6026
+ if (existingAutoPilotWriteCandidate) {
6027
+ try {
6028
+ await autoApproveTrustedWrite({
6029
+ config,
6030
+ runtime,
6031
+ state,
6032
+ approval: existing,
6033
+ candidate: existingAutoPilotWriteCandidate,
6034
+ });
6035
+ continue;
6036
+ } catch (error) {
6037
+ existing.resolved = false;
6038
+ existing.resolving = false;
6039
+ runtime.nativeApprovalsByRequestKey.set(requestKey, existing);
6040
+ runtime.nativeApprovalsByToken.set(existing.token, existing);
6041
+ console.error(`[auto-pilot-write-error] ${requestKey} | ${error.message}`);
6042
+ }
6043
+ }
5137
6044
  if (changed) {
5138
6045
  const fileKeys = isPlainObject(existing.rawParams) ? Object.keys(existing.rawParams).join(",") : "";
5139
6046
  console.log(
@@ -5156,6 +6063,57 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
5156
6063
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
5157
6064
  runtime.nativeApprovalsByToken.set(approval.token, approval);
5158
6065
 
6066
+ const nativeAutoPilotMatch =
6067
+ approval.kind === "command"
6068
+ ? maybeAutoApproveTrustedRead({
6069
+ state,
6070
+ commandText: approval.rawParams?.command ?? approval.rawParams?.cmd ?? "",
6071
+ cwd: approval.rawParams?.cwd ?? approval.rawParams?.grantRoot ?? "",
6072
+ workspaceRoot: approval.rawParams?.grantRoot ?? approval.rawParams?.cwd ?? "",
6073
+ })
6074
+ : null;
6075
+ if (nativeAutoPilotMatch) {
6076
+ try {
6077
+ await autoApproveTrustedRead({
6078
+ config,
6079
+ runtime,
6080
+ state,
6081
+ approval,
6082
+ policyMatch: nativeAutoPilotMatch,
6083
+ });
6084
+ continue;
6085
+ } catch (error) {
6086
+ approval.resolved = false;
6087
+ approval.resolving = false;
6088
+ runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
6089
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
6090
+ console.error(`[auto-pilot-error] ${requestKey} | ${error.message}`);
6091
+ }
6092
+ }
6093
+
6094
+ const nativeAutoPilotWriteCandidate =
6095
+ approval.kind === "file"
6096
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval })
6097
+ : null;
6098
+ if (nativeAutoPilotWriteCandidate) {
6099
+ try {
6100
+ await autoApproveTrustedWrite({
6101
+ config,
6102
+ runtime,
6103
+ state,
6104
+ approval,
6105
+ candidate: nativeAutoPilotWriteCandidate,
6106
+ });
6107
+ continue;
6108
+ } catch (error) {
6109
+ approval.resolved = false;
6110
+ approval.resolving = false;
6111
+ runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
6112
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
6113
+ console.error(`[auto-pilot-write-error] ${requestKey} | ${error.message}`);
6114
+ }
6115
+ }
6116
+
5159
6117
  if (previousKeys.has(requestKey)) {
5160
6118
  const fileKeys = isPlainObject(approval.rawParams) ? Object.keys(approval.rawParams).join(",") : "";
5161
6119
  console.log(
@@ -5187,6 +6145,8 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
5187
6145
  config,
5188
6146
  state,
5189
6147
  kind: "approval",
6148
+ tab: "inbox",
6149
+ subtab: "pending",
5190
6150
  token: approval.token,
5191
6151
  stableId: pendingApprovalStableId(approval),
5192
6152
  title: approval.title,
@@ -5310,6 +6270,8 @@ async function syncPlanImplementationRequests({
5310
6270
  config,
5311
6271
  state,
5312
6272
  kind: "plan",
6273
+ tab: "inbox",
6274
+ subtab: "pending",
5313
6275
  token: planRequest.token,
5314
6276
  stableId: pendingPlanStableId(planRequest),
5315
6277
  title: planRequest.title,
@@ -5515,6 +6477,8 @@ async function syncGenericUserInputRequests({
5515
6477
  config,
5516
6478
  state,
5517
6479
  kind: "choice",
6480
+ tab: "inbox",
6481
+ subtab: "pending",
5518
6482
  token: userInputRequest.token,
5519
6483
  stableId: pendingChoiceStableId(userInputRequest),
5520
6484
  title: userInputRequest.title,
@@ -5578,6 +6542,150 @@ async function createNativeApproval({ config, runtime, conversationId, request,
5578
6542
  };
5579
6543
  }
5580
6544
 
6545
+ function createX402PaymentApproval({ config, body, now = Date.now() }) {
6546
+ const payment = normalizeX402PaymentApprovalBody(body);
6547
+ if (!payment) {
6548
+ return null;
6549
+ }
6550
+ const token = crypto.randomBytes(18).toString("hex");
6551
+ const requestId = cleanText(body.paymentRequestId || body.requestId || crypto.randomUUID());
6552
+ const requestKey = `payment:${requestId}`;
6553
+ const title = payment.amountUsdc
6554
+ ? `Payment approval — ${payment.amountUsdc} USDC`
6555
+ : "Payment approval";
6556
+ return {
6557
+ token,
6558
+ requestKey,
6559
+ conversationId: "payments",
6560
+ requestId,
6561
+ ownerClientId: null,
6562
+ kind: "payment",
6563
+ threadLabel: "x402 payment",
6564
+ title,
6565
+ messageText: formatX402PaymentApprovalMessage(payment),
6566
+ reviewUrl: `${config.nativeApprovalPublicBaseUrl}/native-approvals/${token}`,
6567
+ rawParams: payment,
6568
+ cwd: "",
6569
+ workspaceRoot: "",
6570
+ fileRefs: [],
6571
+ diffText: "",
6572
+ diffAvailable: false,
6573
+ diffSource: "",
6574
+ diffAddedLines: 0,
6575
+ diffRemovedLines: 0,
6576
+ createdAtMs: now,
6577
+ resolved: false,
6578
+ resolving: false,
6579
+ resolveClaudeWaiter: null,
6580
+ provider: "viveworker",
6581
+ };
6582
+ }
6583
+
6584
+
6585
+ function createHazbaseWalletPaymentApproval({ config, body, now = Date.now() }) {
6586
+ const payment = normalizeX402PaymentApprovalBody(body);
6587
+ if (!payment) return null;
6588
+ const paymentRequestId = cleanText(body.paymentRequestId || body.requestId || "");
6589
+ if (!/^[a-zA-Z0-9:_-]{8,160}$/u.test(paymentRequestId)) return null;
6590
+ const token = crypto.randomBytes(18).toString("hex");
6591
+ const requestKey = `hazbase_wallet_payment:${paymentRequestId}`;
6592
+ const title = payment.amountUsdc
6593
+ ? `Hazbase wallet payment — ${payment.amountUsdc} USDC`
6594
+ : "Hazbase wallet payment";
6595
+ return {
6596
+ token,
6597
+ requestKey,
6598
+ conversationId: "payments",
6599
+ requestId: paymentRequestId,
6600
+ paymentRequestId,
6601
+ ownerClientId: null,
6602
+ kind: "hazbase_wallet_payment",
6603
+ threadLabel: "x402 payment",
6604
+ title,
6605
+ messageText: formatHazbaseWalletPaymentApprovalMessage(payment),
6606
+ reviewUrl: `${config.nativeApprovalPublicBaseUrl}/native-approvals/${token}`,
6607
+ rawParams: payment,
6608
+ cwd: "",
6609
+ workspaceRoot: "",
6610
+ fileRefs: [],
6611
+ diffText: "",
6612
+ diffAvailable: false,
6613
+ diffSource: "",
6614
+ diffAddedLines: 0,
6615
+ diffRemovedLines: 0,
6616
+ createdAtMs: now,
6617
+ resolved: false,
6618
+ resolving: false,
6619
+ resolveClaudeWaiter: null,
6620
+ provider: "viveworker",
6621
+ };
6622
+ }
6623
+
6624
+ function formatHazbaseWalletPaymentApprovalMessage(payment) {
6625
+ const lines = [
6626
+ "Hazbase Smart Wallet payment requested.",
6627
+ "",
6628
+ `Amount: ${payment.amountUsdc || payment.amountAtomic} USDC`,
6629
+ `Network: ${payment.network} (chainId ${payment.chainId})`,
6630
+ `Pay to: ${payment.payTo}`,
6631
+ `Asset: ${payment.asset}`,
6632
+ `Resource: ${payment.resource}`,
6633
+ "",
6634
+ "Approving will ask for your passkey, then hazBase will submit a gasless Smart Wallet payment.",
6635
+ ];
6636
+ if (payment.description) lines.splice(7, 0, `Description: ${payment.description}`);
6637
+ if (payment.url && payment.url !== payment.resource) lines.splice(-1, 0, `URL: ${payment.url}`);
6638
+ return lines.join("\n");
6639
+ }
6640
+
6641
+ function normalizeX402PaymentApprovalBody(body) {
6642
+ if (!isPlainObject(body)) return null;
6643
+ const payment = isPlainObject(body.payment) ? body.payment : {};
6644
+ const network = cleanText(payment.network || "");
6645
+ const chainId = Number(payment.chainId) || 0;
6646
+ const amountAtomic = cleanText(payment.amountAtomic || "");
6647
+ const amountUsdc = cleanText(payment.amountUsdc || "");
6648
+ const payTo = cleanText(payment.payTo || "");
6649
+ const asset = cleanText(payment.asset || "");
6650
+ const resource = cleanText(payment.resource || body.url || "");
6651
+ const description = cleanText(payment.description || "");
6652
+ const url = cleanText(body.url || resource);
6653
+ if (!network || !chainId || !amountAtomic || !payTo || !asset || !resource) {
6654
+ return null;
6655
+ }
6656
+ return {
6657
+ url,
6658
+ network,
6659
+ chainId,
6660
+ amountAtomic,
6661
+ amountUsdc,
6662
+ payTo,
6663
+ asset,
6664
+ resource,
6665
+ description,
6666
+ };
6667
+ }
6668
+
6669
+ function formatX402PaymentApprovalMessage(payment) {
6670
+ const lines = [
6671
+ "x402 payment approval requested.",
6672
+ "",
6673
+ `Amount: ${payment.amountUsdc || payment.amountAtomic} USDC`,
6674
+ `Network: ${payment.network} (chainId ${payment.chainId})`,
6675
+ `Pay to: ${payment.payTo}`,
6676
+ `Asset: ${payment.asset}`,
6677
+ `Resource: ${payment.resource}`,
6678
+ ];
6679
+ if (payment.description) {
6680
+ lines.push(`Description: ${payment.description}`);
6681
+ }
6682
+ if (payment.url && payment.url !== payment.resource) {
6683
+ lines.push(`URL: ${payment.url}`);
6684
+ }
6685
+ lines.push("", "Approve only if the amount, recipient, network, and resource match what you expect.");
6686
+ return lines.join("\n");
6687
+ }
6688
+
5581
6689
  async function buildNativeApprovalPayload({ config, runtime, conversationId, request, token }) {
5582
6690
  const kind = nativeApprovalKind(request.method);
5583
6691
  if (!kind) {
@@ -5633,6 +6741,8 @@ async function buildNativeApprovalPayload({ config, runtime, conversationId, req
5633
6741
  ownerClientId: runtime.threadOwnerClientIds.get(conversationId) ?? null,
5634
6742
  approvalIds,
5635
6743
  rawParams,
6744
+ cwd: cleanText(rawParams?.cwd ?? rawParams?.grantRoot ?? ""),
6745
+ workspaceRoot: cleanText(rawParams?.grantRoot ?? rawParams?.cwd ?? ""),
5636
6746
  fileRefs: normalizeTimelineFileRefs(mergedDelta?.fileRefs ?? []),
5637
6747
  diffText: normalizeTimelineDiffText(mergedDelta?.diffText ?? ""),
5638
6748
  diffAvailable: Boolean(mergedDelta?.diffAvailable),
@@ -5669,6 +6779,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5669
6779
  ownerClientId: approval.ownerClientId,
5670
6780
  approvalIds: approval.approvalIds,
5671
6781
  rawParams: approval.rawParams,
6782
+ cwd: approval.cwd,
6783
+ workspaceRoot: approval.workspaceRoot,
5672
6784
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
5673
6785
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
5674
6786
  diffAvailable: Boolean(approval.diffAvailable),
@@ -5688,6 +6800,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5688
6800
  approval.ownerClientId = payload.ownerClientId;
5689
6801
  approval.approvalIds = payload.approvalIds;
5690
6802
  approval.rawParams = payload.rawParams;
6803
+ approval.cwd = payload.cwd;
6804
+ approval.workspaceRoot = payload.workspaceRoot;
5691
6805
  approval.fileRefs = payload.fileRefs;
5692
6806
  approval.diffText = payload.diffText;
5693
6807
  approval.diffAvailable = payload.diffAvailable;
@@ -5707,6 +6821,8 @@ async function refreshNativeApprovalFromRequest({ config, runtime, conversationI
5707
6821
  ownerClientId: approval.ownerClientId,
5708
6822
  approvalIds: approval.approvalIds,
5709
6823
  rawParams: approval.rawParams,
6824
+ cwd: approval.cwd,
6825
+ workspaceRoot: approval.workspaceRoot,
5710
6826
  fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
5711
6827
  diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
5712
6828
  diffAvailable: Boolean(approval.diffAvailable),
@@ -7767,6 +8883,121 @@ function getNativeThreadLabel({ runtime, conversationId, cwd }) {
7767
8883
  return shortId(normalizedConversationId) || t(DEFAULT_LOCALE, "server.fallback.codexTask", { provider: providerDisplayName(DEFAULT_LOCALE, "codex") });
7768
8884
  }
7769
8885
 
8886
+ function isFallbackConversationLabel(label, conversationId) {
8887
+ const normalizedLabel = cleanText(label || "");
8888
+ const normalizedConversationId = cleanText(conversationId || "");
8889
+ if (!normalizedLabel) {
8890
+ return true;
8891
+ }
8892
+ if (normalizedConversationId && normalizedLabel === shortId(normalizedConversationId)) {
8893
+ return true;
8894
+ }
8895
+ return false;
8896
+ }
8897
+
8898
+ function deriveClaudeSdkCliThreadLabel(messageText, conversationId = "") {
8899
+ const cleaned = stripNotificationMarkup(stripEnvironmentContextBlocks(messageText || ""));
8900
+ const single = cleanText(cleaned);
8901
+ if (!single || /^\d+$/u.test(single)) {
8902
+ return "";
8903
+ }
8904
+
8905
+ if (/^You are scoring Moltbook posts? for an AI agent\b/iu.test(single)) {
8906
+ return "Moltbook scoring";
8907
+ }
8908
+ if (/^Codex from another agent:/iu.test(single)) {
8909
+ return truncate(cleanText(single.replace(/^Codex from another agent:\s*/iu, "")), 90) || "Cross-agent task";
8910
+ }
8911
+
8912
+ const firstSentence = cleanText(single.split(/(?<=[.!?。!?])\s+/u)[0] || "");
8913
+ const candidate = firstSentence || single;
8914
+ if (!candidate || candidate === shortId(conversationId)) {
8915
+ return "";
8916
+ }
8917
+ return truncate(candidate, 90);
8918
+ }
8919
+
8920
+ function isHiddenClaudeInternalScoringText(text) {
8921
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
8922
+ if (!single) {
8923
+ return false;
8924
+ }
8925
+ return /^You are scoring Moltbook posts? for an AI agent\b/iu.test(single);
8926
+ }
8927
+
8928
+ function isHiddenCodexApprovalAssessmentText(text) {
8929
+ const single = cleanText(stripNotificationMarkup(stripEnvironmentContextBlocks(text || "")));
8930
+ if (!single) {
8931
+ return false;
8932
+ }
8933
+ return /\bThe following is the Codex agent history(?: added since your last approval assessment)?\b/iu.test(single);
8934
+ }
8935
+
8936
+ function shouldHideInternalTimelineItem(item) {
8937
+ return shouldHideClaudeInternalItem(item) || shouldHideCodexInternalApprovalItem(item);
8938
+ }
8939
+
8940
+ function shouldHideClaudeInternalItem(item) {
8941
+ if (!isPlainObject(item)) {
8942
+ return false;
8943
+ }
8944
+ if (normalizeProvider(item.provider) !== "claude") {
8945
+ return false;
8946
+ }
8947
+ const threadLabel = cleanText(item.threadLabel ?? "");
8948
+ if (threadLabel === "Moltbook scoring") {
8949
+ return true;
8950
+ }
8951
+ return (
8952
+ isHiddenClaudeInternalScoringText(item.messageText) ||
8953
+ isHiddenClaudeInternalScoringText(item.summary) ||
8954
+ isHiddenClaudeInternalScoringText(item.detailText) ||
8955
+ isHiddenClaudeInternalScoringText(item.message)
8956
+ );
8957
+ }
8958
+
8959
+ function shouldSuppressInternalScannedEvent(event) {
8960
+ if (!isPlainObject(event)) {
8961
+ return false;
8962
+ }
8963
+ const provider = normalizeProvider(event.provider || "codex");
8964
+ if (provider !== "codex") {
8965
+ return false;
8966
+ }
8967
+
8968
+ return shouldHideCodexInternalApprovalItem({
8969
+ provider,
8970
+ kind: cleanText(event.kind || ""),
8971
+ title: event.title,
8972
+ threadLabel: event.threadLabel,
8973
+ summary: event.summary ?? event.message,
8974
+ messageText: event.messageText ?? event.detailText ?? event.message,
8975
+ detailText: event.detailText,
8976
+ message: event.message,
8977
+ });
8978
+ }
8979
+
8980
+ function shouldHideCodexInternalApprovalItem(item) {
8981
+ if (!isPlainObject(item)) {
8982
+ return false;
8983
+ }
8984
+ if (normalizeProvider(item.provider) !== "codex") {
8985
+ return false;
8986
+ }
8987
+
8988
+ const title = cleanText(item.title ?? "");
8989
+ const threadLabel = cleanText(item.threadLabel ?? "");
8990
+ if (isHiddenCodexApprovalAssessmentText(title) || isHiddenCodexApprovalAssessmentText(threadLabel)) {
8991
+ return true;
8992
+ }
8993
+ return (
8994
+ isHiddenCodexApprovalAssessmentText(item.messageText) ||
8995
+ isHiddenCodexApprovalAssessmentText(item.summary) ||
8996
+ isHiddenCodexApprovalAssessmentText(item.detailText) ||
8997
+ isHiddenCodexApprovalAssessmentText(item.message)
8998
+ );
8999
+ }
9000
+
7770
9001
  function threadStateArchiveStatus(threadState) {
7771
9002
  if (!isPlainObject(threadState)) {
7772
9003
  return "";
@@ -8724,6 +9955,8 @@ function markDevicePaired(state, config, deviceId, metadata = {}, now = Date.now
8724
9955
  return JSON.stringify(previous ?? null) !== JSON.stringify(next);
8725
9956
  }
8726
9957
 
9958
+ const TOUCH_DEVICE_TRUST_DEBOUNCE_MS = 60_000;
9959
+
8727
9960
  function touchDeviceTrust(state, config, deviceId, now = Date.now()) {
8728
9961
  const normalizedDeviceId = cleanText(deviceId || "");
8729
9962
  const current = getActiveDeviceTrustRecord(state, config, normalizedDeviceId, now);
@@ -8731,6 +9964,20 @@ function touchDeviceTrust(state, config, deviceId, now = Date.now()) {
8731
9964
  return false;
8732
9965
  }
8733
9966
 
9967
+ // Debounce: `lastAuthenticatedAtMs` is bumped to `now` on every
9968
+ // authenticated request. Without this guard, every `/api/session`
9969
+ // (and `/api/bootstrap`) call flags the record as "changed" and the
9970
+ // caller runs a full `saveState`, which synchronously stringifies an
9971
+ // 8+ MB state.json on the main thread and starves the event loop —
9972
+ // driving TLS handshake latency for any in-flight connection into the
9973
+ // seconds range. Skip the write when we've touched it recently; the
9974
+ // next real change (pairing, revocation, locale update, etc.) still
9975
+ // goes through unchanged.
9976
+ const previousTouch = Number(current.lastAuthenticatedAtMs) || 0;
9977
+ if (previousTouch > 0 && now - previousTouch < TOUCH_DEVICE_TRUST_DEBOUNCE_MS) {
9978
+ return false;
9979
+ }
9980
+
8734
9981
  const next = {
8735
9982
  ...current,
8736
9983
  lastAuthenticatedAtMs: now,
@@ -8853,11 +10100,27 @@ function buildDeviceSummary({ config, state, deviceId, record, currentDeviceId,
8853
10100
  };
8854
10101
  }
8855
10102
 
8856
- function buildDevicesResponse({ config, state, session, locale }) {
10103
+ // Shared between `/api/session` and `/api/bootstrap` so both return an
10104
+ // identical session shape.
10105
+ function buildSessionPayload({ config, state, session }) {
8857
10106
  return {
8858
- devices: activeTrustedDevices(state, config).map(({ deviceId, record }) =>
8859
- buildDeviceSummary({
8860
- config,
10107
+ authenticated: Boolean(session.authenticated),
10108
+ expiresAtMs: Number(session.expiresAtMs) || 0,
10109
+ pairingAvailable: isPairingAvailableForState(config, state),
10110
+ webPushEnabled: config.webPushEnabled,
10111
+ httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
10112
+ appVersion: appPackageVersion,
10113
+ deviceId: session.deviceId || null,
10114
+ temporaryPairing: session.temporaryPairing === true,
10115
+ ...buildSessionLocalePayload(config, state, session.deviceId),
10116
+ };
10117
+ }
10118
+
10119
+ function buildDevicesResponse({ config, state, session, locale }) {
10120
+ return {
10121
+ devices: activeTrustedDevices(state, config).map(({ deviceId, record }) =>
10122
+ buildDeviceSummary({
10123
+ config,
8861
10124
  state,
8862
10125
  deviceId,
8863
10126
  record,
@@ -9448,7 +10711,9 @@ function buildPendingInboxItems(runtime, state, config, locale) {
9448
10711
  token: approval.token,
9449
10712
  threadId: cleanText(approval.conversationId || ""),
9450
10713
  threadLabel: approval.threadLabel || "",
9451
- title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
10714
+ title: cleanText(approval.kind || "") === "payment"
10715
+ ? cleanText(approval.title || "") || formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel)
10716
+ : formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
9452
10717
  summary: formatNotificationBody(approval.messageText, 100) || approval.messageText,
9453
10718
  primaryLabel: t(locale, "server.action.review"),
9454
10719
  createdAtMs: Number(approval.createdAtMs) || now,
@@ -9608,7 +10873,7 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
9608
10873
  }
9609
10874
 
9610
10875
  async function buildDiffInboxItems(runtime, state, config, locale) {
9611
- return (await buildDiffThreadGroups(runtime, state, config)).map((group) => ({
10876
+ return (await getDiffThreadGroupsCached(runtime, state, config)).map((group) => ({
9612
10877
  kind: "diff_thread",
9613
10878
  token: group.token,
9614
10879
  threadId: group.threadId,
@@ -9767,14 +11032,24 @@ async function buildDiffThreadGroups(runtime, state, config) {
9767
11032
  .sort((left, right) => Number(right.latestChangedAtMs ?? 0) - Number(left.latestChangedAtMs ?? 0));
9768
11033
  }
9769
11034
 
9770
- async function buildInboxResponse(runtime, state, config, locale) {
11035
+ // Split into two halves so `/api/inbox` (pending + completed) returns
11036
+ // without waiting on the diff build, which spawns `git` subprocesses per
11037
+ // tracked repo inside `buildCurrentUnstagedChangesForRepo`. The diff half
11038
+ // is served from `/api/inbox/diff` and fetched separately by the PWA so
11039
+ // it doesn't block first paint of the inbox/completed lists.
11040
+ function buildInboxFastResponse(runtime, state, config, locale) {
9771
11041
  return {
9772
11042
  pending: buildPendingInboxItems(runtime, state, config, locale),
9773
- diff: await buildDiffInboxItems(runtime, state, config, locale),
9774
11043
  completed: buildCompletedInboxItems(runtime, state, config, locale),
9775
11044
  };
9776
11045
  }
9777
11046
 
11047
+ async function buildInboxDiffResponse(runtime, state, config, locale) {
11048
+ return {
11049
+ diff: await buildDiffInboxItems(runtime, state, config, locale),
11050
+ };
11051
+ }
11052
+
9778
11053
  function buildOperationalTimelineEntries(runtime, state, config, locale) {
9779
11054
  const now = Date.now();
9780
11055
  const items = [];
@@ -9790,7 +11065,9 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
9790
11065
  kind: "approval",
9791
11066
  threadId: cleanText(approval.conversationId || ""),
9792
11067
  threadLabel: approval.threadLabel,
9793
- title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
11068
+ title: cleanText(approval.kind || "") === "payment"
11069
+ ? cleanText(approval.title || "") || formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel)
11070
+ : formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
9794
11071
  summary: formatNotificationBody(approval.messageText, 180) || approval.messageText,
9795
11072
  messageText: approval.messageText,
9796
11073
  outcome: "pending",
@@ -9988,15 +11265,18 @@ function buildTimelineResponse(runtime, state, config, locale) {
9988
11265
  };
9989
11266
  }
9990
11267
 
9991
- function buildPendingApprovalDetail(runtime, approval, locale) {
11268
+ function buildPendingApprovalDetail(runtime, state, approval, locale) {
9992
11269
  const previousContext = buildPreviousApprovalContext(runtime, approval);
9993
11270
  const approvalKind = cleanText(approval.kind || "");
11271
+ const autoPilotReview = buildAutoPilotManualReview(runtime, state, approval, locale);
9994
11272
  const detail = {
9995
11273
  kind: "approval",
9996
11274
  approvalKind,
9997
11275
  provider: normalizeProvider(approval.provider),
9998
11276
  token: approval.token,
9999
- title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
11277
+ title: approvalKind === "payment"
11278
+ ? cleanText(approval.title || "") || formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel)
11279
+ : formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
10000
11280
  threadLabel: approval.threadLabel || "",
10001
11281
  createdAtMs: Number(approval.createdAtMs) || 0,
10002
11282
  messageHtml: renderMessageHtml(approval.messageText, `<p>${escapeHtml(t(locale, "detail.approvalRequested"))}</p>`),
@@ -10007,13 +11287,18 @@ function buildPendingApprovalDetail(runtime, approval, locale) {
10007
11287
  diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
10008
11288
  diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
10009
11289
  previousContext,
11290
+ autoPilotReview,
10010
11291
  readOnly: approval.readOnly === true,
10011
11292
  actions: approval.readOnly === true
10012
11293
  ? []
10013
- : [
10014
- { label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
10015
- { label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
10016
- ],
11294
+ : approvalKind === "hazbase_wallet_payment"
11295
+ ? [
11296
+ { label: t(locale, "server.action.payWithWallet"), tone: "primary", url: `/api/payments/x402/hazbase-wallet/${encodeURIComponent(approval.token)}/pay`, body: { hazbaseReauth: true } },
11297
+ ]
11298
+ : [
11299
+ { label: t(locale, "server.action.approve"), tone: "primary", url: `/api/items/approval/${encodeURIComponent(approval.token)}/accept`, body: {} },
11300
+ { label: t(locale, "server.action.reject"), tone: "danger", url: `/api/items/approval/${encodeURIComponent(approval.token)}/decline`, body: {} },
11301
+ ],
10017
11302
  };
10018
11303
  if (approvalKind === "plan") {
10019
11304
  const planText = String(approval.planText || "");
@@ -10031,6 +11316,193 @@ function buildPendingApprovalDetail(runtime, approval, locale) {
10031
11316
  return detail;
10032
11317
  }
10033
11318
 
11319
+ function buildAutoPilotManualReview(runtime, state, approval, locale) {
11320
+ if (!isPlainObject(approval) || approval.readOnly === true) {
11321
+ return null;
11322
+ }
11323
+ if (cleanText(approval.kind || "") !== "file") {
11324
+ return null;
11325
+ }
11326
+ return buildAutoPilotWriteManualReview(runtime, state, approval, locale);
11327
+ }
11328
+
11329
+ function buildAutoPilotWriteManualReview(runtime, state, approval, locale) {
11330
+ const writesEnabled = isAutoPilotTrustedWritesEnabled(state);
11331
+ if (!writesEnabled) {
11332
+ return {
11333
+ title: t(locale, "detail.autoPilot.manualTitle"),
11334
+ body: t(locale, "detail.autoPilot.writeDisabled"),
11335
+ };
11336
+ }
11337
+
11338
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
11339
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
11340
+ if (!cwd || !workspaceRoot) {
11341
+ return {
11342
+ title: t(locale, "detail.autoPilot.manualTitle"),
11343
+ body: t(locale, "detail.autoPilot.writeMissingWorkspace"),
11344
+ };
11345
+ }
11346
+
11347
+ const diffText = normalizeTimelineDiffText(approval?.diffText ?? "");
11348
+ if (!diffText) {
11349
+ return {
11350
+ title: t(locale, "detail.autoPilot.manualTitle"),
11351
+ body: t(locale, "detail.autoPilot.writeMissingDiff"),
11352
+ };
11353
+ }
11354
+
11355
+ const rawFileRefs = normalizeTimelineFileRefs(approval?.fileRefs ?? []);
11356
+ if (rawFileRefs.length === 0) {
11357
+ return {
11358
+ title: t(locale, "detail.autoPilot.manualTitle"),
11359
+ body: t(locale, "detail.autoPilot.writeMissingFiles"),
11360
+ };
11361
+ }
11362
+
11363
+ const resolvedFileRefs = resolveTrustedWriteFileRefs({
11364
+ fileRefs: approval?.fileRefs ?? [],
11365
+ cwd,
11366
+ workspaceRoot,
11367
+ });
11368
+ if (!resolvedFileRefs) {
11369
+ return {
11370
+ title: t(locale, "detail.autoPilot.manualTitle"),
11371
+ body: t(locale, "detail.autoPilot.writeInvalidFiles"),
11372
+ };
11373
+ }
11374
+
11375
+ const counts = diffLineCounts(diffText);
11376
+ const totalChangedLines =
11377
+ Math.max(0, Number(counts.addedLines) || 0) +
11378
+ Math.max(0, Number(counts.removedLines) || 0);
11379
+ if (totalChangedLines === 0) {
11380
+ return {
11381
+ title: t(locale, "detail.autoPilot.manualTitle"),
11382
+ body: t(locale, "detail.autoPilot.writeMissingDiff"),
11383
+ };
11384
+ }
11385
+ if (totalChangedLines > 120) {
11386
+ return {
11387
+ title: t(locale, "detail.autoPilot.manualTitle"),
11388
+ body: t(locale, "detail.autoPilot.writeDiffTooLarge"),
11389
+ };
11390
+ }
11391
+
11392
+ const diffSections = splitUnifiedDiffTextByFile(diffText);
11393
+ if (diffSections.length === 0) {
11394
+ return {
11395
+ title: t(locale, "detail.autoPilot.manualTitle"),
11396
+ body: t(locale, "detail.autoPilot.writeDiffUnreadable"),
11397
+ };
11398
+ }
11399
+ if (diffSections.length > 3) {
11400
+ return {
11401
+ title: t(locale, "detail.autoPilot.manualTitle"),
11402
+ body: t(locale, "detail.autoPilot.writeDiffTooLarge"),
11403
+ };
11404
+ }
11405
+
11406
+ if (
11407
+ /^(?: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)
11408
+ ) {
11409
+ return {
11410
+ title: t(locale, "detail.autoPilot.manualTitle"),
11411
+ body: t(locale, "detail.autoPilot.writeDangerousDiff"),
11412
+ };
11413
+ }
11414
+
11415
+ const diffSectionRefs = resolveTrustedWriteDiffSectionFileRefs({
11416
+ diffSections,
11417
+ cwd,
11418
+ workspaceRoot,
11419
+ });
11420
+ if (!diffSectionRefs || !sameNormalizedFileRefSet(diffSectionRefs, resolvedFileRefs)) {
11421
+ return {
11422
+ title: t(locale, "detail.autoPilot.manualTitle"),
11423
+ body: t(locale, "detail.autoPilot.writeDiffMismatch"),
11424
+ };
11425
+ }
11426
+
11427
+ const lanes = autoPilotWriteLaneState(state);
11428
+ const isContentOnly = resolvedFileRefs.every((fileRef) => isAutoPilotContentWritePath(fileRef));
11429
+ if (isContentOnly) {
11430
+ if (!lanes.content) {
11431
+ return {
11432
+ title: t(locale, "detail.autoPilot.manualTitle"),
11433
+ body: t(locale, "detail.autoPilot.writeContentDisabled"),
11434
+ };
11435
+ }
11436
+ return {
11437
+ title: t(locale, "detail.autoPilot.manualTitle"),
11438
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
11439
+ };
11440
+ }
11441
+
11442
+ const isUiTestsOnly = resolvedFileRefs.every((fileRef) => isAutoPilotUiTestWritePath(fileRef));
11443
+ if (isUiTestsOnly) {
11444
+ if (!lanes.uiTests) {
11445
+ return {
11446
+ title: t(locale, "detail.autoPilot.manualTitle"),
11447
+ body: t(locale, "detail.autoPilot.writeUiDisabled"),
11448
+ };
11449
+ }
11450
+ if (totalChangedLines > 80) {
11451
+ return {
11452
+ title: t(locale, "detail.autoPilot.manualTitle"),
11453
+ body: t(locale, "detail.autoPilot.writeUiTooLarge"),
11454
+ };
11455
+ }
11456
+ if (hasUnsafeUiOrTestWriteDiff(diffText)) {
11457
+ return {
11458
+ title: t(locale, "detail.autoPilot.manualTitle"),
11459
+ body: t(locale, "detail.autoPilot.writeUiUnsafe"),
11460
+ };
11461
+ }
11462
+ return {
11463
+ title: t(locale, "detail.autoPilot.manualTitle"),
11464
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
11465
+ };
11466
+ }
11467
+
11468
+ const isSourceOnly = resolvedFileRefs.every((fileRef) => isAutoPilotSourceWritePath(fileRef));
11469
+ if (isSourceOnly) {
11470
+ if (!lanes.source) {
11471
+ return {
11472
+ title: t(locale, "detail.autoPilot.manualTitle"),
11473
+ body: t(locale, "detail.autoPilot.writeSourceDisabled"),
11474
+ };
11475
+ }
11476
+ if (totalChangedLines > 40) {
11477
+ return {
11478
+ title: t(locale, "detail.autoPilot.manualTitle"),
11479
+ body: t(locale, "detail.autoPilot.writeSourceTooLarge"),
11480
+ };
11481
+ }
11482
+ if (hasUnsafeSourceWriteDiff(diffText)) {
11483
+ return {
11484
+ title: t(locale, "detail.autoPilot.manualTitle"),
11485
+ body: t(locale, "detail.autoPilot.writeSourceUnsafe"),
11486
+ };
11487
+ }
11488
+ if (!hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs })) {
11489
+ return {
11490
+ title: t(locale, "detail.autoPilot.manualTitle"),
11491
+ body: t(locale, "detail.autoPilot.writeSourceContinuity"),
11492
+ };
11493
+ }
11494
+ return {
11495
+ title: t(locale, "detail.autoPilot.manualTitle"),
11496
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
11497
+ };
11498
+ }
11499
+
11500
+ return {
11501
+ title: t(locale, "detail.autoPilot.manualTitle"),
11502
+ body: t(locale, "detail.autoPilot.writeNoLaneMatch"),
11503
+ };
11504
+ }
11505
+
10034
11506
  function buildPreviousApprovalContext(runtime, approval) {
10035
11507
  const threadId = cleanText(approval?.conversationId || "");
10036
11508
  const approvalCreatedAtMs = Number(approval?.createdAtMs) || 0;
@@ -10993,142 +12465,668 @@ function findClaudePendingApprovalForTool(runtime, threadId, toolName, fingerpri
10993
12465
  return match;
10994
12466
  }
10995
12467
 
10996
- async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
10997
- if (approval.resolveClaudeWaiter) {
10998
- if (approval.provider === "claude" && approval.kind === "plan") {
10999
- // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
11000
- // (Claude still shows the native PC plan dialog). Instead, deny the tool
11001
- // call with a reason that tells Claude the user already decided on mobile.
11002
- approval.resolveClaudeWaiter({
11003
- permissionDecision: "deny",
11004
- permissionDecisionReason:
11005
- decision === "accept"
11006
- ? "User approved the plan via the viveworker mobile app. Proceed with implementing the plan exactly as proposed and do not call ExitPlanMode again."
11007
- : "User rejected the plan via the viveworker mobile app. Revise the plan based on the conversation context before calling ExitPlanMode again.",
11008
- });
11009
- } else {
11010
- approval.resolveClaudeWaiter(decision);
11011
- }
11012
- } else if (approval.provider === "claude") {
11013
- // notifyOnly Claude approvals have no long-poll waiter and no ipcClient
11014
- // back-channel — the desktop user already answered. Nothing to send.
11015
- } else {
11016
- await runtime.ipcClient?.sendApprovalDecision(approval, decision);
11017
- }
11018
- approval.resolved = true;
11019
- approval.resolving = false;
11020
- runtime.nativeApprovalsByToken.delete(approval.token);
11021
- runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
11022
- const stateChanged = recordActionHistoryItem({
11023
- config,
11024
- runtime,
11025
- state,
11026
- kind: "approval",
11027
- stableId: `approval:${approval.requestKey}:${Date.now()}`,
11028
- token: approval.token,
11029
- title: approval.title,
11030
- threadLabel: approval.threadLabel || "",
11031
- messageText: `${approvalDecisionMessage(decision, config.defaultLocale, approval.provider)}\n\n${approval.messageText}`,
11032
- summary: formatNotificationBody(approval.messageText, 160) || approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
11033
- fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
11034
- diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
11035
- diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
11036
- diffAvailable: approval.diffAvailable === true || Boolean(approval.diffText),
11037
- diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
11038
- diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
11039
- outcome: decision === "accept" ? "approved" : "rejected",
11040
- provider: approval.provider || "codex",
11041
- });
11042
- if (stateChanged) {
11043
- await saveState(config.stateFile, state);
12468
+ function maybeAutoApproveTrustedRead({ state, commandText, cwd, workspaceRoot }) {
12469
+ if (state?.autoPilotTrustedReads !== true) {
12470
+ return null;
11044
12471
  }
11045
- console.log(`[native-decision] ${approval.requestKey} | ${decision}`);
12472
+ return evaluateTrustedReadCommandPolicy({ commandText, cwd, workspaceRoot });
11046
12473
  }
11047
12474
 
11048
- async function buildApiItemDetail({ config, runtime, state, kind, token, locale }) {
11049
- if (kind === "diff_thread") {
11050
- const group = (await buildDiffThreadGroups(runtime, state, config)).find((entry) => entry.token === token);
11051
- return group ? buildDiffThreadDetail(group, locale) : null;
12475
+ function isAutoPilotTrustedWritesEnabled(state) {
12476
+ return hasAnyAutoPilotWriteLaneEnabled(state);
12477
+ }
12478
+
12479
+ function isDeniedTrustedWritePath(candidatePath) {
12480
+ const normalized = cleanText(candidatePath || "");
12481
+ if (!normalized) {
12482
+ return true;
11052
12483
  }
11053
- if (kind === "file_event") {
11054
- const entry = timelineEntryByToken(runtime, token, kind);
11055
- return entry ? buildTimelineFileEventDetail(entry, locale) : null;
12484
+ const lower = normalized.toLowerCase();
12485
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
12486
+ const basename = segments[segments.length - 1] || "";
12487
+ if (isSensitiveCommandPath(normalized)) {
12488
+ return true;
11056
12489
  }
11057
- if (kind === "ambient_suggestions") {
11058
- const entry = timelineEntryByToken(runtime, token, kind);
11059
- if (entry) {
11060
- return buildAmbientSuggestionsDetail(entry, locale);
11061
- }
11062
- const historicalSource = (runtime.recentHistoryItems ?? []).find((item) => {
11063
- if (cleanText(item?.token || "") !== cleanText(token || "")) {
11064
- return false;
11065
- }
11066
- const itemKind = cleanText(item?.kind || "");
11067
- if (itemKind !== "assistant_final" && itemKind !== "completion") {
11068
- return false;
11069
- }
11070
- return parseAmbientSuggestionsPayload(item?.messageText ?? "") !== null;
11071
- });
11072
- if (!historicalSource) {
11073
- return null;
11074
- }
11075
- return buildAmbientSuggestionsDetail({
11076
- token: historicalSource.token,
11077
- threadId: historyItemThreadId(historicalSource),
11078
- threadLabel: historicalSource.threadLabel,
11079
- title: kindTitle(locale, "ambient_suggestions"),
11080
- createdAtMs: historicalSource.createdAtMs,
11081
- suggestions: parseAmbientSuggestionsPayload(historicalSource.messageText ?? "") ?? [],
11082
- }, locale);
12490
+ if (segments.some((segment) => [".github", ".gitlab", ".terraform", ".claude", ".husky", ".vscode"].includes(segment))) {
12491
+ return true;
11083
12492
  }
11084
- if (timelineMessageKinds.has(kind)) {
11085
- const entry = timelineEntryByToken(runtime, token, kind);
11086
- if (!entry) return null;
11087
- const detail = buildTimelineMessageDetail(entry, locale, runtime);
11088
- // Add reply support for Codex assistant_final entries only (replaces completion reply).
11089
- // Check directly against timeline entries (not history items) to avoid
11090
- // maxHistoryItems eviction issues.
11091
- if (kind === "assistant_final") {
11092
- const entryProvider = normalizeProvider(entry.provider);
11093
- if (entryProvider === "codex" && runtime?.ipcClient) {
11094
- const entryThreadId = cleanText(entry.threadId || "");
11095
- const entryTs = Number(entry.createdAtMs) || 0;
11096
- let isLatestForThread = true;
11097
- for (const other of runtime.recentTimelineEntries) {
11098
- if (other.kind === "assistant_final" &&
11099
- cleanText(other.threadId || "") === entryThreadId &&
11100
- (Number(other.createdAtMs) || 0) > entryTs) {
11101
- isLatestForThread = false;
11102
- break;
11103
- }
11104
- }
11105
- if (isLatestForThread) {
11106
- detail.reply = { enabled: true, supportsPlanMode: true, supportsImages: true };
11107
- detail.provider = entryProvider;
11108
- }
11109
- }
11110
- }
11111
- return detail;
12493
+ if (
12494
+ [
12495
+ "package.json",
12496
+ "package-lock.json",
12497
+ "pnpm-lock.yaml",
12498
+ "yarn.lock",
12499
+ "bun.lockb",
12500
+ "cargo.toml",
12501
+ "cargo.lock",
12502
+ "gemfile",
12503
+ "gemfile.lock",
12504
+ "podfile",
12505
+ "podfile.lock",
12506
+ "composer.json",
12507
+ "composer.lock",
12508
+ "pipfile",
12509
+ "pipfile.lock",
12510
+ "poetry.lock",
12511
+ "requirements.txt",
12512
+ "dockerfile",
12513
+ "wrangler.toml",
12514
+ "tsconfig.json",
12515
+ "tsconfig.tsbuildinfo",
12516
+ ].includes(basename)
12517
+ ) {
12518
+ return true;
11112
12519
  }
11113
- if (kind === "approval") {
11114
- const approval = runtime.nativeApprovalsByToken.get(token);
11115
- if (approval) {
11116
- return buildPendingApprovalDetail(runtime, approval, locale);
11117
- }
11118
- const historicalApproval = historyItemByToken(runtime, kind, token);
11119
- return historicalApproval ? buildHistoryDetail(historicalApproval, locale, runtime) : null;
12520
+ return false;
12521
+ }
12522
+
12523
+ function isAutoPilotContentWritePath(candidatePath) {
12524
+ const normalized = cleanText(candidatePath || "");
12525
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
12526
+ return false;
11120
12527
  }
11121
- if (kind === "plan") {
11122
- const planRequest = runtime.planRequestsByToken.get(token);
11123
- if (planRequest && !planRequest.resolved && !isPlanRequestExpired(planRequest)) {
11124
- return buildPendingPlanDetail(planRequest, locale);
11125
- }
11126
- const historicalPlan = historyItemByToken(runtime, kind, token);
11127
- return historicalPlan ? buildHistoryDetail(historicalPlan, locale, runtime) : null;
12528
+ const lower = normalized.toLowerCase();
12529
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
12530
+ const basename = segments[segments.length - 1] || "";
12531
+ const extension = path.extname(basename);
12532
+ if ([".md", ".mdx", ".txt", ".rst", ".adoc"].includes(extension)) {
12533
+ return true;
11128
12534
  }
11129
- if (kind === "choice") {
11130
- const userInputRequest = findLatestPersistedUserInputRequest({ config, runtime, state, token });
11131
- if (userInputRequest && !userInputRequest.resolved && !isGenericUserInputRequestExpired(userInputRequest)) {
12535
+ if (["license", "notice", "copying", "readme", "changelog", "contributing"].includes(path.basename(basename, extension))) {
12536
+ return true;
12537
+ }
12538
+ if (segments.includes("i18n") && [".js", ".ts", ".json", ".yaml", ".yml"].includes(extension)) {
12539
+ return true;
12540
+ }
12541
+ if (segments.includes("messages") && extension === ".json") {
12542
+ return true;
12543
+ }
12544
+ return false;
12545
+ }
12546
+
12547
+ function isAutoPilotUiTestWritePath(candidatePath) {
12548
+ const normalized = cleanText(candidatePath || "");
12549
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
12550
+ return false;
12551
+ }
12552
+ const lower = normalized.toLowerCase();
12553
+ const segments = lower.split(/[\\/]+/u).filter(Boolean);
12554
+ const basename = segments[segments.length - 1] || "";
12555
+ const extension = path.extname(basename);
12556
+ if ([".css", ".scss", ".sass", ".less", ".styl", ".pcss"].includes(extension)) {
12557
+ return true;
12558
+ }
12559
+ if (segments.includes("__tests__") || /\.(test|spec)\.[cm]?[jt]sx?$/u.test(basename)) {
12560
+ return true;
12561
+ }
12562
+ if ((segments.includes("web") || segments.includes("components")) && [".js", ".jsx", ".ts", ".tsx", ".html"].includes(extension)) {
12563
+ return true;
12564
+ }
12565
+ return false;
12566
+ }
12567
+
12568
+ function isAutoPilotSourceWritePath(candidatePath) {
12569
+ const normalized = cleanText(candidatePath || "");
12570
+ if (!normalized || isDeniedTrustedWritePath(normalized)) {
12571
+ return false;
12572
+ }
12573
+ if (isAutoPilotContentWritePath(normalized) || isAutoPilotUiTestWritePath(normalized)) {
12574
+ return false;
12575
+ }
12576
+ const extension = path.extname(normalized.toLowerCase());
12577
+ return [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"].includes(extension);
12578
+ }
12579
+
12580
+ function extractAddedDiffLines(diffText) {
12581
+ return normalizeTimelineDiffText(diffText)
12582
+ .split("\n")
12583
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"));
12584
+ }
12585
+
12586
+ function addedDiffLinesContain(diffText, pattern) {
12587
+ return extractAddedDiffLines(diffText).some((line) => pattern.test(line.slice(1)));
12588
+ }
12589
+
12590
+ function hasUnsafeUiOrTestWriteDiff(diffText) {
12591
+ const patterns = [
12592
+ /\bprocess\.env\b/u,
12593
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
12594
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
12595
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
12596
+ /\bcrypto\b/u,
12597
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
12598
+ ];
12599
+ return (
12600
+ addedDiffLinesContain(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
12601
+ addedDiffLinesContain(diffText, /\brequire\s*\(/u) ||
12602
+ patterns.some((pattern) => addedDiffLinesContain(diffText, pattern))
12603
+ );
12604
+ }
12605
+
12606
+ function hasUnsafeSourceWriteDiff(diffText) {
12607
+ const patterns = [
12608
+ /\bprocess\.env\b/u,
12609
+ /\b(?:child_process|spawn|exec|execFile|fork)\b/u,
12610
+ /\bfs\.(?:write|append|rm|unlink|rename|chmod|chown|copyFile|cp)\b/u,
12611
+ /\b(?:fetch|axios|XMLHttpRequest)\s*\(/u,
12612
+ /\b(?:net|tls|dgram|http2?)\b/u,
12613
+ /\bcrypto\b/u,
12614
+ /\b(?:secret|token|password|privateKey|credential)\b/iu,
12615
+ ];
12616
+ return (
12617
+ addedDiffLinesContain(diffText, /^\s*(?:import\s|export\s+.*\s+from\s)/u) ||
12618
+ addedDiffLinesContain(diffText, /\brequire\s*\(/u) ||
12619
+ patterns.some((pattern) => addedDiffLinesContain(diffText, pattern))
12620
+ );
12621
+ }
12622
+
12623
+ const AUTO_PILOT_SOURCE_CONTINUITY_WINDOW_MS = 15 * 60 * 1000;
12624
+
12625
+ function isReadContinuityTimelineEntry(entry) {
12626
+ if (!isPlainObject(entry)) {
12627
+ return false;
12628
+ }
12629
+ if (cleanText(entry.kind || "") === "file_event" && cleanText(entry.fileEventType || "") === "read") {
12630
+ return true;
12631
+ }
12632
+ return (
12633
+ cleanText(entry.kind || "") === "approval" &&
12634
+ cleanText(entry.outcome || "") === "approved" &&
12635
+ !normalizeTimelineDiffText(entry.diffText ?? "") &&
12636
+ normalizeTimelineFileRefs(entry.fileRefs ?? []).length > 0
12637
+ );
12638
+ }
12639
+
12640
+ function resolveRecentReadContinuityFileRefs({ entry, cwd, workspaceRoot }) {
12641
+ const normalizedRefs = normalizeTimelineFileRefs(entry?.fileRefs ?? []);
12642
+ if (normalizedRefs.length === 0) {
12643
+ return [];
12644
+ }
12645
+ const resolvedRefs = [];
12646
+ for (const fileRef of normalizedRefs) {
12647
+ const resolved = resolveTrustedReadTargetPath({
12648
+ token: fileRef,
12649
+ cwd,
12650
+ workspaceRoot,
12651
+ });
12652
+ if (resolved) {
12653
+ resolvedRefs.push(resolved);
12654
+ }
12655
+ }
12656
+ return normalizeTimelineFileRefs(resolvedRefs);
12657
+ }
12658
+
12659
+ function hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs }) {
12660
+ const normalizedTargets = normalizeTimelineFileRefs(resolvedFileRefs ?? []);
12661
+ if (normalizedTargets.length !== 1) {
12662
+ return false;
12663
+ }
12664
+ const targetFileRef = normalizedTargets[0];
12665
+ const conversationId = cleanText(approval?.conversationId || "");
12666
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
12667
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
12668
+ if (!cwd || !workspaceRoot) {
12669
+ return false;
12670
+ }
12671
+ const timelineEntries =
12672
+ Array.isArray(runtime?.recentTimelineEntries) && runtime.recentTimelineEntries.length > 0
12673
+ ? runtime.recentTimelineEntries
12674
+ : normalizeTimelineEntries(state?.recentTimelineEntries ?? [], 40);
12675
+ const now = Date.now();
12676
+ for (const entry of timelineEntries) {
12677
+ if (!isReadContinuityTimelineEntry(entry)) {
12678
+ continue;
12679
+ }
12680
+ const createdAtMs = Number(entry?.createdAtMs) || 0;
12681
+ if (!createdAtMs || now - createdAtMs > AUTO_PILOT_SOURCE_CONTINUITY_WINDOW_MS) {
12682
+ continue;
12683
+ }
12684
+ const entryThreadId = cleanText(entry?.threadId || extractConversationIdFromStableId(entry?.stableId) || "");
12685
+ if (conversationId && entryThreadId && entryThreadId !== conversationId) {
12686
+ continue;
12687
+ }
12688
+ const entryFileRefs = resolveRecentReadContinuityFileRefs({
12689
+ entry,
12690
+ cwd,
12691
+ workspaceRoot,
12692
+ });
12693
+ if (entryFileRefs.includes(targetFileRef)) {
12694
+ return true;
12695
+ }
12696
+ }
12697
+ return false;
12698
+ }
12699
+
12700
+ function classifyTrustedWriteLane({ runtime, state, approval, resolvedFileRefs, diffText, totalChangedLines }) {
12701
+ const lanes = autoPilotWriteLaneState(state);
12702
+ if (
12703
+ lanes.content &&
12704
+ resolvedFileRefs.length >= 1 &&
12705
+ resolvedFileRefs.length <= 3 &&
12706
+ totalChangedLines <= 120 &&
12707
+ resolvedFileRefs.every((fileRef) => isAutoPilotContentWritePath(fileRef))
12708
+ ) {
12709
+ return "content";
12710
+ }
12711
+ if (
12712
+ lanes.uiTests &&
12713
+ resolvedFileRefs.length >= 1 &&
12714
+ resolvedFileRefs.length <= 2 &&
12715
+ totalChangedLines <= 80 &&
12716
+ resolvedFileRefs.every((fileRef) => isAutoPilotUiTestWritePath(fileRef)) &&
12717
+ !hasUnsafeUiOrTestWriteDiff(diffText)
12718
+ ) {
12719
+ return "ui_tests";
12720
+ }
12721
+ if (
12722
+ lanes.source &&
12723
+ resolvedFileRefs.length === 1 &&
12724
+ totalChangedLines <= 40 &&
12725
+ resolvedFileRefs.every((fileRef) => isAutoPilotSourceWritePath(fileRef)) &&
12726
+ !hasUnsafeSourceWriteDiff(diffText) &&
12727
+ hasRecentSourceReadContinuity({ runtime, state, approval, resolvedFileRefs })
12728
+ ) {
12729
+ return "source";
12730
+ }
12731
+ return "";
12732
+ }
12733
+
12734
+ function resolveTrustedWriteFileRefs({ fileRefs, cwd, workspaceRoot }) {
12735
+ const normalizedRefs = normalizeTimelineFileRefs(fileRefs ?? []);
12736
+ if (normalizedRefs.length === 0 || normalizedRefs.length > 3) {
12737
+ return null;
12738
+ }
12739
+ const resolvedRefs = [];
12740
+ for (const fileRef of normalizedRefs) {
12741
+ const resolved = resolveTrustedReadTargetPath({
12742
+ token: fileRef,
12743
+ cwd,
12744
+ workspaceRoot,
12745
+ });
12746
+ if (!resolved || isDeniedTrustedWritePath(resolved)) {
12747
+ return null;
12748
+ }
12749
+ resolvedRefs.push(resolved);
12750
+ }
12751
+ return normalizeTimelineFileRefs(resolvedRefs);
12752
+ }
12753
+
12754
+ function sameNormalizedFileRefSet(leftRefs, rightRefs) {
12755
+ const left = normalizeTimelineFileRefs(leftRefs ?? []);
12756
+ const right = normalizeTimelineFileRefs(rightRefs ?? []);
12757
+ if (left.length !== right.length) {
12758
+ return false;
12759
+ }
12760
+ const rightSet = new Set(right);
12761
+ return left.every((value) => rightSet.has(value));
12762
+ }
12763
+
12764
+ function resolveTrustedWriteDiffSectionFileRefs({ diffSections, cwd, workspaceRoot }) {
12765
+ const resolvedSections = [];
12766
+ for (const section of diffSections) {
12767
+ const paths = extractUnifiedDiffSectionPaths(section);
12768
+ const sectionFileRef = cleanText(paths.newFileRef || paths.oldFileRef || "");
12769
+ if (!sectionFileRef) {
12770
+ return null;
12771
+ }
12772
+ if (paths.oldFileRef && paths.newFileRef && cleanText(paths.oldFileRef) !== cleanText(paths.newFileRef)) {
12773
+ return null;
12774
+ }
12775
+ const resolved = resolveTrustedDiffSectionPath({
12776
+ token: sectionFileRef,
12777
+ cwd,
12778
+ workspaceRoot,
12779
+ });
12780
+ if (!resolved || isDeniedTrustedWritePath(resolved)) {
12781
+ return null;
12782
+ }
12783
+ resolvedSections.push(resolved);
12784
+ }
12785
+ return normalizeTimelineFileRefs(resolvedSections);
12786
+ }
12787
+
12788
+ function evaluateTrustedWriteApprovalCandidate({ runtime, state, approval }) {
12789
+ const cwd = cleanText(approval?.cwd || approval?.rawParams?.cwd || approval?.rawParams?.grantRoot || "");
12790
+ const workspaceRoot = cleanText(approval?.workspaceRoot || approval?.rawParams?.grantRoot || cwd);
12791
+ const resolvedFileRefs = resolveTrustedWriteFileRefs({
12792
+ fileRefs: approval?.fileRefs ?? [],
12793
+ cwd,
12794
+ workspaceRoot,
12795
+ });
12796
+ const diffText = normalizeTimelineDiffText(approval?.diffText ?? "");
12797
+ if (!cwd || !workspaceRoot || !resolvedFileRefs || !diffText) {
12798
+ return null;
12799
+ }
12800
+ const counts = diffLineCounts(diffText);
12801
+ const totalChangedLines =
12802
+ Math.max(0, Number(counts.addedLines) || 0) +
12803
+ Math.max(0, Number(counts.removedLines) || 0);
12804
+ if (totalChangedLines === 0 || totalChangedLines > 120) {
12805
+ return null;
12806
+ }
12807
+ const diffSections = splitUnifiedDiffTextByFile(diffText);
12808
+ if (diffSections.length === 0 || diffSections.length > 3) {
12809
+ return null;
12810
+ }
12811
+ if (
12812
+ /^(?: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)
12813
+ ) {
12814
+ return null;
12815
+ }
12816
+ const diffSectionRefs = resolveTrustedWriteDiffSectionFileRefs({
12817
+ diffSections,
12818
+ cwd,
12819
+ workspaceRoot,
12820
+ });
12821
+ if (!diffSectionRefs || !sameNormalizedFileRefSet(diffSectionRefs, resolvedFileRefs)) {
12822
+ return null;
12823
+ }
12824
+ const lane = classifyTrustedWriteLane({
12825
+ runtime,
12826
+ state: state ?? approval?.stateSnapshot ?? null,
12827
+ approval,
12828
+ resolvedFileRefs: diffSectionRefs,
12829
+ diffText,
12830
+ totalChangedLines,
12831
+ });
12832
+ if (!lane) {
12833
+ return null;
12834
+ }
12835
+ return {
12836
+ fileRefs: diffSectionRefs,
12837
+ diffText,
12838
+ diffAddedLines: Math.max(0, Number(counts.addedLines) || 0),
12839
+ diffRemovedLines: Math.max(0, Number(counts.removedLines) || 0),
12840
+ totalChangedLines,
12841
+ lane,
12842
+ };
12843
+ }
12844
+
12845
+ function maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval }) {
12846
+ if (!isAutoPilotTrustedWritesEnabled(state)) {
12847
+ return null;
12848
+ }
12849
+ if (cleanText(approval?.kind || "") !== "file") {
12850
+ return null;
12851
+ }
12852
+ return evaluateTrustedWriteApprovalCandidate({
12853
+ runtime,
12854
+ state,
12855
+ approval: { ...approval, stateSnapshot: state },
12856
+ });
12857
+ }
12858
+
12859
+ function autoPilotTrustedWriteMessage(locale, approval, candidate) {
12860
+ const prefix = t(locale, "server.message.autoPilotTrustedWriteApproved");
12861
+ const fileBlock = candidate?.fileRefs?.length
12862
+ ? `${t(locale, "server.message.filesLabel")}\n\`\`\`text\n${candidate.fileRefs.join("\n")}\n\`\`\``
12863
+ : "";
12864
+ const sizeLine =
12865
+ candidate && Number.isFinite(candidate.diffAddedLines) && Number.isFinite(candidate.diffRemovedLines)
12866
+ ? `${t(locale, "server.message.diffSummaryLabel")} +${candidate.diffAddedLines} / -${candidate.diffRemovedLines}`
12867
+ : "";
12868
+ const originalMessage = cleanText(approval?.messageText || "");
12869
+ return [prefix, sizeLine, fileBlock, originalMessage].filter(Boolean).join("\n\n");
12870
+ }
12871
+
12872
+ async function recordAutoApprovedTrustedRead({
12873
+ config,
12874
+ runtime,
12875
+ state,
12876
+ approval,
12877
+ policyMatch,
12878
+ }) {
12879
+ const stateChanged = recordActionHistoryItem({
12880
+ config,
12881
+ runtime,
12882
+ state,
12883
+ kind: "approval",
12884
+ stableId: `approval:${approval.requestKey}:autopilot`,
12885
+ token: approval.token,
12886
+ title: approval.title,
12887
+ threadLabel: approval.threadLabel || "",
12888
+ messageText: autoPilotApprovalMessage(config.defaultLocale, policyMatch.commandText),
12889
+ summary: t(config.defaultLocale, "server.message.autoPilotTrustedReadSummary"),
12890
+ fileRefs: normalizeTimelineFileRefs(policyMatch.fileRefs ?? approval.fileRefs ?? []),
12891
+ diffText: "",
12892
+ diffSource: "",
12893
+ diffAvailable: false,
12894
+ diffAddedLines: 0,
12895
+ diffRemovedLines: 0,
12896
+ outcome: "approved",
12897
+ provider: approval.provider || "codex",
12898
+ });
12899
+ if (stateChanged) {
12900
+ await saveState(config.stateFile, state);
12901
+ }
12902
+ }
12903
+
12904
+ async function recordAutoApprovedTrustedWrite({
12905
+ config,
12906
+ runtime,
12907
+ state,
12908
+ approval,
12909
+ candidate,
12910
+ }) {
12911
+ const stateChanged = recordActionHistoryItem({
12912
+ config,
12913
+ runtime,
12914
+ state,
12915
+ kind: "approval",
12916
+ stableId: `approval:${approval.requestKey}:autopilot-write:${candidate.lane || "content"}`,
12917
+ token: approval.token,
12918
+ title: approval.title,
12919
+ threadLabel: approval.threadLabel || "",
12920
+ messageText: autoPilotTrustedWriteMessage(config.defaultLocale, approval, candidate),
12921
+ summary: t(config.defaultLocale, "server.message.autoPilotTrustedWriteSummary"),
12922
+ fileRefs: candidate.fileRefs,
12923
+ diffText: candidate.diffText,
12924
+ diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
12925
+ diffAvailable: true,
12926
+ diffAddedLines: candidate.diffAddedLines,
12927
+ diffRemovedLines: candidate.diffRemovedLines,
12928
+ outcome: "approved",
12929
+ provider: approval.provider || "codex",
12930
+ });
12931
+ if (stateChanged) {
12932
+ await saveState(config.stateFile, state);
12933
+ }
12934
+ }
12935
+
12936
+ async function autoApproveTrustedRead({
12937
+ config,
12938
+ runtime,
12939
+ state,
12940
+ approval,
12941
+ policyMatch,
12942
+ }) {
12943
+ if (approval.provider === "claude" && approval.resolveClaudeWaiter) {
12944
+ approval.resolveClaudeWaiter({
12945
+ permissionDecision: "allow",
12946
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedReadReason"),
12947
+ });
12948
+ } else if (approval.provider !== "claude") {
12949
+ await runtime.ipcClient?.sendApprovalDecision(approval, "accept");
12950
+ }
12951
+ approval.resolved = true;
12952
+ approval.resolving = false;
12953
+ runtime.nativeApprovalsByToken.delete(approval.token);
12954
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
12955
+ await recordAutoApprovedTrustedRead({
12956
+ config,
12957
+ runtime,
12958
+ state,
12959
+ approval,
12960
+ policyMatch,
12961
+ });
12962
+ console.log(`[auto-pilot-approved] ${approval.requestKey} | ${policyMatch.command}`);
12963
+ }
12964
+
12965
+ async function autoApproveTrustedWrite({
12966
+ config,
12967
+ runtime,
12968
+ state,
12969
+ approval,
12970
+ candidate,
12971
+ }) {
12972
+ if (approval.provider === "claude" && approval.resolveClaudeWaiter) {
12973
+ approval.resolveClaudeWaiter({
12974
+ permissionDecision: "allow",
12975
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedWriteReason"),
12976
+ });
12977
+ } else if (approval.provider !== "claude") {
12978
+ await runtime.ipcClient?.sendApprovalDecision(approval, "accept");
12979
+ }
12980
+ approval.resolved = true;
12981
+ approval.resolving = false;
12982
+ runtime.nativeApprovalsByToken.delete(approval.token);
12983
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
12984
+ await recordAutoApprovedTrustedWrite({
12985
+ config,
12986
+ runtime,
12987
+ state,
12988
+ approval,
12989
+ candidate,
12990
+ });
12991
+ console.log(`[auto-pilot-approved-write] ${approval.requestKey} | files=${candidate.fileRefs.length} | lines=${candidate.totalChangedLines}`);
12992
+ }
12993
+
12994
+ async function handleNativeApprovalDecision({ config, runtime, state, approval, decision }) {
12995
+ if (approval.resolveClaudeWaiter) {
12996
+ if (approval.provider === "claude" && approval.kind === "plan") {
12997
+ // ExitPlanMode cannot be truly auto-approved via permissionDecision: "allow"
12998
+ // (Claude still shows the native PC plan dialog). Instead, deny the tool
12999
+ // call with a reason that tells Claude the user already decided on mobile.
13000
+ approval.resolveClaudeWaiter({
13001
+ permissionDecision: "deny",
13002
+ permissionDecisionReason:
13003
+ decision === "accept"
13004
+ ? "User approved the plan via the viveworker mobile app. Proceed with implementing the plan exactly as proposed and do not call ExitPlanMode again."
13005
+ : "User rejected the plan via the viveworker mobile app. Revise the plan based on the conversation context before calling ExitPlanMode again.",
13006
+ });
13007
+ } else {
13008
+ approval.resolveClaudeWaiter(decision);
13009
+ }
13010
+ } else if (approval.provider === "claude") {
13011
+ // notifyOnly Claude approvals have no long-poll waiter and no ipcClient
13012
+ // back-channel — the desktop user already answered. Nothing to send.
13013
+ } else {
13014
+ await runtime.ipcClient?.sendApprovalDecision(approval, decision);
13015
+ }
13016
+ approval.resolved = true;
13017
+ approval.resolving = false;
13018
+ runtime.nativeApprovalsByToken.delete(approval.token);
13019
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
13020
+ const stateChanged = recordActionHistoryItem({
13021
+ config,
13022
+ runtime,
13023
+ state,
13024
+ kind: "approval",
13025
+ stableId: `approval:${approval.requestKey}:${Date.now()}`,
13026
+ token: approval.token,
13027
+ title: approval.title,
13028
+ threadLabel: approval.threadLabel || "",
13029
+ messageText: `${approvalDecisionMessage(decision, config.defaultLocale, approval.provider)}\n\n${approval.messageText}`,
13030
+ summary: formatNotificationBody(approval.messageText, 160) || approvalDecisionMessage(decision, config.defaultLocale, approval.provider),
13031
+ fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
13032
+ diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
13033
+ diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
13034
+ diffAvailable: approval.diffAvailable === true || Boolean(approval.diffText),
13035
+ diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
13036
+ diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
13037
+ outcome: decision === "accept" ? "approved" : "rejected",
13038
+ provider: approval.provider || "codex",
13039
+ });
13040
+ if (stateChanged) {
13041
+ await saveState(config.stateFile, state);
13042
+ }
13043
+ console.log(`[native-decision] ${approval.requestKey} | ${decision}`);
13044
+ }
13045
+
13046
+ async function buildApiItemDetail({ config, runtime, state, kind, token, locale }) {
13047
+ if (kind === "diff_thread") {
13048
+ const group = (await getDiffThreadGroupsCached(runtime, state, config)).find((entry) => entry.token === token);
13049
+ return group ? buildDiffThreadDetail(group, locale) : null;
13050
+ }
13051
+ if (kind === "file_event") {
13052
+ const entry = timelineEntryByToken(runtime, token, kind);
13053
+ return entry ? buildTimelineFileEventDetail(entry, locale) : null;
13054
+ }
13055
+ if (kind === "ambient_suggestions") {
13056
+ const entry = timelineEntryByToken(runtime, token, kind);
13057
+ if (entry) {
13058
+ return buildAmbientSuggestionsDetail(entry, locale);
13059
+ }
13060
+ const historicalSource = (runtime.recentHistoryItems ?? []).find((item) => {
13061
+ if (cleanText(item?.token || "") !== cleanText(token || "")) {
13062
+ return false;
13063
+ }
13064
+ const itemKind = cleanText(item?.kind || "");
13065
+ if (itemKind !== "assistant_final" && itemKind !== "completion") {
13066
+ return false;
13067
+ }
13068
+ return parseAmbientSuggestionsPayload(item?.messageText ?? "") !== null;
13069
+ });
13070
+ if (!historicalSource) {
13071
+ return null;
13072
+ }
13073
+ return buildAmbientSuggestionsDetail({
13074
+ token: historicalSource.token,
13075
+ threadId: historyItemThreadId(historicalSource),
13076
+ threadLabel: historicalSource.threadLabel,
13077
+ title: kindTitle(locale, "ambient_suggestions"),
13078
+ createdAtMs: historicalSource.createdAtMs,
13079
+ suggestions: parseAmbientSuggestionsPayload(historicalSource.messageText ?? "") ?? [],
13080
+ }, locale);
13081
+ }
13082
+ if (timelineMessageKinds.has(kind)) {
13083
+ const entry = timelineEntryByToken(runtime, token, kind);
13084
+ if (!entry) return null;
13085
+ const detail = buildTimelineMessageDetail(entry, locale, runtime);
13086
+ // Add reply support for Codex assistant_final entries only (replaces completion reply).
13087
+ // Check directly against timeline entries (not history items) to avoid
13088
+ // maxHistoryItems eviction issues.
13089
+ if (kind === "assistant_final") {
13090
+ const entryProvider = normalizeProvider(entry.provider);
13091
+ if (entryProvider === "codex" && runtime?.ipcClient) {
13092
+ const entryThreadId = cleanText(entry.threadId || "");
13093
+ const entryTs = Number(entry.createdAtMs) || 0;
13094
+ let isLatestForThread = true;
13095
+ for (const other of runtime.recentTimelineEntries) {
13096
+ if (other.kind === "assistant_final" &&
13097
+ cleanText(other.threadId || "") === entryThreadId &&
13098
+ (Number(other.createdAtMs) || 0) > entryTs) {
13099
+ isLatestForThread = false;
13100
+ break;
13101
+ }
13102
+ }
13103
+ if (isLatestForThread) {
13104
+ detail.reply = { enabled: true, supportsPlanMode: true, supportsImages: true };
13105
+ detail.provider = entryProvider;
13106
+ }
13107
+ }
13108
+ }
13109
+ return detail;
13110
+ }
13111
+ if (kind === "approval") {
13112
+ const approval = runtime.nativeApprovalsByToken.get(token);
13113
+ if (approval) {
13114
+ return buildPendingApprovalDetail(runtime, state, approval, locale);
13115
+ }
13116
+ const historicalApproval = historyItemByToken(runtime, kind, token);
13117
+ return historicalApproval ? buildHistoryDetail(historicalApproval, locale, runtime) : null;
13118
+ }
13119
+ if (kind === "plan") {
13120
+ const planRequest = runtime.planRequestsByToken.get(token);
13121
+ if (planRequest && !planRequest.resolved && !isPlanRequestExpired(planRequest)) {
13122
+ return buildPendingPlanDetail(planRequest, locale);
13123
+ }
13124
+ const historicalPlan = historyItemByToken(runtime, kind, token);
13125
+ return historicalPlan ? buildHistoryDetail(historicalPlan, locale, runtime) : null;
13126
+ }
13127
+ if (kind === "choice") {
13128
+ const userInputRequest = findLatestPersistedUserInputRequest({ config, runtime, state, token });
13129
+ if (userInputRequest && !userInputRequest.resolved && !isGenericUserInputRequestExpired(userInputRequest)) {
11132
13130
  return buildChoiceDetail(userInputRequest, config, locale);
11133
13131
  }
11134
13132
  const historicalChoice = historyItemByToken(runtime, kind, token);
@@ -11343,21 +13341,81 @@ function contentTypeForFile(filePath) {
11343
13341
  }
11344
13342
  }
11345
13343
 
11346
- async function serveWebAsset(res, urlPath) {
13344
+ // Per-file cache of {raw, gzip, mtimeMs}. Gzip of the ~464KB app shell
13345
+ // (app.js 278KB + app.css 76KB + i18n.js 110KB) takes a few ms to compute,
13346
+ // and without this the bridge would recompress on every request. Keyed by
13347
+ // resolved filePath so /app and /index.html share a cache slot.
13348
+ const WEB_ASSET_CACHE = new Map();
13349
+ const GZIPPABLE_EXTENSIONS = new Set([
13350
+ ".js",
13351
+ ".mjs",
13352
+ ".css",
13353
+ ".html",
13354
+ ".htm",
13355
+ ".json",
13356
+ ".webmanifest",
13357
+ ".svg",
13358
+ ".txt",
13359
+ ".map",
13360
+ ]);
13361
+ const MIN_GZIP_BYTES = 512;
13362
+
13363
+ async function loadWebAssetEntry(filePath) {
13364
+ const stat = await fs.stat(filePath);
13365
+ const cached = WEB_ASSET_CACHE.get(filePath);
13366
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
13367
+ return cached;
13368
+ }
13369
+ const raw = await fs.readFile(filePath);
13370
+ const extension = path.extname(filePath).toLowerCase();
13371
+ let gzip = null;
13372
+ if (GZIPPABLE_EXTENSIONS.has(extension) && raw.length >= MIN_GZIP_BYTES) {
13373
+ try {
13374
+ gzip = zlib.gzipSync(raw, { level: 6 });
13375
+ } catch {
13376
+ gzip = null;
13377
+ }
13378
+ }
13379
+ const entry = {
13380
+ raw,
13381
+ gzip,
13382
+ mtimeMs: stat.mtimeMs,
13383
+ size: stat.size,
13384
+ contentType: contentTypeForFile(filePath),
13385
+ };
13386
+ WEB_ASSET_CACHE.set(filePath, entry);
13387
+ return entry;
13388
+ }
13389
+
13390
+ function clientAcceptsGzip(req) {
13391
+ const header = req?.headers?.["accept-encoding"];
13392
+ if (!header) return false;
13393
+ const value = Array.isArray(header) ? header.join(",") : String(header);
13394
+ return /\bgzip\b/i.test(value);
13395
+ }
13396
+
13397
+ async function serveWebAsset(res, urlPath, req) {
11347
13398
  const filePath = resolveWebAsset(urlPath);
11348
13399
  if (!filePath) {
11349
13400
  return false;
11350
13401
  }
11351
13402
 
11352
13403
  try {
11353
- const body = await fs.readFile(filePath);
13404
+ const entry = await loadWebAssetEntry(filePath);
13405
+ const wantsGzip = entry.gzip && clientAcceptsGzip(req);
11354
13406
  res.statusCode = 200;
11355
- res.setHeader("Content-Type", contentTypeForFile(filePath));
13407
+ res.setHeader("Content-Type", entry.contentType);
11356
13408
  res.setHeader("Cache-Control", "no-store, max-age=0");
11357
13409
  if (urlPath === "/sw.js") {
11358
13410
  res.setHeader("Service-Worker-Allowed", "/");
11359
13411
  }
11360
- res.end(body);
13412
+ if (wantsGzip) {
13413
+ res.setHeader("Content-Encoding", "gzip");
13414
+ res.setHeader("Vary", "Accept-Encoding");
13415
+ res.end(entry.gzip);
13416
+ } else {
13417
+ res.end(entry.raw);
13418
+ }
11361
13419
  return true;
11362
13420
  } catch {
11363
13421
  return false;
@@ -11489,10 +13547,11 @@ function createNativeApprovalServer({ config, runtime, state }) {
11489
13547
  url.pathname === "/app.js" ||
11490
13548
  url.pathname === "/app.css" ||
11491
13549
  url.pathname === "/i18n.js" ||
13550
+ url.pathname === "/hazbase-passkey.js" ||
11492
13551
  url.pathname === "/sw.js" ||
11493
13552
  url.pathname.startsWith("/icons/")
11494
13553
  ) {
11495
- const served = await serveWebAsset(res, url.pathname);
13554
+ const served = await serveWebAsset(res, url.pathname, req);
11496
13555
  if (served) {
11497
13556
  return;
11498
13557
  }
@@ -11632,16 +13691,47 @@ function createNativeApprovalServer({ config, runtime, state }) {
11632
13691
  if (session.authenticated && session.restoredFromDevice) {
11633
13692
  setSessionCookie(res, config);
11634
13693
  }
13694
+ return writeJson(res, 200, buildSessionPayload({ config, state, session }));
13695
+ }
13696
+
13697
+ if (url.pathname === "/api/version/status" && req.method === "GET") {
13698
+ const session = requireApiSession(req, res, config, state);
13699
+ if (!session) return;
13700
+ return writeJson(res, 200, await getNpmVersionStatus(runtime, config));
13701
+ }
13702
+
13703
+ // Collapses the PWA's boot-time fan-out (session + inbox + timeline
13704
+ // + devices) into a single HTTPS round-trip. iOS Safari tears down
13705
+ // connections aggressively, so each parallel fetch pays its own
13706
+ // TLS handshake — and `JSON.stringify(state)` hiccups on other
13707
+ // requests make those handshakes bursty. One endpoint = one
13708
+ // handshake + one response, which drops the "Completed list is
13709
+ // blank for several seconds after launch" latency floor.
13710
+ if (url.pathname === "/api/bootstrap" && req.method === "GET") {
13711
+ const session = readSession(req, config, state);
13712
+ const localeInfo = resolveDeviceLocaleInfo(config, state, session.deviceId);
13713
+ if (session.authenticated && session.deviceId) {
13714
+ const trustChanged = touchDeviceTrust(state, config, session.deviceId);
13715
+ const metadataChanged = updateCurrentDeviceSnapshot(state, config, session.deviceId, {
13716
+ userAgent: requestUserAgent(req),
13717
+ lastLocale: localeInfo.locale,
13718
+ });
13719
+ if (trustChanged || metadataChanged) {
13720
+ await saveState(config.stateFile, state);
13721
+ }
13722
+ }
13723
+ if (session.authenticated && session.restoredFromDevice) {
13724
+ setSessionCookie(res, config);
13725
+ }
13726
+ const sessionPayload = buildSessionPayload({ config, state, session });
13727
+ if (!session.authenticated) {
13728
+ return writeJson(res, 200, { session: sessionPayload });
13729
+ }
11635
13730
  return writeJson(res, 200, {
11636
- authenticated: Boolean(session.authenticated),
11637
- expiresAtMs: Number(session.expiresAtMs) || 0,
11638
- pairingAvailable: isPairingAvailableForState(config, state),
11639
- webPushEnabled: config.webPushEnabled,
11640
- httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
11641
- appVersion: appPackageVersion,
11642
- deviceId: session.deviceId || null,
11643
- temporaryPairing: session.temporaryPairing === true,
11644
- ...buildSessionLocalePayload(config, state, session.deviceId),
13731
+ session: sessionPayload,
13732
+ inbox: buildInboxFastResponse(runtime, state, config, localeInfo.locale),
13733
+ timeline: buildTimelineResponse(runtime, state, config, localeInfo.locale),
13734
+ devices: buildDevicesResponse({ config, state, session, locale: localeInfo.locale }),
11645
13735
  });
11646
13736
  }
11647
13737
 
@@ -11756,7 +13846,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11756
13846
  lastLocale: resolveDeviceLocaleInfo(config, state, session.deviceId).locale,
11757
13847
  }) || changed;
11758
13848
  if (changed) {
11759
- await saveState(config.stateFile, state);
13849
+ scheduleSaveState(config, state);
11760
13850
  }
11761
13851
  return writeJson(res, 200, {
11762
13852
  ok: true,
@@ -11778,12 +13868,80 @@ function createNativeApprovalServer({ config, runtime, state }) {
11778
13868
  const enabled = payload?.enabled === true;
11779
13869
  if (state.claudeAwayMode !== enabled) {
11780
13870
  state.claudeAwayMode = enabled;
11781
- await saveState(config.stateFile, state);
13871
+ scheduleSaveState(config, state);
11782
13872
  }
11783
13873
  await syncClaudeAwayModeSentinel(config, enabled);
11784
13874
  return writeJson(res, 200, { ok: true, enabled });
11785
13875
  }
11786
13876
 
13877
+ if (url.pathname === "/api/settings/auto-pilot" && req.method === "POST") {
13878
+ const session = requireMutatingApiSession(req, res, config, state);
13879
+ if (!session) {
13880
+ return;
13881
+ }
13882
+ let payload;
13883
+ try {
13884
+ payload = await parseJsonBody(req);
13885
+ } catch {
13886
+ return writeJson(res, 400, { error: "invalid-json-body" });
13887
+ }
13888
+ const trustedReadsEnabled =
13889
+ typeof payload?.trustedReadsEnabled === "boolean"
13890
+ ? payload.trustedReadsEnabled === true
13891
+ : state.autoPilotTrustedReads === true;
13892
+ const hasLaneUpdate =
13893
+ typeof payload?.writeLaneContentEnabled === "boolean" ||
13894
+ typeof payload?.writeLaneUiTestsEnabled === "boolean" ||
13895
+ typeof payload?.writeLaneSourceEnabled === "boolean";
13896
+ const legacyTrustedWritesEnabled =
13897
+ typeof payload?.trustedWritesEnabled === "boolean"
13898
+ ? payload.trustedWritesEnabled === true
13899
+ : typeof payload?.trustedWritesShadowEnabled === "boolean"
13900
+ ? payload.trustedWritesShadowEnabled === true
13901
+ : null;
13902
+ const currentLanes = autoPilotWriteLaneState(state);
13903
+ const writeLaneContentEnabled =
13904
+ typeof payload?.writeLaneContentEnabled === "boolean"
13905
+ ? payload.writeLaneContentEnabled === true
13906
+ : !hasLaneUpdate && legacyTrustedWritesEnabled != null
13907
+ ? legacyTrustedWritesEnabled
13908
+ : currentLanes.content;
13909
+ const writeLaneUiTestsEnabled =
13910
+ typeof payload?.writeLaneUiTestsEnabled === "boolean"
13911
+ ? payload.writeLaneUiTestsEnabled === true
13912
+ : currentLanes.uiTests;
13913
+ const writeLaneSourceEnabled =
13914
+ typeof payload?.writeLaneSourceEnabled === "boolean"
13915
+ ? payload.writeLaneSourceEnabled === true
13916
+ : currentLanes.source;
13917
+ const trustedWritesEnabled =
13918
+ writeLaneContentEnabled || writeLaneUiTestsEnabled || writeLaneSourceEnabled;
13919
+ if (
13920
+ state.autoPilotTrustedReads !== trustedReadsEnabled ||
13921
+ state.autoPilotWriteLaneContent !== writeLaneContentEnabled ||
13922
+ state.autoPilotWriteLaneUiTests !== writeLaneUiTestsEnabled ||
13923
+ state.autoPilotWriteLaneSource !== writeLaneSourceEnabled ||
13924
+ isAutoPilotTrustedWritesEnabled(state) !== trustedWritesEnabled
13925
+ ) {
13926
+ state.autoPilotTrustedReads = trustedReadsEnabled;
13927
+ state.autoPilotTrustedWrites = trustedWritesEnabled;
13928
+ state.autoPilotTrustedWritesShadow = false;
13929
+ state.autoPilotWriteLaneContent = writeLaneContentEnabled;
13930
+ state.autoPilotWriteLaneUiTests = writeLaneUiTestsEnabled;
13931
+ state.autoPilotWriteLaneSource = writeLaneSourceEnabled;
13932
+ await saveState(config.stateFile, state);
13933
+ }
13934
+ return writeJson(res, 200, {
13935
+ ok: true,
13936
+ trustedReadsEnabled,
13937
+ trustedWritesEnabled,
13938
+ trustedWritesShadowEnabled: false,
13939
+ writeLaneContentEnabled,
13940
+ writeLaneUiTestsEnabled,
13941
+ writeLaneSourceEnabled,
13942
+ });
13943
+ }
13944
+
11787
13945
  if (url.pathname === "/api/settings/a2a-executor" && req.method === "POST") {
11788
13946
  const session = requireMutatingApiSession(req, res, config, state);
11789
13947
  if (!session) return;
@@ -11797,7 +13955,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11797
13955
  const preference = valid.has(payload?.preference) ? payload.preference : "auto";
11798
13956
  if (state.a2aExecutorPreference !== preference) {
11799
13957
  state.a2aExecutorPreference = preference;
11800
- await saveState(config.stateFile, state);
13958
+ scheduleSaveState(config, state);
11801
13959
  }
11802
13960
  return writeJson(res, 200, { ok: true, preference });
11803
13961
  }
@@ -11967,6 +14125,314 @@ function createNativeApprovalServer({ config, runtime, state }) {
11967
14125
  });
11968
14126
  }
11969
14127
 
14128
+
14129
+ if (url.pathname === "/api/hazbase/status" && req.method === "GET") {
14130
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
14131
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
14132
+ if (!hookAuth) {
14133
+ const session = requireApiSession(req, res, config, state);
14134
+ if (!session) return;
14135
+ }
14136
+ if (!config.hazbaseApiUrl) {
14137
+ return writeJson(res, 200, { enabled: false });
14138
+ }
14139
+ const hazbase = normalizeHazbaseState(state.hazbase);
14140
+ const [paymentsResult, chainsResult] = await Promise.allSettled([
14141
+ fetchHazbaseMetadata(config, "/api/meta/payments"),
14142
+ fetchHazbaseMetadata(config, "/api/meta/chains"),
14143
+ ]);
14144
+ const payments = paymentsResult.status === "fulfilled" ? paymentsResult.value : null;
14145
+ const chains = chainsResult.status === "fulfilled" ? chainsResult.value : null;
14146
+ const errors = [];
14147
+ if (paymentsResult.status === "rejected") {
14148
+ errors.push(paymentsResult.reason?.message || String(paymentsResult.reason || "payments metadata unavailable"));
14149
+ }
14150
+ if (chainsResult.status === "rejected") {
14151
+ errors.push(chainsResult.reason?.message || String(chainsResult.reason || "chains metadata unavailable"));
14152
+ }
14153
+ const supportedChains = Array.isArray(chains?.chains)
14154
+ ? chains.chains.filter((entry) => Number(entry.chainId) === 8453 || Number(entry.chainId) === 84532)
14155
+ : [];
14156
+ const payoutAddresses = {
14157
+ 8453: resolveHazbaseAccountForChain(hazbase.accounts, 8453)?.smartAccountAddress || "",
14158
+ 84532: resolveHazbaseAccountForChain(hazbase.accounts, 84532)?.smartAccountAddress || "",
14159
+ };
14160
+ const signedIn = Boolean(hazbase.accessToken) && !hazbase.sessionInvalid;
14161
+ return writeJson(res, 200, {
14162
+ enabled: true,
14163
+ apiUrl: config.hazbaseApiUrl,
14164
+ signedIn,
14165
+ sessionStatus: hazbase.sessionInvalid ? "expired" : signedIn ? "signed_in" : "signed_out",
14166
+ sessionInvalid: hazbase.sessionInvalid,
14167
+ sessionInvalidReason: hazbase.sessionInvalidReason,
14168
+ sessionInvalidAt: hazbase.sessionInvalidAt,
14169
+ email: hazbase.email,
14170
+ userId: hazbase.userId,
14171
+ sessionId: hazbase.sessionId,
14172
+ deviceBindingId: hazbase.deviceBindingId,
14173
+ credentialId: hazbase.credentialId,
14174
+ highTrustExpiresAt: hazbase.highTrustExpiresAt,
14175
+ accounts: hazbase.accounts,
14176
+ supportedPayments: payments?.networks || [],
14177
+ supportedChains,
14178
+ defaultPaymentNetwork: payments?.defaultNetwork || "base-sepolia",
14179
+ payoutAddresses,
14180
+ error: errors.join(" | "),
14181
+ });
14182
+ }
14183
+
14184
+ if (url.pathname === "/api/hazbase/payout-address" && req.method === "GET") {
14185
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
14186
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
14187
+ if (!hookAuth) {
14188
+ const session = requireApiSession(req, res, config, state);
14189
+ if (!session) return;
14190
+ }
14191
+ const hazbase = normalizeHazbaseState(state.hazbase);
14192
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14193
+ const chainId = Number(url.searchParams.get("chainId") || 0);
14194
+ if (chainId !== 8453 && chainId !== 84532) {
14195
+ return writeJson(res, 400, { error: "unsupported-chain" });
14196
+ }
14197
+ const account = resolveHazbaseAccountForChain(hazbase.accounts, chainId);
14198
+ if (!account?.smartAccountAddress) {
14199
+ return writeJson(res, 409, { error: "hazbase-wallet-account-missing" });
14200
+ }
14201
+ return writeJson(res, 200, {
14202
+ chainId,
14203
+ payoutMethod: "hazbase_wallet",
14204
+ payoutAddress: account.smartAccountAddress,
14205
+ smartAccountAddress: account.smartAccountAddress,
14206
+ accountVariant: account.accountVariant || "",
14207
+ relayMode: account.relayMode || "",
14208
+ });
14209
+ }
14210
+
14211
+ if (url.pathname === "/api/hazbase/request-otp" && req.method === "POST") {
14212
+ const session = requireMutatingApiSession(req, res, config, state);
14213
+ if (!session) return;
14214
+ const body = await parseJsonBody(req);
14215
+ const email = cleanText(body?.email || "");
14216
+ if (!email) return writeJson(res, 400, { error: "email-required" });
14217
+ const result = await requestHazbaseEmailOtp({ email });
14218
+ state.hazbase = { ...normalizeHazbaseState(state.hazbase), email };
14219
+ await saveState(config.stateFile, state);
14220
+ return writeJson(res, 200, result);
14221
+ }
14222
+
14223
+ if (url.pathname === "/api/hazbase/verify-otp" && req.method === "POST") {
14224
+ const session = requireMutatingApiSession(req, res, config, state);
14225
+ if (!session) return;
14226
+ const body = await parseJsonBody(req);
14227
+ const email = cleanText(body?.email || state.hazbase?.email || "");
14228
+ const code = cleanText(body?.code || "");
14229
+ if (!email || !code) return writeJson(res, 400, { error: "otp-required" });
14230
+ const result = await verifyHazbaseEmailOtp({ email, code });
14231
+ const previousHazbase = normalizeHazbaseState(state.hazbase);
14232
+ state.hazbase = {
14233
+ ...previousHazbase,
14234
+ email: result.email || email,
14235
+ accessToken: cleanText(result.accessToken || ""),
14236
+ sessionId: cleanText(result.sessionId || ""),
14237
+ userId: cleanText(result.userId || ""),
14238
+ accounts: mergeHazbaseAccountSummaries(previousHazbase.accounts, Array.isArray(result.accounts) ? result.accounts : []),
14239
+ highTrustToken: "",
14240
+ highTrustExpiresAt: "",
14241
+ sessionInvalid: false,
14242
+ sessionInvalidReason: "",
14243
+ sessionInvalidAt: "",
14244
+ };
14245
+ await saveState(config.stateFile, state);
14246
+ return writeJson(res, 200, result);
14247
+ }
14248
+
14249
+ if (url.pathname === "/api/hazbase/passkey/register/challenge" && req.method === "POST") {
14250
+ const session = requireMutatingApiSession(req, res, config, state);
14251
+ if (!session) return;
14252
+ const hazbase = normalizeHazbaseState(state.hazbase);
14253
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14254
+ const body = await parseJsonBody(req);
14255
+ let partnerOrigin;
14256
+ try {
14257
+ partnerOrigin = deriveHazbasePartnerPasskeyOrigin(req, config);
14258
+ } catch (error) {
14259
+ return writeJson(res, 400, {
14260
+ error: error?.code || "hazbase-passkey-local-host-required",
14261
+ message: error?.message || "Open viveworker on its .local hostname to use hazBase passkeys.",
14262
+ });
14263
+ }
14264
+ let result;
14265
+ try {
14266
+ result = await requestPasskeyRegistrationChallenge({
14267
+ emailSession: hazbase.accessToken,
14268
+ deviceLabel: cleanText(body?.deviceLabel || config.hazbaseDeviceLabel || "") || undefined,
14269
+ partnerOrigin,
14270
+ });
14271
+ } catch (error) {
14272
+ if (await maybeWriteHazbaseSessionExpiredResponse({ error, config, state, res })) return;
14273
+ throw error;
14274
+ }
14275
+ return writeJson(res, 200, result);
14276
+ }
14277
+
14278
+ if (url.pathname === "/api/hazbase/passkey/register/complete" && req.method === "POST") {
14279
+ const session = requireMutatingApiSession(req, res, config, state);
14280
+ if (!session) return;
14281
+ const hazbase = normalizeHazbaseState(state.hazbase);
14282
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14283
+ const body = await parseJsonBody(req);
14284
+ let result;
14285
+ try {
14286
+ result = await completePasskeyRegistration({
14287
+ emailSession: hazbase.accessToken,
14288
+ challengeId: cleanText(body?.challengeId || ""),
14289
+ credential: body?.credential,
14290
+ deviceLabel: cleanText(body?.deviceLabel || config.hazbaseDeviceLabel || "") || undefined,
14291
+ });
14292
+ } catch (error) {
14293
+ if (await maybeWriteHazbaseSessionExpiredResponse({ error, config, state, res })) return;
14294
+ throw error;
14295
+ }
14296
+ state.hazbase = {
14297
+ ...hazbase,
14298
+ deviceBindingId: cleanText(result.deviceBindingId || hazbase.deviceBindingId || ""),
14299
+ credentialId: cleanText(result.credentialId || hazbase.credentialId || ""),
14300
+ sessionInvalid: false,
14301
+ sessionInvalidReason: "",
14302
+ sessionInvalidAt: "",
14303
+ };
14304
+ await saveState(config.stateFile, state);
14305
+ return writeJson(res, 200, result);
14306
+ }
14307
+
14308
+ if (url.pathname === "/api/hazbase/passkey/assert/challenge" && req.method === "POST") {
14309
+ const session = requireMutatingApiSession(req, res, config, state);
14310
+ if (!session) return;
14311
+ const hazbase = normalizeHazbaseState(state.hazbase);
14312
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14313
+ const body = await parseJsonBody(req);
14314
+ let partnerOrigin;
14315
+ try {
14316
+ partnerOrigin = deriveHazbasePartnerPasskeyOrigin(req, config);
14317
+ } catch (error) {
14318
+ return writeJson(res, 400, {
14319
+ error: error?.code || "hazbase-passkey-local-host-required",
14320
+ message: error?.message || "Open viveworker on its .local hostname to use hazBase passkeys.",
14321
+ });
14322
+ }
14323
+ let result;
14324
+ try {
14325
+ result = await requestPasskeyAssertionChallenge({
14326
+ emailSession: hazbase.accessToken,
14327
+ purpose: cleanText(body?.purpose || "bootstrap") || "bootstrap",
14328
+ deviceBindingId: hazbase.deviceBindingId || undefined,
14329
+ partnerOrigin,
14330
+ });
14331
+ } catch (error) {
14332
+ if (await maybeWriteHazbaseSessionExpiredResponse({ error, config, state, res })) return;
14333
+ throw error;
14334
+ }
14335
+ return writeJson(res, 200, result);
14336
+ }
14337
+
14338
+ if (url.pathname === "/api/hazbase/passkey/assert/complete" && req.method === "POST") {
14339
+ const session = requireMutatingApiSession(req, res, config, state);
14340
+ if (!session) return;
14341
+ const hazbase = normalizeHazbaseState(state.hazbase);
14342
+ if (!hazbase.accessToken) return writeJson(res, 401, { error: "hazbase-auth-required" });
14343
+ const body = await parseJsonBody(req);
14344
+ let result;
14345
+ try {
14346
+ result = await completePasskeyAssertion({
14347
+ emailSession: hazbase.accessToken,
14348
+ challengeId: cleanText(body?.challengeId || ""),
14349
+ credential: body?.credential,
14350
+ purpose: cleanText(body?.purpose || "bootstrap") || "bootstrap",
14351
+ deviceBindingId: hazbase.deviceBindingId || undefined,
14352
+ });
14353
+ } catch (error) {
14354
+ if (await maybeWriteHazbaseSessionExpiredResponse({ error, config, state, res })) return;
14355
+ throw error;
14356
+ }
14357
+ state.hazbase = {
14358
+ ...hazbase,
14359
+ deviceBindingId: cleanText(result.deviceBindingId || hazbase.deviceBindingId || ""),
14360
+ credentialId: cleanText(result.credentialId || hazbase.credentialId || ""),
14361
+ highTrustToken: cleanText(result.highTrustToken || ""),
14362
+ highTrustExpiresAt: cleanText(result.highTrustExpiresAt || ""),
14363
+ sessionInvalid: false,
14364
+ sessionInvalidReason: "",
14365
+ sessionInvalidAt: "",
14366
+ };
14367
+ await saveState(config.stateFile, state);
14368
+ return writeJson(res, 200, result);
14369
+ }
14370
+
14371
+ if (url.pathname === "/api/hazbase/account/bootstrap" && req.method === "POST") {
14372
+ const session = requireMutatingApiSession(req, res, config, state);
14373
+ if (!session) return;
14374
+ const hazbase = normalizeHazbaseState(state.hazbase);
14375
+ if (!hazbase.accessToken || !hazbase.deviceBindingId || !hazbase.highTrustToken) {
14376
+ return writeJson(res, 409, { error: "hazbase-bootstrap-prerequisites-missing" });
14377
+ }
14378
+ const body = await parseJsonBody(req);
14379
+ const chainId = Number(body?.chainId || 0);
14380
+ if (chainId !== 8453 && chainId !== 84532) {
14381
+ return writeJson(res, 400, { error: "unsupported-chain" });
14382
+ }
14383
+ let result;
14384
+ try {
14385
+ result = await bootstrapPasskeyAccount({
14386
+ emailSession: hazbase.accessToken,
14387
+ deviceBindingId: hazbase.deviceBindingId,
14388
+ highTrustToken: hazbase.highTrustToken,
14389
+ chainId,
14390
+ });
14391
+ } catch (error) {
14392
+ if (await maybeWriteHazbaseSessionExpiredResponse({ error, config, state, res })) return;
14393
+ throw error;
14394
+ }
14395
+ const nextAccounts = mergeHazbaseAccountSummaries(hazbase.accounts, [{
14396
+ smartAccountAddress: result.smartAccountAddress,
14397
+ chainId: result.chainId,
14398
+ accountVariant: result.accountVariant,
14399
+ relayMode: result.relayMode,
14400
+ primaryDeviceBindingId: result.deviceBindingId,
14401
+ }]);
14402
+ state.hazbase = {
14403
+ ...hazbase,
14404
+ accounts: nextAccounts,
14405
+ sessionInvalid: false,
14406
+ sessionInvalidReason: "",
14407
+ sessionInvalidAt: "",
14408
+ };
14409
+ await saveState(config.stateFile, state);
14410
+ return writeJson(res, 200, result);
14411
+ }
14412
+
14413
+ if (url.pathname === "/api/hazbase/session/refresh" && req.method === "POST") {
14414
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
14415
+ const hookAuth = config.sessionSecret && hookSecret === config.sessionSecret;
14416
+ if (!hookAuth) {
14417
+ const session = requireMutatingApiSession(req, res, config, state);
14418
+ if (!session) return;
14419
+ }
14420
+ markHazbaseSessionInvalid(state, "manual_refresh_requested");
14421
+ await saveState(config.stateFile, state);
14422
+ return writeJson(res, 200, {
14423
+ ok: true,
14424
+ status: "session_refresh_required",
14425
+ });
14426
+ }
14427
+
14428
+ if (url.pathname === "/api/hazbase/logout" && req.method === "POST") {
14429
+ const session = requireMutatingApiSession(req, res, config, state);
14430
+ if (!session) return;
14431
+ state.hazbase = normalizeHazbaseState(null);
14432
+ await saveState(config.stateFile, state);
14433
+ return writeJson(res, 200, { ok: true });
14434
+ }
14435
+
11970
14436
  // Toggle acceptPublicTasks on the A2A relay.
11971
14437
  if (url.pathname === "/api/a2a/public-tasks" && req.method === "POST") {
11972
14438
  const session = requireApiSession(req, res, config, state);
@@ -11975,11 +14441,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
11975
14441
  const accept = body?.accept === true;
11976
14442
  config.a2aAcceptPublicTasks = accept;
11977
14443
  state.a2aAcceptPublicTasks = accept;
11978
- try {
11979
- await saveState(config.stateFile, state);
11980
- } catch (error) {
11981
- console.error(`[a2a-public-tasks-save] ${error.message}`);
11982
- }
14444
+ scheduleSaveState(config, state);
11983
14445
  // Re-register with relay to propagate the flag.
11984
14446
  if (config.a2aRelayUrl && config.a2aRelayUserId) {
11985
14447
  try {
@@ -12154,6 +14616,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12154
14616
  await deliverWebPushItem({
12155
14617
  config, state,
12156
14618
  kind: "thread_share",
14619
+ tab: "inbox",
14620
+ subtab: "pending",
12157
14621
  token,
12158
14622
  stableId: `thread_share:${shareId}`,
12159
14623
  title: `Thread Share: ${sourceLabel || sourceTool || "agent"} → ${targetLabel || targetTool}`,
@@ -12251,6 +14715,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12251
14715
  await deliverWebPushItem({
12252
14716
  config, state,
12253
14717
  kind: "thread_share",
14718
+ tab: "inbox",
14719
+ subtab: "completed",
12254
14720
  token: share.token,
12255
14721
  stableId: `thread_share_fallback:${share.shareId}`,
12256
14722
  title: "Thread Share: target unreachable",
@@ -12393,7 +14859,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
12393
14859
  lastLocale: resolveDeviceLocaleInfo(config, state, session.deviceId).locale,
12394
14860
  });
12395
14861
  if (changed || metadataChanged) {
12396
- await saveState(config.stateFile, state);
14862
+ scheduleSaveState(config, state);
12397
14863
  }
12398
14864
  return writeJson(res, 200, {
12399
14865
  ok: true,
@@ -12416,7 +14882,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
12416
14882
  changed = deletePushSubscriptionsForDevice(state, session.deviceId) || changed;
12417
14883
  }
12418
14884
  if (changed) {
12419
- await saveState(config.stateFile, state);
14885
+ scheduleSaveState(config, state);
12420
14886
  }
12421
14887
  return writeJson(res, 200, { ok: true, subscribed: false });
12422
14888
  }
@@ -12428,13 +14894,13 @@ function createNativeApprovalServer({ config, runtime, state }) {
12428
14894
  }
12429
14895
  try {
12430
14896
  await sendPushTestToDevice({ config, state, session });
12431
- await saveState(config.stateFile, state);
14897
+ scheduleSaveState(config, state);
12432
14898
  return writeJson(res, 200, { ok: true });
12433
14899
  } catch (error) {
12434
14900
  const statusCode = Number(error?.statusCode) || 0;
12435
14901
  if (statusCode === 404 || statusCode === 410) {
12436
14902
  deletePushSubscriptionsForDevice(state, session.deviceId);
12437
- await saveState(config.stateFile, state);
14903
+ scheduleSaveState(config, state);
12438
14904
  return writeJson(res, 410, { error: "push-subscription-expired" });
12439
14905
  }
12440
14906
  return writeJson(res, 500, {
@@ -12442,43 +14908,201 @@ function createNativeApprovalServer({ config, runtime, state }) {
12442
14908
  ipcError: error.ipcError ?? null,
12443
14909
  });
12444
14910
  }
12445
- }
12446
-
12447
- if (url.pathname === "/api/devices" && req.method === "GET") {
12448
- const session = requireApiSession(req, res, config, state);
12449
- if (!session) {
12450
- return;
14911
+ }
14912
+
14913
+ if (url.pathname === "/api/devices" && req.method === "GET") {
14914
+ const session = requireApiSession(req, res, config, state);
14915
+ if (!session) {
14916
+ return;
14917
+ }
14918
+ const locale = resolveDeviceLocaleInfo(config, state, session.deviceId).locale;
14919
+ return writeJson(res, 200, buildDevicesResponse({ config, state, session, locale }));
14920
+ }
14921
+
14922
+ const apiDeviceRevokeMatch = url.pathname.match(/^\/api\/devices\/([^/]+)\/revoke$/u);
14923
+ if (apiDeviceRevokeMatch && req.method === "POST") {
14924
+ const session = requireMutatingApiSession(req, res, config, state);
14925
+ if (!session) {
14926
+ return;
14927
+ }
14928
+ const deviceId = decodeURIComponent(apiDeviceRevokeMatch[1]);
14929
+ const existing = getActiveDeviceTrustRecord(state, config, deviceId);
14930
+ if (!existing) {
14931
+ return writeJson(res, 404, { error: "device-not-found" });
14932
+ }
14933
+ let changed = false;
14934
+ changed = revokeDeviceTrust(state, config, deviceId) || changed;
14935
+ changed = deletePushSubscriptionsForDevice(state, deviceId) || changed;
14936
+ if (changed) {
14937
+ await saveState(config.stateFile, state);
14938
+ }
14939
+ const currentDeviceRevoked = deviceId === session.deviceId;
14940
+ if (currentDeviceRevoked) {
14941
+ clearAuthCookies(res, config);
14942
+ }
14943
+ return writeJson(res, 200, {
14944
+ ok: true,
14945
+ deviceId,
14946
+ currentDeviceRevoked,
14947
+ });
14948
+ }
14949
+
14950
+
14951
+ if (url.pathname === "/api/payments/x402/hazbase-wallet" && req.method === "POST") {
14952
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
14953
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
14954
+ return writeJson(res, 401, { error: "unauthorized" });
14955
+ }
14956
+
14957
+ let body;
14958
+ try {
14959
+ body = await parseJsonBody(req);
14960
+ } catch {
14961
+ return writeJson(res, 400, { error: "invalid-json-body" });
14962
+ }
14963
+
14964
+ const approval = createHazbaseWalletPaymentApproval({ config, body });
14965
+ if (!approval) {
14966
+ return writeJson(res, 400, { error: "invalid-hazbase-wallet-payment-request" });
14967
+ }
14968
+ if (runtime.nativeApprovalsByRequestKey.has(approval.requestKey)) {
14969
+ return writeJson(res, 409, { error: "hazbase-wallet-payment-already-pending" });
14970
+ }
14971
+
14972
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
14973
+ runtime.nativeApprovalsByRequestKey.set(approval.requestKey, approval);
14974
+
14975
+ deliverWebPushItem({
14976
+ config,
14977
+ state,
14978
+ kind: "approval",
14979
+ tab: "inbox",
14980
+ subtab: "pending",
14981
+ token: approval.token,
14982
+ stableId: pendingApprovalStableId(approval),
14983
+ title: approval.title,
14984
+ body: approval.messageText,
14985
+ buildLocalizedContent: () => ({ title: approval.title, body: approval.messageText }),
14986
+ }).catch((error) => {
14987
+ console.error(`[hazbase-wallet-payment-push] ${approval.requestKey} | ${error.message}`);
14988
+ });
14989
+
14990
+ const waitMs = Math.max(10_000, Math.min(900_000, Number(body.timeoutMs) || 600_000));
14991
+ const decisionPromise = new Promise((resolve) => {
14992
+ approval.resolveClaudeWaiter = resolve;
14993
+ });
14994
+ const timeoutPromise = new Promise((resolve) => {
14995
+ setTimeout(() => resolve({ paid: false, decision: "timeout", error: "hazbase-wallet-payment-timeout" }), waitMs);
14996
+ });
14997
+ const decision = await Promise.race([decisionPromise, timeoutPromise]);
14998
+
14999
+ if (!approval.resolved) {
15000
+ approval.resolved = true;
15001
+ approval.resolving = false;
15002
+ runtime.nativeApprovalsByToken.delete(approval.token);
15003
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
15004
+ }
15005
+
15006
+ if (decision && typeof decision === "object" && decision.paid === true) {
15007
+ return writeJson(res, 200, decision);
15008
+ }
15009
+ const error = decision?.error || decision?.decision || "hazbase-wallet-payment-declined";
15010
+ const statusCode = error === "hazbase-wallet-payment-timeout" ? 408 : 403;
15011
+ return writeJson(res, statusCode, { paid: false, error, decision: decision?.decision || "decline" });
15012
+ }
15013
+
15014
+ const hazbaseWalletPayMatch = url.pathname.match(/^\/api\/payments\/x402\/hazbase-wallet\/([a-f0-9]+)\/pay$/u);
15015
+ if (hazbaseWalletPayMatch && req.method === "POST") {
15016
+ const session = requireMutatingApiSession(req, res, config, state);
15017
+ if (!session) return;
15018
+ const token = hazbaseWalletPayMatch[1];
15019
+ const approval = runtime.nativeApprovalsByToken.get(token);
15020
+ if (!approval || approval.kind !== "hazbase_wallet_payment") {
15021
+ return writeJson(res, 404, { error: "approval-not-found" });
15022
+ }
15023
+ if (approval.resolved || approval.resolving) {
15024
+ return writeJson(res, 409, { error: "approval-already-handled" });
15025
+ }
15026
+ approval.resolving = true;
15027
+ try {
15028
+ const result = await completeHazbaseWalletPaymentApproval({ config, runtime, state, approval });
15029
+ return writeJson(res, 200, result);
15030
+ } catch (error) {
15031
+ approval.resolving = false;
15032
+ console.error(`[hazbase-wallet-payment-error] ${approval.requestKey} | ${error?.stack || error?.message || error}`);
15033
+ return writeJson(res, Number(error?.statusCode) || 500, { error: error?.code || error?.message || "hazbase-wallet-payment-failed" });
12451
15034
  }
12452
- const locale = resolveDeviceLocaleInfo(config, state, session.deviceId).locale;
12453
- return writeJson(res, 200, buildDevicesResponse({ config, state, session, locale }));
12454
15035
  }
12455
15036
 
12456
- const apiDeviceRevokeMatch = url.pathname.match(/^\/api\/devices\/([^/]+)\/revoke$/u);
12457
- if (apiDeviceRevokeMatch && req.method === "POST") {
12458
- const session = requireMutatingApiSession(req, res, config, state);
12459
- if (!session) {
12460
- return;
15037
+ if (url.pathname === "/api/payments/x402/approval" && req.method === "POST") {
15038
+ const hookSecret = req.headers["x-viveworker-hook-secret"] || "";
15039
+ if (!config.sessionSecret || hookSecret !== config.sessionSecret) {
15040
+ return writeJson(res, 401, { error: "unauthorized" });
12461
15041
  }
12462
- const deviceId = decodeURIComponent(apiDeviceRevokeMatch[1]);
12463
- const existing = getActiveDeviceTrustRecord(state, config, deviceId);
12464
- if (!existing) {
12465
- return writeJson(res, 404, { error: "device-not-found" });
15042
+
15043
+ let body;
15044
+ try {
15045
+ body = await parseJsonBody(req);
15046
+ } catch {
15047
+ return writeJson(res, 400, { error: "invalid-json-body" });
12466
15048
  }
12467
- let changed = false;
12468
- changed = revokeDeviceTrust(state, config, deviceId) || changed;
12469
- changed = deletePushSubscriptionsForDevice(state, deviceId) || changed;
12470
- if (changed) {
12471
- await saveState(config.stateFile, state);
15049
+
15050
+ const approval = createX402PaymentApproval({ config, body });
15051
+ if (!approval) {
15052
+ return writeJson(res, 400, { error: "invalid-payment-approval-request" });
12472
15053
  }
12473
- const currentDeviceRevoked = deviceId === session.deviceId;
12474
- if (currentDeviceRevoked) {
12475
- clearAuthCookies(res, config);
15054
+ if (runtime.nativeApprovalsByRequestKey.has(approval.requestKey)) {
15055
+ return writeJson(res, 409, { error: "payment-approval-already-pending" });
12476
15056
  }
12477
- return writeJson(res, 200, {
12478
- ok: true,
12479
- deviceId,
12480
- currentDeviceRevoked,
15057
+
15058
+ runtime.nativeApprovalsByToken.set(approval.token, approval);
15059
+ runtime.nativeApprovalsByRequestKey.set(approval.requestKey, approval);
15060
+
15061
+ deliverWebPushItem({
15062
+ config,
15063
+ state,
15064
+ kind: "approval",
15065
+ tab: "inbox",
15066
+ subtab: "pending",
15067
+ token: approval.token,
15068
+ stableId: pendingApprovalStableId(approval),
15069
+ title: approval.title,
15070
+ body: approval.messageText,
15071
+ buildLocalizedContent: () => ({
15072
+ title: approval.title,
15073
+ body: approval.messageText,
15074
+ }),
15075
+ }).catch((error) => {
15076
+ console.error(`[payment-approval-push] ${approval.requestKey} | ${error.message}`);
15077
+ });
15078
+
15079
+ const waitMs = Math.max(10_000, Math.min(900_000, Number(body.timeoutMs) || 600_000));
15080
+ const decisionPromise = new Promise((resolve) => {
15081
+ approval.resolveClaudeWaiter = resolve;
15082
+ });
15083
+ const timeoutPromise = new Promise((resolve) => {
15084
+ setTimeout(() => resolve("timeout"), waitMs);
12481
15085
  });
15086
+ const decision = await Promise.race([decisionPromise, timeoutPromise]);
15087
+
15088
+ if (!approval.resolved) {
15089
+ approval.resolved = true;
15090
+ approval.resolving = false;
15091
+ runtime.nativeApprovalsByToken.delete(approval.token);
15092
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
15093
+ }
15094
+
15095
+ if (decision === "accept") {
15096
+ return writeJson(res, 200, {
15097
+ approved: true,
15098
+ decision,
15099
+ approvedPayment: approval.rawParams,
15100
+ });
15101
+ }
15102
+ if (decision === "timeout") {
15103
+ return writeJson(res, 408, { approved: false, decision, error: "payment-approval-timeout" });
15104
+ }
15105
+ return writeJson(res, 403, { approved: false, decision: "decline", error: "payment-approval-declined" });
12482
15106
  }
12483
15107
 
12484
15108
  if (url.pathname === "/api/providers/claude/events" && req.method === "POST") {
@@ -12640,6 +15264,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12640
15264
  diffSource: String(body.diffSource || "claude_permission_request"),
12641
15265
  diffAddedLines: Math.max(0, Number(body.diffAddedLines) || 0),
12642
15266
  diffRemovedLines: Math.max(0, Number(body.diffRemovedLines) || 0),
15267
+ cwd: body.cwd ? String(body.cwd) : "",
15268
+ workspaceRoot: body.cwd ? String(body.cwd) : "",
12643
15269
  createdAtMs: Number(body.createdAtMs) || Date.now(),
12644
15270
  resolved: false,
12645
15271
  resolving: false,
@@ -12653,6 +15279,59 @@ function createNativeApprovalServer({ config, runtime, state }) {
12653
15279
  readOnly: body.notifyOnly === true,
12654
15280
  };
12655
15281
 
15282
+ const claudeAutoPilotMatch =
15283
+ approval.kind === "command"
15284
+ ? maybeAutoApproveTrustedRead({
15285
+ state,
15286
+ commandText: body?.toolInput?.command ?? "",
15287
+ cwd: body.cwd ? String(body.cwd) : "",
15288
+ workspaceRoot: body.cwd ? String(body.cwd) : "",
15289
+ })
15290
+ : null;
15291
+ if (claudeAutoPilotMatch) {
15292
+ try {
15293
+ await recordAutoApprovedTrustedRead({
15294
+ config,
15295
+ runtime,
15296
+ state,
15297
+ approval,
15298
+ policyMatch: claudeAutoPilotMatch,
15299
+ });
15300
+ console.log(`[auto-pilot-approved] ${approval.requestKey} | ${claudeAutoPilotMatch.command}`);
15301
+ return writeJson(res, 200, {
15302
+ permissionDecision: "allow",
15303
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedReadReason"),
15304
+ });
15305
+ } catch (error) {
15306
+ console.error(`[auto-pilot-error] ${approval.requestKey} | ${error.message}`);
15307
+ }
15308
+ }
15309
+
15310
+ const claudeAutoPilotWriteCandidate =
15311
+ approval.kind === "file"
15312
+ ? maybeAutoApproveTrustedWriteCandidate({ runtime, state, approval })
15313
+ : null;
15314
+ if (claudeAutoPilotWriteCandidate) {
15315
+ try {
15316
+ await recordAutoApprovedTrustedWrite({
15317
+ config,
15318
+ runtime,
15319
+ state,
15320
+ approval,
15321
+ candidate: claudeAutoPilotWriteCandidate,
15322
+ });
15323
+ console.log(
15324
+ `[auto-pilot-approved-write] ${approval.requestKey} | files=${claudeAutoPilotWriteCandidate.fileRefs.length} | lines=${claudeAutoPilotWriteCandidate.totalChangedLines}`
15325
+ );
15326
+ return writeJson(res, 200, {
15327
+ permissionDecision: "allow",
15328
+ permissionDecisionReason: t(config.defaultLocale, "server.message.autoPilotTrustedWriteReason"),
15329
+ });
15330
+ } catch (error) {
15331
+ console.error(`[auto-pilot-write-error] ${approval.requestKey} | ${error.message}`);
15332
+ }
15333
+ }
15334
+
12656
15335
  runtime.nativeApprovalsByToken.set(token, approval);
12657
15336
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
12658
15337
 
@@ -12660,6 +15339,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12660
15339
  config,
12661
15340
  state,
12662
15341
  kind: "approval",
15342
+ tab: "inbox",
15343
+ subtab: "pending",
12663
15344
  token: approval.token,
12664
15345
  stableId: pendingApprovalStableId(approval),
12665
15346
  title: approval.title,
@@ -12789,6 +15470,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12789
15470
  config,
12790
15471
  state,
12791
15472
  kind: "moltbook_reply",
15473
+ tab: "inbox",
15474
+ subtab: "pending",
12792
15475
  token,
12793
15476
  stableId: `moltbook_reply:${item.sourceId}`,
12794
15477
  title: item.title,
@@ -12970,6 +15653,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
12970
15653
  config,
12971
15654
  state,
12972
15655
  kind: "moltbook_draft",
15656
+ tab: "inbox",
15657
+ subtab: "pending",
12973
15658
  token,
12974
15659
  stableId: `moltbook_draft:${sourceId}`,
12975
15660
  title: pushTitle,
@@ -13066,6 +15751,8 @@ function createNativeApprovalServer({ config, runtime, state }) {
13066
15751
  try {
13067
15752
  await deliverWebPushItem({
13068
15753
  config, state, kind: "moltbook_draft", token: draft.token,
15754
+ tab: "inbox",
15755
+ subtab: "completed",
13069
15756
  stableId: `moltbook_draft_failed:${draft.sourceId}`,
13070
15757
  title: "Draft post failed",
13071
15758
  body: String(postError.message || "").slice(0, 160),
@@ -13089,7 +15776,16 @@ function createNativeApprovalServer({ config, runtime, state }) {
13089
15776
  return;
13090
15777
  }
13091
15778
  const locale = resolveDeviceLocaleInfo(config, state, session.deviceId).locale;
13092
- return writeJson(res, 200, await buildInboxResponse(runtime, state, config, locale));
15779
+ return writeJson(res, 200, buildInboxFastResponse(runtime, state, config, locale));
15780
+ }
15781
+
15782
+ if (url.pathname === "/api/inbox/diff" && req.method === "GET") {
15783
+ const session = requireApiSession(req, res, config, state);
15784
+ if (!session) {
15785
+ return;
15786
+ }
15787
+ const locale = resolveDeviceLocaleInfo(config, state, session.deviceId).locale;
15788
+ return writeJson(res, 200, await buildInboxDiffResponse(runtime, state, config, locale));
13093
15789
  }
13094
15790
 
13095
15791
  if (url.pathname === "/api/timeline" && req.method === "GET") {
@@ -13226,6 +15922,10 @@ function createNativeApprovalServer({ config, runtime, state }) {
13226
15922
  if (approval.resolved || approval.resolving) {
13227
15923
  return writeJson(res, 409, { error: "approval-already-handled" });
13228
15924
  }
15925
+ if (approval.kind === "hazbase_wallet_payment") {
15926
+ console.warn(`[hazbase-wallet-payment-generic-decision-blocked] ${approval.requestKey} | ${decision}`);
15927
+ return writeJson(res, 409, { error: "hazbase-wallet-payment-requires-wallet-pay-route" });
15928
+ }
13229
15929
 
13230
15930
  approval.resolving = true;
13231
15931
  try {
@@ -13820,6 +16520,21 @@ function createNativeApprovalServer({ config, runtime, state }) {
13820
16520
  if (approval.resolved || approval.resolving) {
13821
16521
  return writeApprovalHandled(res, req, approval.title, 409, config.defaultLocale);
13822
16522
  }
16523
+ if (approval.kind === "hazbase_wallet_payment") {
16524
+ console.warn(`[hazbase-wallet-payment-native-decision-blocked] ${approval.requestKey} | ${decision}`);
16525
+ if (requestWantsHtml(req)) {
16526
+ return writeHtml(
16527
+ res,
16528
+ 409,
16529
+ renderStatusPage({
16530
+ title: approval.title,
16531
+ body: "This approval must be completed from the viveworker app with the wallet payment button.",
16532
+ tone: "warn",
16533
+ })
16534
+ );
16535
+ }
16536
+ return writeJson(res, 409, { error: "hazbase-wallet-payment-requires-wallet-pay-route" });
16537
+ }
13823
16538
 
13824
16539
  approval.resolving = true;
13825
16540
  try {
@@ -15169,6 +17884,8 @@ async function executeMoltbookDraftPost(draft, config, runtime, state) {
15169
17884
  const pushTitle = draft.draftType === "original_post" ? finalTitle : `Reply → ${draft.postTitle || "Moltbook"}`;
15170
17885
  await deliverWebPushItem({
15171
17886
  config, state, kind: "moltbook_draft", token: draft.token,
17887
+ tab: "inbox",
17888
+ subtab: "completed",
15172
17889
  stableId: `moltbook_draft_posted:${draft.sourceId}`,
15173
17890
  title: "Moltbook posted",
15174
17891
  body: truncate(singleLine(pushTitle), 160),
@@ -15201,6 +17918,12 @@ function buildConfig(cli) {
15201
17918
  a2aShareUrl: stripTrailingSlash(
15202
17919
  process.env.VIVEWORKER_SHARE_URL || "https://share.viveworker.com"
15203
17920
  ),
17921
+ hazbaseApiUrl: stripTrailingSlash(process.env.HAZBASE_API_URL || "https://api.hazbase.com"),
17922
+ hazbaseClientKey: cleanText(process.env.HAZBASE_CLIENT_KEY || "viveworker-internal"),
17923
+ hazbaseDeviceLabel: cleanText(process.env.HAZBASE_DEVICE_LABEL || "viveworker"),
17924
+ npmPackageName: cleanText(process.env.VIVEWORKER_NPM_PACKAGE || "viveworker"),
17925
+ npmDistTag: cleanText(process.env.VIVEWORKER_NPM_DIST_TAG || "latest"),
17926
+ npmRegistryUrl: stripTrailingSlash(process.env.NPM_REGISTRY_URL || "https://registry.npmjs.org"),
15204
17927
  webUiEnabled: boolEnv("WEB_UI_ENABLED", true),
15205
17928
  authRequired: boolEnv("AUTH_REQUIRED", true),
15206
17929
  webPushEnabled: boolEnv("WEB_PUSH_ENABLED", false),
@@ -15288,6 +18011,386 @@ function buildConfig(cli) {
15288
18011
  };
15289
18012
  }
15290
18013
 
18014
+
18015
+ function normalizeHazbaseState(raw) {
18016
+ const value = raw && typeof raw === "object" ? raw : {};
18017
+ return {
18018
+ email: cleanText(value.email ?? ""),
18019
+ accessToken: cleanText(value.accessToken ?? ""),
18020
+ sessionId: cleanText(value.sessionId ?? ""),
18021
+ userId: cleanText(value.userId ?? ""),
18022
+ deviceBindingId: cleanText(value.deviceBindingId ?? ""),
18023
+ credentialId: cleanText(value.credentialId ?? ""),
18024
+ highTrustToken: cleanText(value.highTrustToken ?? ""),
18025
+ highTrustExpiresAt: cleanText(value.highTrustExpiresAt ?? ""),
18026
+ accounts: Array.isArray(value.accounts) ? value.accounts : [],
18027
+ sessionInvalid: value.sessionInvalid === true,
18028
+ sessionInvalidReason: cleanText(value.sessionInvalidReason ?? ""),
18029
+ sessionInvalidAt: cleanText(value.sessionInvalidAt ?? ""),
18030
+ };
18031
+ }
18032
+
18033
+ function isHazbaseInvalidAppSessionError(error) {
18034
+ const details = error?.details && typeof error.details === "object" ? error.details : {};
18035
+ const statusCode = Number(error?.statusCode || error?.status || details.statusCode || 0);
18036
+ const text = [
18037
+ error?.code,
18038
+ error?.message,
18039
+ details.errorCode,
18040
+ details.code,
18041
+ details.error,
18042
+ details.message,
18043
+ ]
18044
+ .map((entry) => cleanText(entry || "").toLowerCase())
18045
+ .filter(Boolean)
18046
+ .join(" ");
18047
+ return statusCode === 401 && text.includes("invalid app session");
18048
+ }
18049
+
18050
+ function markHazbaseSessionInvalid(state, reason = "invalid_app_session") {
18051
+ const hazbase = normalizeHazbaseState(state.hazbase);
18052
+ state.hazbase = {
18053
+ ...hazbase,
18054
+ accessToken: "",
18055
+ sessionId: "",
18056
+ highTrustToken: "",
18057
+ highTrustExpiresAt: "",
18058
+ sessionInvalid: true,
18059
+ sessionInvalidReason: cleanText(reason || "invalid_app_session"),
18060
+ sessionInvalidAt: new Date().toISOString(),
18061
+ };
18062
+ return state.hazbase;
18063
+ }
18064
+
18065
+ async function maybeWriteHazbaseSessionExpiredResponse({ error, config, state, res }) {
18066
+ if (!isHazbaseInvalidAppSessionError(error)) {
18067
+ return false;
18068
+ }
18069
+ markHazbaseSessionInvalid(state);
18070
+ await saveState(config.stateFile, state);
18071
+ return writeJson(res, 401, {
18072
+ error: "hazbase-session-expired",
18073
+ message: "Hazbase wallet session expired. Sign in again from Wallet settings.",
18074
+ });
18075
+ }
18076
+
18077
+ function hazbasePasskeyLocalHostError() {
18078
+ const error = new Error("Open viveworker on its .local hostname to use hazBase passkeys.");
18079
+ error.code = "hazbase-passkey-local-host-required";
18080
+ return error;
18081
+ }
18082
+
18083
+ function deriveHazbasePartnerPasskeyOrigin(req, config) {
18084
+ const rawOrigin = cleanText(req.headers.origin || "");
18085
+ const rawHost = cleanText(req.headers.host || "");
18086
+ const candidateOrigin = rawOrigin || (rawHost ? `https://${rawHost}` : "");
18087
+ if (!candidateOrigin) {
18088
+ throw hazbasePasskeyLocalHostError();
18089
+ }
18090
+
18091
+ let parsed;
18092
+ try {
18093
+ parsed = new URL(candidateOrigin);
18094
+ } catch {
18095
+ throw hazbasePasskeyLocalHostError();
18096
+ }
18097
+
18098
+ const hostname = cleanText(parsed.hostname || "").toLowerCase();
18099
+ if (parsed.protocol !== "https:" || !hostname || net.isIP(hostname) || !hostname.endsWith(".local")) {
18100
+ throw hazbasePasskeyLocalHostError();
18101
+ }
18102
+
18103
+ return {
18104
+ origin: `${parsed.protocol}//${parsed.host}`.toLowerCase(),
18105
+ rpId: hostname,
18106
+ clientKey: config.hazbaseClientKey || "viveworker-internal",
18107
+ };
18108
+ }
18109
+
18110
+ function configureHazbaseClient(config) {
18111
+ try {
18112
+ setHazbaseClientKey(config.hazbaseClientKey || "viveworker-internal");
18113
+ setHazbaseApiEndpoint(config.hazbaseApiUrl || "https://api.hazbase.com");
18114
+ } catch (error) {
18115
+ console.error(`[hazbase-config] ${error.message}`);
18116
+ }
18117
+ }
18118
+
18119
+ async function fetchHazbaseMetadata(config, endpoint, timeoutMs = HAZBASE_METADATA_TIMEOUT_MS) {
18120
+ const baseUrl = stripTrailingSlash(config?.hazbaseApiUrl || "");
18121
+ if (!baseUrl) return null;
18122
+ const timeout = Math.max(250, Number(timeoutMs) || HAZBASE_METADATA_TIMEOUT_MS);
18123
+ const controller = new AbortController();
18124
+ const timer = setTimeout(() => controller.abort(), timeout);
18125
+ timer.unref?.();
18126
+ try {
18127
+ const response = await fetch(`${baseUrl}${endpoint}`, {
18128
+ method: "GET",
18129
+ headers: { Accept: "application/json" },
18130
+ signal: controller.signal,
18131
+ });
18132
+ if (!response.ok) {
18133
+ const errorText = await response.text().catch(() => "");
18134
+ throw new Error(`${endpoint} failed: ${cleanText(errorText || response.statusText || "upstream error")}`);
18135
+ }
18136
+ const payload = await response.json().catch(() => null);
18137
+ return payload?.data ?? payload ?? null;
18138
+ } catch (error) {
18139
+ if (error?.name === "AbortError") {
18140
+ throw new Error(`${endpoint} timed out after ${timeout}ms`);
18141
+ }
18142
+ throw error;
18143
+ } finally {
18144
+ clearTimeout(timer);
18145
+ }
18146
+ }
18147
+
18148
+ async function getNpmVersionStatus(runtime, config) {
18149
+ const now = Date.now();
18150
+ const cached = runtime.npmVersionStatusCache;
18151
+ if (cached && now - Number(cached.checkedAtMs || 0) < NPM_VERSION_CHECK_CACHE_TTL_MS) {
18152
+ return cached;
18153
+ }
18154
+ if (runtime.npmVersionStatusPending) {
18155
+ return runtime.npmVersionStatusPending;
18156
+ }
18157
+ runtime.npmVersionStatusPending = fetchNpmVersionStatus(config)
18158
+ .then((status) => {
18159
+ runtime.npmVersionStatusCache = status;
18160
+ return status;
18161
+ })
18162
+ .catch((error) => {
18163
+ const fallback = {
18164
+ packageName: config.npmPackageName || "viveworker",
18165
+ distTag: config.npmDistTag || "latest",
18166
+ currentVersion: appPackageVersion,
18167
+ latestVersion: "",
18168
+ updateAvailable: false,
18169
+ checkedAtMs: Date.now(),
18170
+ error: cleanText(error?.message || "npm version check failed"),
18171
+ };
18172
+ runtime.npmVersionStatusCache = fallback;
18173
+ return fallback;
18174
+ })
18175
+ .finally(() => {
18176
+ runtime.npmVersionStatusPending = null;
18177
+ });
18178
+ return runtime.npmVersionStatusPending;
18179
+ }
18180
+
18181
+ async function fetchNpmVersionStatus(config) {
18182
+ const packageName = cleanText(config.npmPackageName || "viveworker") || "viveworker";
18183
+ const distTag = cleanText(config.npmDistTag || "latest") || "latest";
18184
+ const registryBaseUrl = stripTrailingSlash(config.npmRegistryUrl || "https://registry.npmjs.org");
18185
+ const endpoint = `${registryBaseUrl}/${encodeURIComponent(packageName).replace(/^%40/u, "@")}/${encodeURIComponent(distTag)}`;
18186
+ const controller = new AbortController();
18187
+ const timer = setTimeout(() => controller.abort(), NPM_VERSION_CHECK_TIMEOUT_MS);
18188
+ timer.unref?.();
18189
+ try {
18190
+ const response = await fetch(endpoint, {
18191
+ method: "GET",
18192
+ headers: { Accept: "application/json" },
18193
+ signal: controller.signal,
18194
+ });
18195
+ if (!response.ok) {
18196
+ throw new Error(`npm registry returned ${response.status}`);
18197
+ }
18198
+ const payload = await response.json().catch(() => null);
18199
+ const latestVersion = cleanText(payload?.version || "");
18200
+ if (!latestVersion) {
18201
+ throw new Error("npm registry response did not include a version");
18202
+ }
18203
+ return {
18204
+ packageName,
18205
+ distTag,
18206
+ currentVersion: appPackageVersion,
18207
+ latestVersion,
18208
+ updateAvailable: comparePackageVersions(latestVersion, appPackageVersion) > 0,
18209
+ checkedAtMs: Date.now(),
18210
+ error: "",
18211
+ };
18212
+ } catch (error) {
18213
+ if (error?.name === "AbortError") {
18214
+ throw new Error("npm version check timed out");
18215
+ }
18216
+ throw error;
18217
+ } finally {
18218
+ clearTimeout(timer);
18219
+ }
18220
+ }
18221
+
18222
+ function comparePackageVersions(left, right) {
18223
+ const a = parsePackageVersion(left);
18224
+ const b = parsePackageVersion(right);
18225
+ for (let index = 0; index < 3; index += 1) {
18226
+ if (a.parts[index] !== b.parts[index]) return a.parts[index] > b.parts[index] ? 1 : -1;
18227
+ }
18228
+ if (!a.prerelease.length && b.prerelease.length) return 1;
18229
+ if (a.prerelease.length && !b.prerelease.length) return -1;
18230
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
18231
+ for (let index = 0; index < length; index += 1) {
18232
+ const av = a.prerelease[index];
18233
+ const bv = b.prerelease[index];
18234
+ if (av == null && bv == null) return 0;
18235
+ if (av == null) return -1;
18236
+ if (bv == null) return 1;
18237
+ const an = /^\d+$/u.test(av) ? Number(av) : null;
18238
+ const bn = /^\d+$/u.test(bv) ? Number(bv) : null;
18239
+ if (an != null && bn != null && an !== bn) return an > bn ? 1 : -1;
18240
+ if (an != null && bn == null) return -1;
18241
+ if (an == null && bn != null) return 1;
18242
+ const cmp = av.localeCompare(bv);
18243
+ if (cmp !== 0) return cmp > 0 ? 1 : -1;
18244
+ }
18245
+ return 0;
18246
+ }
18247
+
18248
+ function parsePackageVersion(value) {
18249
+ const normalized = cleanText(value || "").replace(/^v/iu, "").split("+")[0];
18250
+ const [core, prerelease = ""] = normalized.split("-", 2);
18251
+ const parts = core.split(".").map((part) => Number(part)).map((part) => (Number.isFinite(part) && part >= 0 ? part : 0));
18252
+ while (parts.length < 3) parts.push(0);
18253
+ return {
18254
+ parts: parts.slice(0, 3),
18255
+ prerelease: prerelease ? prerelease.split(".").map((part) => cleanText(part)).filter(Boolean) : [],
18256
+ };
18257
+ }
18258
+
18259
+ function mergeHazbaseAccountSummaries(existing, incoming) {
18260
+ const merged = new Map();
18261
+ for (const entry of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(incoming) ? incoming : [])]) {
18262
+ if (!entry || !entry.smartAccountAddress) continue;
18263
+ const key = `${Number(entry.chainId) || 0}:${String(entry.smartAccountAddress).toLowerCase()}`;
18264
+ merged.set(key, entry);
18265
+ }
18266
+ return [...merged.values()];
18267
+ }
18268
+
18269
+ function resolveHazbaseAccountForChain(accounts, chainId) {
18270
+ const normalizedChainId = Number(chainId || 0);
18271
+ if (!normalizedChainId) return null;
18272
+ const list = Array.isArray(accounts) ? accounts : [];
18273
+ return list.find((entry) => Number(entry?.chainId) === normalizedChainId && cleanText(entry?.smartAccountAddress || "")) || null;
18274
+ }
18275
+
18276
+
18277
+ async function completeHazbaseWalletPaymentApproval({ config, runtime, state, approval }) {
18278
+ const hazbase = normalizeHazbaseState(state.hazbase);
18279
+ if (!hazbase.accessToken) throw codedError("hazbase-auth-required", 401);
18280
+ if (!hazbase.deviceBindingId) throw codedError("hazbase-passkey-required", 409);
18281
+ if (!hazbase.highTrustToken) throw codedError("hazbase-reauth-required", 409);
18282
+ const chainId = Number(approval.rawParams?.chainId || 0);
18283
+ const account = resolveHazbaseAccountForChain(hazbase.accounts, chainId);
18284
+ if (!account?.smartAccountAddress) throw codedError("hazbase-wallet-account-missing", 409);
18285
+
18286
+ let result;
18287
+ try {
18288
+ result = await postHazbaseJson(config, "/api/payments/x402/hazbase-wallet/pay", {
18289
+ paymentRequestId: approval.paymentRequestId,
18290
+ deviceBindingId: hazbase.deviceBindingId,
18291
+ highTrustToken: hazbase.highTrustToken,
18292
+ smartAccountAddress: account.smartAccountAddress,
18293
+ waitForReceipt: true,
18294
+ }, hazbase.accessToken, 130_000);
18295
+ } catch (error) {
18296
+ if (isHazbaseInvalidAppSessionError(error)) {
18297
+ markHazbaseSessionInvalid(state);
18298
+ await saveState(config.stateFile, state);
18299
+ throw codedError("hazbase-session-expired", 401, {
18300
+ message: "Hazbase wallet session expired. Sign in again from Wallet settings.",
18301
+ });
18302
+ }
18303
+ throw error;
18304
+ }
18305
+
18306
+ if (!result?.xPayment) {
18307
+ throw codedError(result?.errorCode || result?.error || "hazbase-wallet-payment-failed", 502);
18308
+ }
18309
+
18310
+ const payload = {
18311
+ paid: true,
18312
+ decision: "accept",
18313
+ paymentRequestId: approval.paymentRequestId,
18314
+ xPayment: cleanText(result.xPayment || ""),
18315
+ payer: cleanText(result.payer || account.smartAccountAddress || ""),
18316
+ chainId: Number(result.chainId || chainId),
18317
+ network: cleanText(result.network || approval.rawParams?.network || ""),
18318
+ relayMode: cleanText(result.relayMode || ""),
18319
+ submittedUserOpHash: cleanText(result.submittedUserOpHash || ""),
18320
+ transactionHash: cleanText(result.transactionHash || ""),
18321
+ payment: result,
18322
+ };
18323
+
18324
+ approval.resolved = true;
18325
+ approval.resolving = false;
18326
+ runtime.nativeApprovalsByToken.delete(approval.token);
18327
+ runtime.nativeApprovalsByRequestKey.delete(approval.requestKey);
18328
+ approval.resolveClaudeWaiter?.(payload);
18329
+ const stateChanged = recordActionHistoryItem({
18330
+ config,
18331
+ runtime,
18332
+ state,
18333
+ kind: "approval",
18334
+ stableId: `approval:${approval.requestKey}:${Date.now()}`,
18335
+ token: approval.token,
18336
+ title: approval.title,
18337
+ threadLabel: approval.threadLabel || "",
18338
+ messageText: `${approvalDecisionMessage("accept", config.defaultLocale, approval.provider)}\n\n${approval.messageText}\n\nTransaction: ${payload.transactionHash || payload.submittedUserOpHash}`,
18339
+ summary: formatNotificationBody(approval.messageText, 160) || approvalDecisionMessage("accept", config.defaultLocale, approval.provider),
18340
+ fileRefs: [],
18341
+ diffText: "",
18342
+ diffSource: "",
18343
+ diffAvailable: false,
18344
+ diffAddedLines: 0,
18345
+ diffRemovedLines: 0,
18346
+ outcome: "approved",
18347
+ provider: approval.provider || "viveworker",
18348
+ });
18349
+ if (stateChanged) await saveState(config.stateFile, state);
18350
+ console.log(`[hazbase-wallet-payment] ${approval.requestKey} | ${payload.transactionHash || payload.submittedUserOpHash}`);
18351
+ return { ok: true, ...payload };
18352
+ }
18353
+
18354
+ async function postHazbaseJson(config, endpoint, body, bearerToken, timeoutMs = 30_000) {
18355
+ const baseUrl = stripTrailingSlash(config?.hazbaseApiUrl || "");
18356
+ if (!baseUrl) throw codedError("hazbase-api-not-configured", 503);
18357
+ const controller = new AbortController();
18358
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
18359
+ timer.unref?.();
18360
+ try {
18361
+ const response = await fetch(`${baseUrl}${endpoint}`, {
18362
+ method: "POST",
18363
+ headers: {
18364
+ "content-type": "application/json",
18365
+ accept: "application/json",
18366
+ authorization: `Bearer ${bearerToken}`,
18367
+ "x-request-id": `viveworker_${crypto.randomUUID()}`,
18368
+ },
18369
+ body: JSON.stringify(body),
18370
+ signal: controller.signal,
18371
+ });
18372
+ const payload = await response.json().catch(() => null);
18373
+ const data = payload?.data || payload || {};
18374
+ if (!response.ok) {
18375
+ throw codedError(data?.errorCode || data?.code || data?.error || `hazbase-api-${response.status}`, response.status, data);
18376
+ }
18377
+ return data;
18378
+ } catch (error) {
18379
+ if (error?.name === "AbortError") throw codedError("hazbase-api-timeout", 504);
18380
+ throw error;
18381
+ } finally {
18382
+ clearTimeout(timer);
18383
+ }
18384
+ }
18385
+
18386
+ function codedError(code, statusCode = 500, details = null) {
18387
+ const error = new Error(code);
18388
+ error.code = code;
18389
+ error.statusCode = statusCode;
18390
+ error.details = details;
18391
+ return error;
18392
+ }
18393
+
15291
18394
  function validateConfig(config) {
15292
18395
  let publicBaseUrl = null;
15293
18396
  try {
@@ -15443,6 +18546,23 @@ async function loadState(stateFile) {
15443
18546
  try {
15444
18547
  const raw = await fs.readFile(stateFile, "utf8");
15445
18548
  const parsed = JSON.parse(raw);
18549
+ const hasExplicitWriteLaneState =
18550
+ typeof parsed.autoPilotWriteLaneContent === "boolean" ||
18551
+ typeof parsed.autoPilotWriteLaneUiTests === "boolean" ||
18552
+ typeof parsed.autoPilotWriteLaneSource === "boolean";
18553
+ const legacyTrustedWritesEnabled = parsed.autoPilotTrustedWrites === true || parsed.autoPilotTrustedWritesShadow === true;
18554
+ const autoPilotWriteLaneContent =
18555
+ typeof parsed.autoPilotWriteLaneContent === "boolean"
18556
+ ? parsed.autoPilotWriteLaneContent === true
18557
+ : !hasExplicitWriteLaneState && legacyTrustedWritesEnabled;
18558
+ const autoPilotWriteLaneUiTests =
18559
+ typeof parsed.autoPilotWriteLaneUiTests === "boolean"
18560
+ ? parsed.autoPilotWriteLaneUiTests === true
18561
+ : false;
18562
+ const autoPilotWriteLaneSource =
18563
+ typeof parsed.autoPilotWriteLaneSource === "boolean"
18564
+ ? parsed.autoPilotWriteLaneSource === true
18565
+ : false;
15446
18566
  return {
15447
18567
  fileOffsets: parsed.fileOffsets ?? {},
15448
18568
  seenEvents: parsed.seenEvents ?? {},
@@ -15468,7 +18588,14 @@ async function loadState(stateFile) {
15468
18588
  pairingConsumedAt: Number(parsed.pairingConsumedAt) || 0,
15469
18589
  pairingConsumedCredential: cleanText(parsed.pairingConsumedCredential ?? ""),
15470
18590
  claudeAwayMode: parsed.claudeAwayMode === true,
18591
+ autoPilotTrustedReads: parsed.autoPilotTrustedReads === true,
18592
+ autoPilotTrustedWrites: autoPilotWriteLaneContent || autoPilotWriteLaneUiTests || autoPilotWriteLaneSource,
18593
+ autoPilotTrustedWritesShadow: false,
18594
+ autoPilotWriteLaneContent,
18595
+ autoPilotWriteLaneUiTests,
18596
+ autoPilotWriteLaneSource,
15471
18597
  a2aAcceptPublicTasks: parsed.a2aAcceptPublicTasks === true,
18598
+ hazbase: normalizeHazbaseState(parsed.hazbase),
15472
18599
  };
15473
18600
  } catch {
15474
18601
  return {
@@ -15496,6 +18623,13 @@ async function loadState(stateFile) {
15496
18623
  pairingConsumedAt: 0,
15497
18624
  pairingConsumedCredential: "",
15498
18625
  claudeAwayMode: false,
18626
+ autoPilotTrustedReads: false,
18627
+ autoPilotTrustedWrites: false,
18628
+ autoPilotTrustedWritesShadow: false,
18629
+ autoPilotWriteLaneContent: false,
18630
+ autoPilotWriteLaneUiTests: false,
18631
+ autoPilotWriteLaneSource: false,
18632
+ hazbase: normalizeHazbaseState(null),
15499
18633
  };
15500
18634
  }
15501
18635
  }
@@ -15520,7 +18654,11 @@ async function syncClaudeAwayModeSentinel(config, enabled) {
15520
18654
  }
15521
18655
 
15522
18656
  async function saveState(stateFile, state) {
15523
- const output = JSON.stringify(state, null, 2);
18657
+ // No indent. The state file is ~8 MB and humans don't read it; pretty-print
18658
+ // just costs a few extra ms of stringify and ~10% more bytes on disk. The
18659
+ // single newline at the end keeps diffs/hexdumps working on the off chance
18660
+ // someone cats the file.
18661
+ const output = JSON.stringify(state);
15524
18662
  await fs.mkdir(path.dirname(stateFile), { recursive: true });
15525
18663
  await fs.writeFile(stateFile, `${output}\n`, "utf8");
15526
18664
  }
@@ -16128,6 +19266,7 @@ function refreshResolvedThreadLabels({ config, runtime, state }) {
16128
19266
  ) {
16129
19267
  runtime.recentCodeEvents = nextCodeEvents;
16130
19268
  state.recentCodeEvents = nextCodeEvents;
19269
+ invalidateDiffThreadGroupsCache();
16131
19270
  changed = true;
16132
19271
  }
16133
19272
 
@@ -16318,8 +19457,8 @@ function extractTitleOnlyJsonTitle(value) {
16318
19457
  }
16319
19458
 
16320
19459
  function preferTitleOnlyJsonThreadLabel(rawThreadLabel, conversationId, ...candidates) {
16321
- const preferredThreadLabel = sanitizeResolvedThreadLabel(rawThreadLabel, conversationId);
16322
- if (preferredThreadLabel) {
19460
+ const preferredThreadLabel = sanitizeResolvedThreadLabel(rawThreadLabel, conversationId);
19461
+ if (preferredThreadLabel && !isFallbackConversationLabel(preferredThreadLabel, conversationId)) {
16323
19462
  return preferredThreadLabel;
16324
19463
  }
16325
19464
  for (const candidate of candidates) {
@@ -16327,8 +19466,12 @@ function preferTitleOnlyJsonThreadLabel(rawThreadLabel, conversationId, ...candi
16327
19466
  if (titleOnlyJsonTitle) {
16328
19467
  return titleOnlyJsonTitle;
16329
19468
  }
19469
+ const derivedSdkCliLabel = deriveClaudeSdkCliThreadLabel(candidate, conversationId);
19470
+ if (derivedSdkCliLabel) {
19471
+ return derivedSdkCliLabel;
19472
+ }
16330
19473
  }
16331
- return cleanText(rawThreadLabel || "");
19474
+ return cleanText(preferredThreadLabel || rawThreadLabel || "");
16332
19475
  }
16333
19476
 
16334
19477
  function stripMarkdownLinks(value) {
@@ -16400,6 +19543,25 @@ function shortId(value) {
16400
19543
  return text.length > 8 ? text.slice(0, 8) : text;
16401
19544
  }
16402
19545
 
19546
+ function isFallbackTimelineTitle(rawTitle, kind, threadId) {
19547
+ const normalizedTitle = cleanText(rawTitle || "");
19548
+ const normalizedKind = cleanText(kind || "");
19549
+ const normalizedThreadId = cleanText(threadId || "");
19550
+ if (!normalizedTitle) {
19551
+ return true;
19552
+ }
19553
+ if (normalizedThreadId && normalizedTitle === shortId(normalizedThreadId)) {
19554
+ return true;
19555
+ }
19556
+ if (normalizedTitle === kindTitle(DEFAULT_LOCALE, normalizedKind)) {
19557
+ return true;
19558
+ }
19559
+ if (normalizedThreadId && normalizedTitle === formatTitle(kindTitle(DEFAULT_LOCALE, normalizedKind), shortId(normalizedThreadId))) {
19560
+ return true;
19561
+ }
19562
+ return false;
19563
+ }
19564
+
16403
19565
  function sleep(ms) {
16404
19566
  return new Promise((resolve) => setTimeout(resolve, ms));
16405
19567
  }
@@ -16554,6 +19716,7 @@ async function main() {
16554
19716
  migratedRecentCodeEventsStateChanged ||
16555
19717
  restoredPendingUserInputStateChanged ||
16556
19718
  backfilledMoltbookInboxChanged ||
19719
+ recoveredMissingProviderStateChanged ||
16557
19720
  refreshResolvedThreadLabels({ config, runtime, state })
16558
19721
  ) {
16559
19722
  await saveState(config.stateFile, state);
@@ -16660,7 +19823,16 @@ async function main() {
16660
19823
  try {
16661
19824
  const dirty = await scanOnce({ config, runtime, state });
16662
19825
  if (dirty) {
16663
- await saveState(config.stateFile, state);
19826
+ // Fire-and-forget via the debouncer rather than awaiting — the
19827
+ // ~8 MB state file used to block the scan loop for ~50-100 ms on
19828
+ // every dirty iteration, which directly delays the next rollout /
19829
+ // history.jsonl read and therefore how quickly new user_message
19830
+ // entries reach the phone. runtime/state.recentTimelineEntries are
19831
+ // already updated synchronously inside recordTimelineEntry, so the
19832
+ // /api/timeline response sees fresh data the moment the client
19833
+ // next polls, regardless of when the disk write lands. Pending
19834
+ // writes are drained in the `finally` block below on SIGINT/SIGTERM.
19835
+ scheduleSaveState(config, state);
16664
19836
  }
16665
19837
  } catch (error) {
16666
19838
  console.error(`[scan-error] ${error.message}`);
@@ -16737,5 +19909,10 @@ async function main() {
16737
19909
  if (approvalServer) {
16738
19910
  await stopHttpServer(approvalServer);
16739
19911
  }
19912
+
19913
+ // Drain any state mutations that were queued via scheduleSaveState but
19914
+ // not yet written to disk — otherwise SIGINT/SIGTERM mid-debounce would
19915
+ // silently drop the last ~500 ms of settings/notification changes.
19916
+ await flushPendingStateWrite(config, state);
16740
19917
  }
16741
19918
  }