@thinkrun/cli 0.1.28 → 0.1.30

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.
@@ -9,9 +9,10 @@ import { join } from 'path';
9
9
  import { randomUUID } from 'crypto';
10
10
  import { ApiError, SessionError } from '../errors.js';
11
11
  import { config } from '../config/store.js';
12
+ import { resolveTabAuditState } from '@thinkrun/shared/audit-state';
12
13
  import { readBridgePort, DEFAULT_BRIDGE_PORT, BRIDGE_HEALTH_PROBE_TIMEOUT_MS, BRIDGE_REQUEST_TIMEOUT_MS, unwrapEvaluatePayload, } from '../utils.js';
13
14
  import { clearLocalSessionContext, getLocalSessionContext, setLocalSessionContext } from '../session/context.js';
14
- import { initCliSession, syncBrowserAction, syncScreenshotAction, closeCliSession, readCliSessionId, readCliNetworkCursor, writeCliNetworkCursor, } from '../session/cli-session-sync.js';
15
+ import { initCliSession, syncBrowserAction, syncScreenshotAction, closeCliSession, readCliSessionId, readCliNetworkCursor, readCliAuditScreenshotState, writeCliNetworkCursor, writeCliAuditScreenshotState, unrefAbortSignal, } from '../session/cli-session-sync.js';
15
16
  import { acquireLock, getLockedBy, getWorkingLocation, releaseWorkingLocation, setWorkingLocation, updateWorkingLocationControlSession } from '../working-location.js';
16
17
  import { resolveAgentId } from '../session/agent-identity.js';
17
18
  import { localCommandRequiresTabError } from '../session/errors.js';
@@ -22,12 +23,19 @@ import { captureFingerprint, compareFingerprints } from '../obstacle-recovery/st
22
23
  import { classifyElementFailure } from '../obstacle-recovery/obstacle-classifier.js';
23
24
  import { getLocalCommandRetryProfileFromPath, } from './local-command-retry.js';
24
25
  import { LOCAL_BRIDGE_TYPED_TRANSPORT_RECOVERY_ATTEMPTS, LOCAL_BRIDGE_TYPED_EXTENSION_REVALIDATION_DELAYS_MS, LOCAL_BRIDGE_TYPED_RECENT_DISCONNECT_GRACE_MS, } from '../local-bridge-timing.js';
26
+ import { redactSensitiveResult } from '@thinkrun/shared/sensitive-result-redaction';
25
27
  /** Tunable post-action settle delay (ms). React batches setState updates
26
28
  * (~16ms); 50ms gives margin for async DOM mutations to settle. */
27
29
  const POST_ACTION_SETTLE_MS = 50;
28
30
  const FINGERPRINT_EVALUATE_TIMEOUT_MS = 2500;
29
31
  const MAX_NETWORK_REQUESTS_PER_SYNC = 200;
30
32
  const FINAL_NETWORK_FLUSH_TIMEOUT_MS = 5_000;
33
+ const AUDIT_SCREENSHOT_TIMEOUT_MS = 8_000;
34
+ const AUDIT_SCREENSHOT_SESSION_CEILING = 500;
35
+ const AUDIT_SCREENSHOT_RECENT_KEY_LIMIT = 64;
36
+ // Leave 1KB headroom under the backend's 10KB result cap so metadata wrapping
37
+ // and transport encoding cannot push an otherwise-valid payload over the limit.
38
+ const MAX_SYNC_RESULT_JSON_LENGTH = 9_000;
31
39
  /**
32
40
  * Actionable hints keyed by native host error codes.
33
41
  * These are surfaced in structured error output (--json mode) and TTY tips.
@@ -58,11 +66,89 @@ export function getNativeCodeHint(code) {
58
66
  return undefined;
59
67
  return NATIVE_CODE_HINTS[code];
60
68
  }
69
+ function sanitizeAndTruncateSyncResult(value) {
70
+ return truncateSyncResult(redactSensitiveResult(value));
71
+ }
72
+ function truncateSyncResult(value) {
73
+ if (value === undefined)
74
+ return undefined;
75
+ if (typeof value === 'string') {
76
+ if (JSON.stringify(value).length <= MAX_SYNC_RESULT_JSON_LENGTH)
77
+ return value;
78
+ const suffix = '... [truncated]';
79
+ let low = 0;
80
+ let high = value.length;
81
+ let best = suffix;
82
+ while (low <= high) {
83
+ const mid = Math.floor((low + high) / 2);
84
+ const candidate = `${value.slice(0, mid)}${suffix}`;
85
+ if (JSON.stringify(candidate).length <= MAX_SYNC_RESULT_JSON_LENGTH) {
86
+ best = candidate;
87
+ low = mid + 1;
88
+ }
89
+ else {
90
+ high = mid - 1;
91
+ }
92
+ }
93
+ return best;
94
+ }
95
+ try {
96
+ const serialized = JSON.stringify(value);
97
+ if (serialized.length <= MAX_SYNC_RESULT_JSON_LENGTH)
98
+ return value;
99
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
100
+ const next = {};
101
+ for (const [key, entry] of Object.entries(value)) {
102
+ next[key] = truncateSyncResult(entry);
103
+ if (JSON.stringify(next).length > MAX_SYNC_RESULT_JSON_LENGTH) {
104
+ next[key] = '[truncated]';
105
+ if (JSON.stringify(next).length > MAX_SYNC_RESULT_JSON_LENGTH) {
106
+ delete next[key];
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ while (Object.keys(next).length > 0 && JSON.stringify(next).length > MAX_SYNC_RESULT_JSON_LENGTH) {
112
+ const lastKey = Object.keys(next).pop();
113
+ if (!lastKey)
114
+ break;
115
+ delete next[lastKey];
116
+ }
117
+ if (JSON.stringify(next).length <= MAX_SYNC_RESULT_JSON_LENGTH)
118
+ return next;
119
+ return truncateSyncResult(serialized);
120
+ }
121
+ if (Array.isArray(value)) {
122
+ const next = [];
123
+ for (const entry of value) {
124
+ next.push(truncateSyncResult(entry));
125
+ if (JSON.stringify(next).length > MAX_SYNC_RESULT_JSON_LENGTH) {
126
+ next[next.length - 1] = '[truncated]';
127
+ if (JSON.stringify(next).length > MAX_SYNC_RESULT_JSON_LENGTH) {
128
+ next.pop();
129
+ break;
130
+ }
131
+ }
132
+ }
133
+ while (next.length > 0 && JSON.stringify(next).length > MAX_SYNC_RESULT_JSON_LENGTH) {
134
+ next.pop();
135
+ }
136
+ if (JSON.stringify(next).length <= MAX_SYNC_RESULT_JSON_LENGTH)
137
+ return next;
138
+ return truncateSyncResult(serialized);
139
+ }
140
+ return '[truncated]';
141
+ }
142
+ catch {
143
+ return '[unserializable result omitted]';
144
+ }
145
+ }
61
146
  export class LocalAdapter {
62
147
  baseUrl;
63
148
  defaultTimeout = 60000;
64
149
  validatedTabCache = new Map();
65
150
  syncedNetworkRequestMarkers = new Map();
151
+ auditScreenshotChains = new Map();
66
152
  TAB_CACHE_TTL_MS = 60_000;
67
153
  /** Injected fetch function — allows unit tests to intercept all HTTP calls. */
68
154
  fetchFn;
@@ -117,6 +203,102 @@ export class LocalAdapter {
117
203
  ? request.id
118
204
  : undefined;
119
205
  }
206
+ getScreenshotMimeType(format) {
207
+ return format === 'jpeg' ? 'image/jpeg'
208
+ : format === 'webp' ? 'image/webp'
209
+ : 'image/png';
210
+ }
211
+ buildAuditScreenshotCaption(type, details) {
212
+ const subject = typeof details['selector'] === 'string' ? details['selector']
213
+ : typeof details['url'] === 'string' ? details['url']
214
+ : typeof details['key'] === 'string' ? details['key']
215
+ : 'page state';
216
+ return `Audit after ${type}: ${subject}`.slice(0, 240);
217
+ }
218
+ async runSerializedAuditScreenshotCapture(key, task) {
219
+ const prior = this.auditScreenshotChains.get(key) ?? Promise.resolve();
220
+ const current = prior.catch(() => { }).then(task);
221
+ this.auditScreenshotChains.set(key, current);
222
+ try {
223
+ await current;
224
+ }
225
+ finally {
226
+ if (this.auditScreenshotChains.get(key) === current) {
227
+ this.auditScreenshotChains.delete(key);
228
+ }
229
+ }
230
+ }
231
+ async captureAuditScreenshot(tabId, type, details) {
232
+ const auditState = resolveTabAuditState({ tabId });
233
+ if (!auditState.enabled)
234
+ return;
235
+ const persistedSessionId = readCliSessionId(tabId);
236
+ if (!persistedSessionId)
237
+ return;
238
+ const captureKey = `${tabId}:${persistedSessionId}`;
239
+ await this.runSerializedAuditScreenshotCapture(captureKey, async () => {
240
+ const dedupKey = typeof details['__auditActionId'] === 'string'
241
+ ? `${type}:${details['__auditActionId']}`
242
+ : undefined;
243
+ const persistedAuditState = readCliAuditScreenshotState(tabId, persistedSessionId) ?? { count: 0, recentKeys: [] };
244
+ if (dedupKey && persistedAuditState.recentKeys.includes(dedupKey))
245
+ return;
246
+ if (persistedAuditState.count >= AUDIT_SCREENSHOT_SESSION_CEILING) {
247
+ console.warn(`[thinkrun] audit screenshot ceiling reached for session ${persistedSessionId}; dropping additional audit frames`);
248
+ return;
249
+ }
250
+ const sessionId = this.tabOverride ? undefined : this.getAgentSessionId();
251
+ const headers = { 'Content-Type': 'application/json' };
252
+ if (sessionId)
253
+ headers['x-session-id'] = sessionId;
254
+ try {
255
+ const response = await this.fetchFn(`${this.baseUrl}/sessions/${tabId}/screenshot`, {
256
+ method: 'POST',
257
+ headers,
258
+ body: JSON.stringify({ type: 'png' }),
259
+ signal: unrefAbortSignal(AUDIT_SCREENSHOT_TIMEOUT_MS),
260
+ });
261
+ const json = await response.json();
262
+ if (!response.ok || json?.success === false)
263
+ return;
264
+ const screenshot = json?.data && typeof json.data === 'object'
265
+ ? json.data.screenshot
266
+ : json?.data;
267
+ if (typeof screenshot !== 'string' || screenshot.length === 0)
268
+ return;
269
+ const delivered = await syncScreenshotAction(persistedSessionId, screenshot, this.getScreenshotMimeType('png'), this.buildAuditScreenshotCaption(type, details), typeof details['__auditTimestamp'] === 'string' ? details['__auditTimestamp'] : undefined, this.fetchFn);
270
+ if (!delivered)
271
+ return;
272
+ writeCliAuditScreenshotState(tabId, persistedSessionId, {
273
+ count: persistedAuditState.count + 1,
274
+ recentKeys: dedupKey
275
+ ? [...persistedAuditState.recentKeys, dedupKey].slice(-AUDIT_SCREENSHOT_RECENT_KEY_LIMIT)
276
+ : persistedAuditState.recentKeys,
277
+ });
278
+ }
279
+ catch {
280
+ // Fire-and-forget audit capture must never break the primary action path.
281
+ }
282
+ });
283
+ }
284
+ triggerAuditScreenshot(tabId, type, details) {
285
+ this.captureAuditScreenshot(tabId, type, details).catch(() => {
286
+ // Explicit swallow so callers never observe audit capture failures.
287
+ });
288
+ }
289
+ createAuditActionId(type) {
290
+ return `${type}:${randomUUID()}`;
291
+ }
292
+ buildAuditCaptureDetails(type, actionId, details) {
293
+ const captureDetails = {
294
+ ...details,
295
+ // Post-action capture timestamp used to order deferred audit screenshots.
296
+ __auditTimestamp: new Date().toISOString(),
297
+ };
298
+ if (!actionId)
299
+ return captureDetails;
300
+ return { ...captureDetails, __auditActionId: actionId };
301
+ }
120
302
  getNetworkRequestMarker(request) {
121
303
  const method = typeof request.method === 'string' ? request.method : undefined;
122
304
  const url = typeof request.url === 'string' ? request.url : undefined;
@@ -711,7 +893,7 @@ export class LocalAdapter {
711
893
  apiErr.retryHint = json.retryHint;
712
894
  throw apiErr;
713
895
  }
714
- return json;
896
+ return { ...json, __thinkrunRequestId: requestId };
715
897
  }
716
898
  catch (error) {
717
899
  this.logDiagnostics('bridge_request_error', {
@@ -823,6 +1005,7 @@ export class LocalAdapter {
823
1005
  // --- Navigation ---
824
1006
  async navigate(url, options = {}) {
825
1007
  const tabId = await this.getActiveTabId();
1008
+ const auditActionId = this.createAuditActionId('navigate');
826
1009
  try {
827
1010
  const response = await this.request(`/sessions/${tabId}/navigate`, {
828
1011
  method: 'POST',
@@ -837,7 +1020,9 @@ export class LocalAdapter {
837
1020
  const syncedUrl = typeof response.data?.url === 'string' && response.data.url.length > 0
838
1021
  ? response.data.url
839
1022
  : url;
840
- this.syncAction(tabId, 'navigate', { url: syncedUrl });
1023
+ const syncDetails = { url: syncedUrl };
1024
+ this.syncAction(tabId, 'navigate', syncDetails);
1025
+ this.triggerAuditScreenshot(tabId, 'navigate', this.buildAuditCaptureDetails('navigate', auditActionId, syncDetails));
841
1026
  }
842
1027
  return { success: response.success, data: response.data };
843
1028
  }
@@ -860,16 +1045,24 @@ export class LocalAdapter {
860
1045
  }
861
1046
  async goBack() {
862
1047
  const tabId = await this.getActiveTabId();
1048
+ const auditActionId = this.createAuditActionId('go-back');
863
1049
  const response = await this.request(`/sessions/${tabId}/go-back`, { method: 'POST' });
864
- if (response.success)
865
- this.syncAction(tabId, 'go-back', {});
1050
+ if (response.success) {
1051
+ const syncDetails = {};
1052
+ this.syncAction(tabId, 'go-back', syncDetails);
1053
+ this.triggerAuditScreenshot(tabId, 'go-back', this.buildAuditCaptureDetails('go-back', auditActionId, syncDetails));
1054
+ }
866
1055
  return { success: response.success, data: response.data };
867
1056
  }
868
1057
  async goForward() {
869
1058
  const tabId = await this.getActiveTabId();
1059
+ const auditActionId = this.createAuditActionId('go-forward');
870
1060
  const response = await this.request(`/sessions/${tabId}/go-forward`, { method: 'POST' });
871
- if (response.success)
872
- this.syncAction(tabId, 'go-forward', {});
1061
+ if (response.success) {
1062
+ const syncDetails = {};
1063
+ this.syncAction(tabId, 'go-forward', syncDetails);
1064
+ this.triggerAuditScreenshot(tabId, 'go-forward', this.buildAuditCaptureDetails('go-forward', auditActionId, syncDetails));
1065
+ }
873
1066
  return { success: response.success, data: response.data };
874
1067
  }
875
1068
  // --- Fingerprint + obstacle helpers ─────────────────────────────────────
@@ -924,6 +1117,7 @@ export class LocalAdapter {
924
1117
  // --- Interaction ---
925
1118
  async click(selector, options = {}) {
926
1119
  const tabId = await this.getActiveTabId();
1120
+ const auditActionId = this.createAuditActionId('click');
927
1121
  // Capture pre-action fingerprint (best-effort)
928
1122
  const pre = await this.tryCaptureFP(selector);
929
1123
  let response;
@@ -966,12 +1160,16 @@ export class LocalAdapter {
966
1160
  }
967
1161
  catch { /* fingerprint failure — do not break normal flow */ }
968
1162
  }
969
- if (response.success)
970
- this.syncAction(tabId, 'click', { selector });
1163
+ if (response.success) {
1164
+ const syncDetails = { selector };
1165
+ this.syncAction(tabId, 'click', syncDetails);
1166
+ this.triggerAuditScreenshot(tabId, 'click', this.buildAuditCaptureDetails('click', auditActionId, syncDetails));
1167
+ }
971
1168
  return { success: response.success };
972
1169
  }
973
1170
  async clickAt(x, y, options = {}) {
974
1171
  const tabId = await this.getActiveTabId();
1172
+ const auditActionId = this.createAuditActionId('click_at');
975
1173
  // Coordinate clicks are an escape hatch when semantic targeting has already
976
1174
  // failed, so there is no stable selector to fingerprint pre/post here.
977
1175
  const response = await this.request(`/sessions/${tabId}/click-at`, {
@@ -984,12 +1182,16 @@ export class LocalAdapter {
984
1182
  timeout: options.timeout ?? 15000,
985
1183
  }),
986
1184
  });
987
- if (response.success)
988
- this.syncAction(tabId, 'click_at', { x, y, coordinateSpace: 'viewport' });
1185
+ if (response.success) {
1186
+ const syncDetails = { x, y, coordinateSpace: 'viewport' };
1187
+ this.syncAction(tabId, 'click_at', syncDetails);
1188
+ this.triggerAuditScreenshot(tabId, 'click_at', this.buildAuditCaptureDetails('click_at', auditActionId, syncDetails));
1189
+ }
989
1190
  return { success: response.success, data: response.data };
990
1191
  }
991
1192
  async type(selector, text, options = {}) {
992
1193
  const tabId = await this.getActiveTabId();
1194
+ const auditActionId = this.createAuditActionId('type');
993
1195
  const pre = await this.tryCaptureFP(selector);
994
1196
  let response;
995
1197
  try {
@@ -1023,12 +1225,16 @@ export class LocalAdapter {
1023
1225
  }
1024
1226
  catch { /* fingerprint failure — do not break normal flow */ }
1025
1227
  }
1026
- if (response.success)
1027
- this.syncAction(tabId, 'type', { selector });
1228
+ if (response.success) {
1229
+ const syncDetails = { selector };
1230
+ this.syncAction(tabId, 'type', syncDetails);
1231
+ this.triggerAuditScreenshot(tabId, 'type', this.buildAuditCaptureDetails('type', auditActionId, syncDetails));
1232
+ }
1028
1233
  return { success: response.success };
1029
1234
  }
1030
1235
  async fill(selector, value) {
1031
1236
  const tabId = await this.getActiveTabId();
1237
+ const auditActionId = this.createAuditActionId('fill');
1032
1238
  const pre = await this.tryCaptureFP(selector);
1033
1239
  let response;
1034
1240
  try {
@@ -1062,18 +1268,25 @@ export class LocalAdapter {
1062
1268
  }
1063
1269
  catch { /* fingerprint failure — do not break normal flow */ }
1064
1270
  }
1065
- if (response.success)
1066
- this.syncAction(tabId, 'fill', { selector });
1271
+ if (response.success) {
1272
+ const syncDetails = { selector };
1273
+ this.syncAction(tabId, 'fill', syncDetails);
1274
+ this.triggerAuditScreenshot(tabId, 'fill', this.buildAuditCaptureDetails('fill', auditActionId, syncDetails));
1275
+ }
1067
1276
  return { success: response.success };
1068
1277
  }
1069
1278
  async press(key) {
1070
1279
  const tabId = await this.getActiveTabId();
1280
+ const auditActionId = this.createAuditActionId('press');
1071
1281
  const response = await this.request(`/sessions/${tabId}/press`, {
1072
1282
  method: 'POST',
1073
1283
  body: JSON.stringify({ key }),
1074
1284
  });
1075
- if (response.success)
1076
- this.syncAction(tabId, 'press', { key });
1285
+ if (response.success) {
1286
+ const syncDetails = { key };
1287
+ this.syncAction(tabId, 'press', syncDetails);
1288
+ this.triggerAuditScreenshot(tabId, 'press', this.buildAuditCaptureDetails('press', auditActionId, syncDetails));
1289
+ }
1077
1290
  return { success: response.success };
1078
1291
  }
1079
1292
  async scroll(options) {
@@ -1081,6 +1294,7 @@ export class LocalAdapter {
1081
1294
  // Selector-based: scroll element into view via evaluate, but sync as 'scroll'
1082
1295
  // (not 'evaluate') so the activity feed shows the user-facing action type.
1083
1296
  if (options.selector) {
1297
+ const auditActionId = this.createAuditActionId('scroll');
1084
1298
  const resp = await this.request(`/sessions/${tabId}/evaluate`, {
1085
1299
  method: 'POST',
1086
1300
  body: JSON.stringify({
@@ -1088,8 +1302,11 @@ export class LocalAdapter {
1088
1302
  args: [],
1089
1303
  }),
1090
1304
  });
1091
- if (resp.success)
1092
- this.syncAction(tabId, 'scroll', { selector: options.selector });
1305
+ if (resp.success) {
1306
+ const syncDetails = { selector: options.selector };
1307
+ this.syncAction(tabId, 'scroll', syncDetails);
1308
+ this.triggerAuditScreenshot(tabId, 'scroll', this.buildAuditCaptureDetails('scroll', auditActionId, syncDetails));
1309
+ }
1093
1310
  return { success: resp.success, data: resp.data };
1094
1311
  }
1095
1312
  // Horizontal scroll is not supported by the native host's handleScroll (which only
@@ -1101,23 +1318,31 @@ export class LocalAdapter {
1101
1318
  // Translate y offset → {direction, amount} as expected by native host handleScroll
1102
1319
  const direction = (options.y !== undefined && options.y < 0) ? 'up' : 'down';
1103
1320
  const amount = options.y !== undefined ? Math.abs(options.y) : 500;
1321
+ const auditActionId = this.createAuditActionId('scroll');
1104
1322
  const response = await this.request(`/sessions/${tabId}/scroll`, {
1105
1323
  method: 'POST',
1106
1324
  body: JSON.stringify({ direction, amount }),
1107
1325
  });
1108
- if (response.success)
1109
- this.syncAction(tabId, 'scroll', { direction, amount });
1326
+ if (response.success) {
1327
+ const syncDetails = { direction, amount };
1328
+ this.syncAction(tabId, 'scroll', syncDetails);
1329
+ this.triggerAuditScreenshot(tabId, 'scroll', this.buildAuditCaptureDetails('scroll', auditActionId, syncDetails));
1330
+ }
1110
1331
  return { success: response.success };
1111
1332
  }
1112
1333
  async hover(selector) {
1113
1334
  const tabId = await this.getActiveTabId();
1335
+ const auditActionId = this.createAuditActionId('hover');
1114
1336
  try {
1115
1337
  const response = await this.request(`/sessions/${tabId}/hover`, {
1116
1338
  method: 'POST',
1117
1339
  body: JSON.stringify({ selector }),
1118
1340
  });
1119
- if (response.success)
1120
- this.syncAction(tabId, 'hover', { selector });
1341
+ if (response.success) {
1342
+ const syncDetails = { selector };
1343
+ this.syncAction(tabId, 'hover', syncDetails);
1344
+ this.triggerAuditScreenshot(tabId, 'hover', this.buildAuditCaptureDetails('hover', auditActionId, syncDetails));
1345
+ }
1121
1346
  return { success: response.success };
1122
1347
  }
1123
1348
  catch (err) {
@@ -1129,6 +1354,7 @@ export class LocalAdapter {
1129
1354
  }
1130
1355
  async select(selector, value) {
1131
1356
  const tabId = await this.getActiveTabId();
1357
+ const auditActionId = this.createAuditActionId('select');
1132
1358
  const pre = await this.tryCaptureFP(selector);
1133
1359
  let response;
1134
1360
  try {
@@ -1181,8 +1407,11 @@ export class LocalAdapter {
1181
1407
  }
1182
1408
  catch { /* fingerprint failure — do not break normal flow */ }
1183
1409
  }
1184
- if (response.success)
1185
- this.syncAction(tabId, 'select', { selector });
1410
+ if (response.success) {
1411
+ const syncDetails = { selector };
1412
+ this.syncAction(tabId, 'select', syncDetails);
1413
+ this.triggerAuditScreenshot(tabId, 'select', this.buildAuditCaptureDetails('select', auditActionId, syncDetails));
1414
+ }
1186
1415
  return { success: response.success };
1187
1416
  }
1188
1417
  // --- Waiting ---
@@ -1245,9 +1474,7 @@ export class LocalAdapter {
1245
1474
  const tempPath = join(tmpdir(), `thinkrun-${ts}-${randomUUID().slice(0, 8)}.png`);
1246
1475
  writeFileSync(tempPath, Buffer.from(b64, 'base64'));
1247
1476
  // Derive MIME type once — used for both the artifact upload and the sync action.
1248
- const mimeType = options.format === 'jpeg' ? 'image/jpeg'
1249
- : options.format === 'webp' ? 'image/webp'
1250
- : 'image/png';
1477
+ const mimeType = this.getScreenshotMimeType(options.format);
1251
1478
  // If this local tab has a registered cloud session (from startSession()), sync
1252
1479
  // the screenshot as an action in the activity feed. Fire-and-forget — never
1253
1480
  // blocks the caller.
@@ -1263,7 +1490,7 @@ export class LocalAdapter {
1263
1490
  if (!caption) {
1264
1491
  throw new Error('Local screenshots synced to ThinkRun require --caption <text>');
1265
1492
  }
1266
- syncScreenshotAction(persistedSessionId, b64, mimeType, caption, this.fetchFn);
1493
+ syncScreenshotAction(persistedSessionId, b64, mimeType, caption, undefined, this.fetchFn);
1267
1494
  }
1268
1495
  }
1269
1496
  // If API key is configured, upload to cloud artifact storage so the screenshot
@@ -1323,8 +1550,9 @@ export class LocalAdapter {
1323
1550
  multiple: options.all,
1324
1551
  }),
1325
1552
  });
1326
- if (response.success)
1327
- this.syncAction(tabId, 'extract', { selector: selector || 'page' });
1553
+ if (response.success) {
1554
+ this.syncAction(tabId, 'extract', { selector: selector || 'page' }, { result: sanitizeAndTruncateSyncResult(response.data) });
1555
+ }
1328
1556
  return { success: response.success, data: response.data };
1329
1557
  }
1330
1558
  async evaluate(script, args = [], evalOpts) {
@@ -1336,7 +1564,7 @@ export class LocalAdapter {
1336
1564
  timeoutMs: Math.min(timeoutMs + 15_000, 615_000),
1337
1565
  });
1338
1566
  if (response.success)
1339
- this.syncAction(tabId, 'evaluate', {}, { result: response.data });
1567
+ this.syncAction(tabId, 'evaluate', {}, { result: sanitizeAndTruncateSyncResult(response.data) });
1340
1568
  return { success: response.success, data: response.data };
1341
1569
  }
1342
1570
  async getUrl() {