@thinkrun/cli 0.1.27

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.
Files changed (183) hide show
  1. package/README.md +349 -0
  2. package/dist/bin/thinkrun.d.ts +6 -0
  3. package/dist/bin/thinkrun.d.ts.map +1 -0
  4. package/dist/bin/thinkrun.js +124 -0
  5. package/dist/bin/thinkrun.js.map +1 -0
  6. package/dist/scripts/browse.sh +1107 -0
  7. package/dist/src/adapters/cloud.d.ts +79 -0
  8. package/dist/src/adapters/cloud.d.ts.map +1 -0
  9. package/dist/src/adapters/cloud.js +637 -0
  10. package/dist/src/adapters/cloud.js.map +1 -0
  11. package/dist/src/adapters/index.d.ts +47 -0
  12. package/dist/src/adapters/index.d.ts.map +1 -0
  13. package/dist/src/adapters/index.js +211 -0
  14. package/dist/src/adapters/index.js.map +1 -0
  15. package/dist/src/adapters/local-command-retry.d.ts +12 -0
  16. package/dist/src/adapters/local-command-retry.d.ts.map +1 -0
  17. package/dist/src/adapters/local-command-retry.js +224 -0
  18. package/dist/src/adapters/local-command-retry.js.map +1 -0
  19. package/dist/src/adapters/local.d.ts +136 -0
  20. package/dist/src/adapters/local.d.ts.map +1 -0
  21. package/dist/src/adapters/local.js +1273 -0
  22. package/dist/src/adapters/local.js.map +1 -0
  23. package/dist/src/adapters/types.d.ts +45 -0
  24. package/dist/src/adapters/types.d.ts.map +1 -0
  25. package/dist/src/adapters/types.js +6 -0
  26. package/dist/src/adapters/types.js.map +1 -0
  27. package/dist/src/commands/actions.d.ts +135 -0
  28. package/dist/src/commands/actions.d.ts.map +1 -0
  29. package/dist/src/commands/actions.js +2207 -0
  30. package/dist/src/commands/actions.js.map +1 -0
  31. package/dist/src/commands/agent-init.d.ts +16 -0
  32. package/dist/src/commands/agent-init.d.ts.map +1 -0
  33. package/dist/src/commands/agent-init.js +222 -0
  34. package/dist/src/commands/agent-init.js.map +1 -0
  35. package/dist/src/commands/analyze.d.ts +11 -0
  36. package/dist/src/commands/analyze.d.ts.map +1 -0
  37. package/dist/src/commands/analyze.js +238 -0
  38. package/dist/src/commands/analyze.js.map +1 -0
  39. package/dist/src/commands/cache.d.ts +6 -0
  40. package/dist/src/commands/cache.d.ts.map +1 -0
  41. package/dist/src/commands/cache.js +147 -0
  42. package/dist/src/commands/cache.js.map +1 -0
  43. package/dist/src/commands/cloud.d.ts +6 -0
  44. package/dist/src/commands/cloud.d.ts.map +1 -0
  45. package/dist/src/commands/cloud.js +332 -0
  46. package/dist/src/commands/cloud.js.map +1 -0
  47. package/dist/src/commands/config.d.ts +7 -0
  48. package/dist/src/commands/config.d.ts.map +1 -0
  49. package/dist/src/commands/config.js +208 -0
  50. package/dist/src/commands/config.js.map +1 -0
  51. package/dist/src/commands/doctor.d.ts +127 -0
  52. package/dist/src/commands/doctor.d.ts.map +1 -0
  53. package/dist/src/commands/doctor.js +684 -0
  54. package/dist/src/commands/doctor.js.map +1 -0
  55. package/dist/src/commands/evaluate-helpers.d.ts +6 -0
  56. package/dist/src/commands/evaluate-helpers.d.ts.map +1 -0
  57. package/dist/src/commands/evaluate-helpers.js +13 -0
  58. package/dist/src/commands/evaluate-helpers.js.map +1 -0
  59. package/dist/src/commands/install.d.ts +118 -0
  60. package/dist/src/commands/install.d.ts.map +1 -0
  61. package/dist/src/commands/install.js +975 -0
  62. package/dist/src/commands/install.js.map +1 -0
  63. package/dist/src/commands/release.d.ts +7 -0
  64. package/dist/src/commands/release.d.ts.map +1 -0
  65. package/dist/src/commands/release.js +123 -0
  66. package/dist/src/commands/release.js.map +1 -0
  67. package/dist/src/commands/reset-connection.d.ts +17 -0
  68. package/dist/src/commands/reset-connection.d.ts.map +1 -0
  69. package/dist/src/commands/reset-connection.js +141 -0
  70. package/dist/src/commands/reset-connection.js.map +1 -0
  71. package/dist/src/commands/session-debug.d.ts +23 -0
  72. package/dist/src/commands/session-debug.d.ts.map +1 -0
  73. package/dist/src/commands/session-debug.js +267 -0
  74. package/dist/src/commands/session-debug.js.map +1 -0
  75. package/dist/src/commands/setup.d.ts +53 -0
  76. package/dist/src/commands/setup.d.ts.map +1 -0
  77. package/dist/src/commands/setup.js +249 -0
  78. package/dist/src/commands/setup.js.map +1 -0
  79. package/dist/src/config/store.d.ts +39 -0
  80. package/dist/src/config/store.d.ts.map +1 -0
  81. package/dist/src/config/store.js +290 -0
  82. package/dist/src/config/store.js.map +1 -0
  83. package/dist/src/daemon/access.d.ts +53 -0
  84. package/dist/src/daemon/access.d.ts.map +1 -0
  85. package/dist/src/daemon/access.js +87 -0
  86. package/dist/src/daemon/access.js.map +1 -0
  87. package/dist/src/daemon/bridge-envelope.d.ts +96 -0
  88. package/dist/src/daemon/bridge-envelope.d.ts.map +1 -0
  89. package/dist/src/daemon/bridge-envelope.js +235 -0
  90. package/dist/src/daemon/bridge-envelope.js.map +1 -0
  91. package/dist/src/daemon/utils.d.ts +43 -0
  92. package/dist/src/daemon/utils.d.ts.map +1 -0
  93. package/dist/src/daemon/utils.js +134 -0
  94. package/dist/src/daemon/utils.js.map +1 -0
  95. package/dist/src/errors.d.ts +60 -0
  96. package/dist/src/errors.d.ts.map +1 -0
  97. package/dist/src/errors.js +87 -0
  98. package/dist/src/errors.js.map +1 -0
  99. package/dist/src/local-bridge-timing.d.ts +31 -0
  100. package/dist/src/local-bridge-timing.d.ts.map +1 -0
  101. package/dist/src/local-bridge-timing.js +41 -0
  102. package/dist/src/local-bridge-timing.js.map +1 -0
  103. package/dist/src/obstacle-recovery/classify-script.d.ts +16 -0
  104. package/dist/src/obstacle-recovery/classify-script.d.ts.map +1 -0
  105. package/dist/src/obstacle-recovery/classify-script.js +53 -0
  106. package/dist/src/obstacle-recovery/classify-script.js.map +1 -0
  107. package/dist/src/obstacle-recovery/obstacle-classifier.d.ts +21 -0
  108. package/dist/src/obstacle-recovery/obstacle-classifier.d.ts.map +1 -0
  109. package/dist/src/obstacle-recovery/obstacle-classifier.js +37 -0
  110. package/dist/src/obstacle-recovery/obstacle-classifier.js.map +1 -0
  111. package/dist/src/obstacle-recovery/state-fingerprint.d.ts +26 -0
  112. package/dist/src/obstacle-recovery/state-fingerprint.d.ts.map +1 -0
  113. package/dist/src/obstacle-recovery/state-fingerprint.js +85 -0
  114. package/dist/src/obstacle-recovery/state-fingerprint.js.map +1 -0
  115. package/dist/src/obstacle-recovery/types.d.ts +44 -0
  116. package/dist/src/obstacle-recovery/types.d.ts.map +1 -0
  117. package/dist/src/obstacle-recovery/types.js +16 -0
  118. package/dist/src/obstacle-recovery/types.js.map +1 -0
  119. package/dist/src/output/formatter.d.ts +55 -0
  120. package/dist/src/output/formatter.d.ts.map +1 -0
  121. package/dist/src/output/formatter.js +55 -0
  122. package/dist/src/output/formatter.js.map +1 -0
  123. package/dist/src/output/mode.d.ts +11 -0
  124. package/dist/src/output/mode.d.ts.map +1 -0
  125. package/dist/src/output/mode.js +16 -0
  126. package/dist/src/output/mode.js.map +1 -0
  127. package/dist/src/protected-flow/detector.d.ts +26 -0
  128. package/dist/src/protected-flow/detector.d.ts.map +1 -0
  129. package/dist/src/protected-flow/detector.js +75 -0
  130. package/dist/src/protected-flow/detector.js.map +1 -0
  131. package/dist/src/protected-flow/types.d.ts +24 -0
  132. package/dist/src/protected-flow/types.d.ts.map +1 -0
  133. package/dist/src/protected-flow/types.js +28 -0
  134. package/dist/src/protected-flow/types.js.map +1 -0
  135. package/dist/src/session/agent-identity.d.ts +65 -0
  136. package/dist/src/session/agent-identity.d.ts.map +1 -0
  137. package/dist/src/session/agent-identity.js +133 -0
  138. package/dist/src/session/agent-identity.js.map +1 -0
  139. package/dist/src/session/cli-session-sync.d.ts +72 -0
  140. package/dist/src/session/cli-session-sync.d.ts.map +1 -0
  141. package/dist/src/session/cli-session-sync.js +244 -0
  142. package/dist/src/session/cli-session-sync.js.map +1 -0
  143. package/dist/src/session/context.d.ts +24 -0
  144. package/dist/src/session/context.d.ts.map +1 -0
  145. package/dist/src/session/context.js +165 -0
  146. package/dist/src/session/context.js.map +1 -0
  147. package/dist/src/session/continuity.d.ts +33 -0
  148. package/dist/src/session/continuity.d.ts.map +1 -0
  149. package/dist/src/session/continuity.js +179 -0
  150. package/dist/src/session/continuity.js.map +1 -0
  151. package/dist/src/session/errors.d.ts +9 -0
  152. package/dist/src/session/errors.d.ts.map +1 -0
  153. package/dist/src/session/errors.js +31 -0
  154. package/dist/src/session/errors.js.map +1 -0
  155. package/dist/src/session/local-continuity.d.ts +16 -0
  156. package/dist/src/session/local-continuity.d.ts.map +1 -0
  157. package/dist/src/session/local-continuity.js +146 -0
  158. package/dist/src/session/local-continuity.js.map +1 -0
  159. package/dist/src/session/signal-handler.d.ts +24 -0
  160. package/dist/src/session/signal-handler.d.ts.map +1 -0
  161. package/dist/src/session/signal-handler.js +35 -0
  162. package/dist/src/session/signal-handler.js.map +1 -0
  163. package/dist/src/shared/local-recovery-policy.d.ts +40 -0
  164. package/dist/src/shared/local-recovery-policy.d.ts.map +1 -0
  165. package/dist/src/shared/local-recovery-policy.js +59 -0
  166. package/dist/src/shared/local-recovery-policy.js.map +1 -0
  167. package/dist/src/shared/recovery-state.d.ts +3 -0
  168. package/dist/src/shared/recovery-state.d.ts.map +1 -0
  169. package/dist/src/shared/recovery-state.js +9 -0
  170. package/dist/src/shared/recovery-state.js.map +1 -0
  171. package/dist/src/types.d.ts +131 -0
  172. package/dist/src/types.d.ts.map +1 -0
  173. package/dist/src/types.js +5 -0
  174. package/dist/src/types.js.map +1 -0
  175. package/dist/src/utils.d.ts +50 -0
  176. package/dist/src/utils.d.ts.map +1 -0
  177. package/dist/src/utils.js +147 -0
  178. package/dist/src/utils.js.map +1 -0
  179. package/dist/src/working-location.d.ts +107 -0
  180. package/dist/src/working-location.d.ts.map +1 -0
  181. package/dist/src/working-location.js +651 -0
  182. package/dist/src/working-location.js.map +1 -0
  183. package/package.json +65 -0
@@ -0,0 +1,1273 @@
1
+ /**
2
+ * Local adapter — routes browser actions to the native host HTTP API
3
+ * (localhost:PORT/sessions/:tabId/*). No API key required; uses the
4
+ * active tabId stored by `thinkrun attach`.
5
+ */
6
+ import { writeFileSync } from 'fs';
7
+ import { tmpdir } from 'os';
8
+ import { join } from 'path';
9
+ import { randomUUID } from 'crypto';
10
+ import { ApiError, SessionError } from '../errors.js';
11
+ import { config } from '../config/store.js';
12
+ import { readBridgePort, DEFAULT_BRIDGE_PORT, BRIDGE_HEALTH_PROBE_TIMEOUT_MS, BRIDGE_REQUEST_TIMEOUT_MS, unwrapEvaluatePayload, } from '../utils.js';
13
+ import { clearLocalSessionContext, getLocalSessionContext, setLocalSessionContext } from '../session/context.js';
14
+ import { initCliSession, syncBrowserAction, syncScreenshotAction, closeCliSession, readCliSessionId } from '../session/cli-session-sync.js';
15
+ import { acquireLock, getLockedBy, getWorkingLocation, releaseWorkingLocation, setWorkingLocation, updateWorkingLocationControlSession } from '../working-location.js';
16
+ import { resolveAgentId } from '../session/agent-identity.js';
17
+ import { localCommandRequiresTabError } from '../session/errors.js';
18
+ import { assessLocalContinuity } from '../session/local-continuity.js';
19
+ import { classifyFromBridgeResult, classifyFromStateCheck } from '../protected-flow/detector.js';
20
+ import { captureFingerprint, compareFingerprints } from '../obstacle-recovery/state-fingerprint.js';
21
+ import { classifyElementFailure } from '../obstacle-recovery/obstacle-classifier.js';
22
+ import { getLocalCommandRetryProfileFromPath, } from './local-command-retry.js';
23
+ 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';
24
+ /** Tunable post-action settle delay (ms). React batches setState updates
25
+ * (~16ms); 50ms gives margin for async DOM mutations to settle. */
26
+ const POST_ACTION_SETTLE_MS = 50;
27
+ /**
28
+ * Actionable hints keyed by native host error codes.
29
+ * These are surfaced in structured error output (--json mode) and TTY tips.
30
+ */
31
+ const NATIVE_CODE_HINTS = {
32
+ EXTENSION_NOT_CONNECTED: 'Open a Chromium-based browser (e.g. Chrome or Helium) with the ThinkRun extension active',
33
+ TAB_NOT_FOUND: 'Tab was closed. Run: thinkrun tabs, then: thinkrun attach <tabId>',
34
+ ELEMENT_NOT_FOUND: 'Check selector. Run: thinkrun snapshot to inspect page structure',
35
+ NAVIGATION_FAILED: 'Check the URL and network connectivity',
36
+ REQUEST_TIMEOUT: 'Tab is taking a long time to respond — it may be a background tab throttled by the browser. Try bringing the tab into focus in the browser window, then retry.',
37
+ COMMAND_TIMEOUT: 'Extension command timed out; the tab may be frozen',
38
+ SCRIPT_ERROR: 'JavaScript error in evaluate script. Check the syntax',
39
+ TAB_NOT_OWNED: 'Tab not owned by this session. Run: thinkrun attach <tabId>',
40
+ TAB_OWNED_BY_OTHER_SESSION: 'Stay in local ThinkRun and run: thinkrun session debug --json. ' +
41
+ 'Use thinkrun tabs --json to inspect ownerSessionId for the tab. ' +
42
+ 'If the tab is truly owned by another live session, open a new tab/window instead of trying to take it over. ' +
43
+ 'If the tab should still belong to your current workflow, re-attach or follow the continuity guidance from session debug before retrying a mutating command. ' +
44
+ 'Do not use cloud start without an API key. Do not invent flags; see thinkrun attach --help. ' +
45
+ 'Human docs: https://thinkbrowse.io/docs — LLM-oriented summary: https://thinkbrowse.io/llms.txt (repo: docs/ERROR_CODES.md).',
46
+ TAB_LOCKED: 'Another local controller currently holds the tab lock or an exclusive stale-lock reclaim claim. ' +
47
+ 'Run: thinkrun session debug --json to inspect the current continuity state before retrying. ' +
48
+ 'If this is your own stale state, wait for the reclaim attempt to finish or re-run the command once the competing claim is gone.',
49
+ SESSION_NOT_FOUND: 'The attached local tab lost its bridge registration. Run: thinkrun session debug --json to confirm the persisted local tab binding, then re-register it with: thinkrun attach <tabId>.',
50
+ };
51
+ /** Actionable hint for native host error codes (TTY tips and JSON `hint` field). */
52
+ export function getNativeCodeHint(code) {
53
+ if (!code)
54
+ return undefined;
55
+ return NATIVE_CODE_HINTS[code];
56
+ }
57
+ export class LocalAdapter {
58
+ baseUrl;
59
+ defaultTimeout = 60000;
60
+ validatedTabCache = new Map();
61
+ TAB_CACHE_TTL_MS = 60_000;
62
+ /** Injected fetch function — allows unit tests to intercept all HTTP calls. */
63
+ fetchFn;
64
+ /**
65
+ * When set (via --tab flag or THINKRUN_TAB_ID env), bypasses the global
66
+ * working-location.json entirely. Each command targets this tab directly.
67
+ */
68
+ tabOverride;
69
+ readBridgePortFn;
70
+ diagnosticsEnabled;
71
+ constructor(port, fetchFn, tabOverride, readBridgePortFn = readBridgePort) {
72
+ this.readBridgePortFn = readBridgePortFn;
73
+ const resolvedPort = port ?? this.readBridgePortFn() ?? DEFAULT_BRIDGE_PORT;
74
+ this.baseUrl = `http://127.0.0.1:${resolvedPort}`;
75
+ this.fetchFn = fetchFn ?? globalThis.fetch;
76
+ this.tabOverride = tabOverride;
77
+ this.diagnosticsEnabled = process.env['THINKRUN_LOCAL_DIAGNOSTICS'] === '1';
78
+ }
79
+ /** Read the agent session ID from config (set by `thinkrun attach`). */
80
+ getAgentSessionId() {
81
+ return getLocalSessionContext()?.agentSessionId;
82
+ }
83
+ rethrowTabLock(error) {
84
+ if (error instanceof ApiError)
85
+ throw error;
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ if (message.startsWith('TAB_LOCKED')) {
88
+ throw new ApiError(message, undefined, getNativeCodeHint('TAB_LOCKED'), 'TAB_LOCKED', false);
89
+ }
90
+ throw error;
91
+ }
92
+ /**
93
+ * Fire-and-forget: sync a browser action to the cloud session activity feed.
94
+ * No-op if no cloud sessionId is persisted for the tab (e.g. no API key configured).
95
+ * Never throws, never blocks the caller.
96
+ */
97
+ syncAction(tabId, type, details) {
98
+ const sessionId = readCliSessionId(tabId);
99
+ if (!sessionId)
100
+ return;
101
+ syncBrowserAction(sessionId, type, details, this.fetchFn);
102
+ }
103
+ refreshBaseUrl() {
104
+ const nextPort = this.readBridgePortFn() ?? DEFAULT_BRIDGE_PORT;
105
+ this.baseUrl = `http://127.0.0.1:${nextPort}`;
106
+ }
107
+ isTransientBridgeError(error) {
108
+ if (!(error instanceof Error))
109
+ return false;
110
+ if (error.name === 'TimeoutError')
111
+ return true;
112
+ if (error.message === 'fetch failed')
113
+ return true;
114
+ const code = error.code;
115
+ return code === 'ECONNREFUSED';
116
+ }
117
+ isPreDispatchBridgeDisconnect(error) {
118
+ if (!(error instanceof Error))
119
+ return false;
120
+ if (error.name === 'TimeoutError')
121
+ return false;
122
+ if (error.message === 'fetch failed')
123
+ return true;
124
+ const code = error.code;
125
+ return code === 'ECONNREFUSED';
126
+ }
127
+ logDiagnostics(message, fields = {}) {
128
+ if (!this.diagnosticsEnabled)
129
+ return;
130
+ const suffix = Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : '';
131
+ process.stderr.write(`[thinkrun-local] ${message}${suffix}\n`);
132
+ }
133
+ async probeBridgeHealth(baseUrl = this.baseUrl) {
134
+ try {
135
+ const response = await this.fetchFn(`${baseUrl}/health`, {
136
+ signal: AbortSignal.timeout(BRIDGE_HEALTH_PROBE_TIMEOUT_MS),
137
+ });
138
+ if (!response.ok)
139
+ return { reachable: false };
140
+ const json = await response.json();
141
+ const inner = json?.data;
142
+ const extensionConnected = typeof inner?.extensionConnected === 'boolean'
143
+ ? inner.extensionConnected
144
+ : typeof json?.extensionConnected === 'boolean'
145
+ ? json.extensionConnected
146
+ : undefined;
147
+ const lastDisconnectReason = typeof inner?.lastExtensionDisconnectReason === 'string'
148
+ ? inner.lastExtensionDisconnectReason
149
+ : typeof json?.lastExtensionDisconnectReason === 'string'
150
+ ? json.lastExtensionDisconnectReason
151
+ : undefined;
152
+ const lastDisconnectAtRaw = typeof inner?.lastExtensionDisconnectAt === 'string'
153
+ ? inner.lastExtensionDisconnectAt
154
+ : typeof json?.lastExtensionDisconnectAt === 'string'
155
+ ? json.lastExtensionDisconnectAt
156
+ : undefined;
157
+ const recoveryState = typeof inner?.recoveryState === 'string'
158
+ ? inner.recoveryState
159
+ : typeof json?.recoveryState === 'string'
160
+ ? json.recoveryState
161
+ : undefined;
162
+ const parsedLastDisconnectAt = typeof lastDisconnectAtRaw === 'string'
163
+ ? Date.parse(lastDisconnectAtRaw)
164
+ : Number.NaN;
165
+ const lastDisconnectAt = Number.isNaN(parsedLastDisconnectAt)
166
+ ? undefined
167
+ : parsedLastDisconnectAt;
168
+ return {
169
+ reachable: true,
170
+ extensionConnected,
171
+ lastDisconnectReason,
172
+ lastDisconnectAt,
173
+ recoveryState,
174
+ };
175
+ }
176
+ catch {
177
+ return { reachable: false };
178
+ }
179
+ }
180
+ getLiveExtensionDisconnectHint(lastDisconnectReason, recoveryState) {
181
+ if (recoveryState === 'flapping') {
182
+ return 'A live bridge health probe confirms the local bridge is flapping from repeated disconnects. Avoid patient retry loops; reload the ThinkRun extension or stabilize the page/browser before retrying.';
183
+ }
184
+ if (typeof lastDisconnectReason === 'string' && lastDisconnectReason.startsWith('Circuit breaker:')) {
185
+ return 'A live bridge health probe confirms extensionConnected=false because the circuit breaker is open. Run: thinkrun reset-connection';
186
+ }
187
+ return 'A live bridge health probe confirms extensionConnected=false right now. Reload the ThinkRun extension in your Chromium-based browser, then retry.';
188
+ }
189
+ isRecentDisconnectedState(health) {
190
+ return health.extensionConnected === false
191
+ && health.recoveryState === 'disconnected'
192
+ && typeof health.lastDisconnectAt === 'number'
193
+ && Date.now() - health.lastDisconnectAt <= LOCAL_BRIDGE_TYPED_RECENT_DISCONNECT_GRACE_MS;
194
+ }
195
+ async revalidateExtensionConnection() {
196
+ const probeCurrentBridge = async () => this.probeBridgeHealth(this.baseUrl);
197
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
198
+ let latest = await probeCurrentBridge();
199
+ if (latest.extensionConnected === true) {
200
+ return { recovered: true };
201
+ }
202
+ const previousBase = this.baseUrl;
203
+ this.refreshBaseUrl();
204
+ if (this.baseUrl !== previousBase) {
205
+ latest = await probeCurrentBridge();
206
+ if (latest.extensionConnected === true) {
207
+ return { recovered: true };
208
+ }
209
+ }
210
+ const shouldWaitForRecovery = latest.reachable === false
211
+ || latest.extensionConnected === undefined
212
+ || latest.recoveryState === 'recovering'
213
+ || latest.recoveryState === 'recently_recovered'
214
+ || this.isRecentDisconnectedState(latest);
215
+ if (shouldWaitForRecovery) {
216
+ for (const delayMs of LOCAL_BRIDGE_TYPED_EXTENSION_REVALIDATION_DELAYS_MS) {
217
+ await sleep(delayMs);
218
+ latest = await probeCurrentBridge();
219
+ if (latest.extensionConnected === true) {
220
+ return { recovered: true };
221
+ }
222
+ if (latest.reachable !== false
223
+ && latest.extensionConnected !== undefined
224
+ && latest.recoveryState !== 'recovering'
225
+ && latest.recoveryState !== 'recently_recovered'
226
+ && !this.isRecentDisconnectedState(latest)) {
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ if (latest.extensionConnected === false) {
232
+ return { recovered: false, hint: this.getLiveExtensionDisconnectHint(latest.lastDisconnectReason, latest.recoveryState) };
233
+ }
234
+ return { recovered: false };
235
+ }
236
+ extractTabIdFromSessionPath(path) {
237
+ const match = path.match(/^\/sessions\/(\d+)\//);
238
+ return match?.[1];
239
+ }
240
+ async tryRecoverMissingSession(path) {
241
+ if (this.tabOverride)
242
+ return false;
243
+ const localContext = getLocalSessionContext();
244
+ const agentSessionId = localContext?.agentSessionId;
245
+ const tabId = localContext?.tabId;
246
+ const pathTabId = this.extractTabIdFromSessionPath(path);
247
+ if (!agentSessionId || !tabId || !pathTabId || pathTabId !== tabId) {
248
+ return false;
249
+ }
250
+ try {
251
+ const response = await this.fetchFn(`${this.baseUrl}/sessions/register`, {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': 'application/json' },
254
+ body: JSON.stringify({ tabId: Number(tabId), sessionId: agentSessionId }),
255
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
256
+ });
257
+ if (!response.ok)
258
+ return false;
259
+ const json = await response.json();
260
+ const reboundSessionId = typeof json?.data?.sessionId === 'string' && json.data.sessionId.length > 0
261
+ ? json.data.sessionId
262
+ : agentSessionId;
263
+ setLocalSessionContext(tabId, reboundSessionId, localContext.windowId);
264
+ return true;
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ }
270
+ tryRepairMissingMutatingAuthority() {
271
+ if (this.tabOverride)
272
+ return false;
273
+ const localContext = getLocalSessionContext();
274
+ if (!localContext?.tabId)
275
+ return false;
276
+ const workingLocation = getWorkingLocation();
277
+ const continuity = assessLocalContinuity(localContext, workingLocation);
278
+ if (continuity.state !== 'same_controller_missing_mutating_authority_state'
279
+ && continuity.state !== 'same_controller_read_resumable') {
280
+ return false;
281
+ }
282
+ try {
283
+ if (workingLocation && localContext.controlSessionId) {
284
+ updateWorkingLocationControlSession(localContext.controlSessionId);
285
+ return true;
286
+ }
287
+ if (workingLocation && !localContext.controlSessionId) {
288
+ setLocalSessionContext(localContext.tabId, localContext.agentSessionId, localContext.windowId);
289
+ return true;
290
+ }
291
+ if (!workingLocation) {
292
+ setWorkingLocation({
293
+ tabId: Number(localContext.tabId),
294
+ ...(typeof localContext.windowId === 'number' ? { windowId: localContext.windowId } : {}),
295
+ ...(localContext.controlSessionId ? { controlSessionId: localContext.controlSessionId } : {}),
296
+ });
297
+ if (!localContext.controlSessionId) {
298
+ setLocalSessionContext(localContext.tabId, localContext.agentSessionId, localContext.windowId);
299
+ }
300
+ return true;
301
+ }
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ return false;
307
+ }
308
+ /**
309
+ * Pre-action check: if this process has a working location set (via `attach`
310
+ * or `new-window`) and the configured active tab has drifted away from it,
311
+ * silently re-switch the bridge to the working-location tab.
312
+ *
313
+ * This prevents "tab stolen by another agent" where a second agent's `attach`
314
+ * changes the bridge's active tab mid-session.
315
+ */
316
+ async ensureWorkingLocationTab() {
317
+ const loc = getWorkingLocation();
318
+ if (!loc)
319
+ return; // no lock set — nothing to enforce
320
+ const ctx = getLocalSessionContext();
321
+ const workingTabId = String(loc.tabId);
322
+ if (ctx && workingTabId === ctx.tabId)
323
+ return; // already on the right tab
324
+ // The context points to a different tab than our lock. Re-switch silently.
325
+ // Regardless of whether the bridge switch succeeds or fails, always update the
326
+ // local context to the working-location tab so subsequent getActiveTabId() calls
327
+ // reference the right tab. If the tab is truly gone, getActiveTabId's validation
328
+ // will surface the error.
329
+ let reboundAgentSessionId;
330
+ try {
331
+ const res = await this.fetchFn(`${this.baseUrl}/api/tabs/switch`, {
332
+ method: 'POST',
333
+ headers: { 'Content-Type': 'application/json' },
334
+ body: JSON.stringify({ tabId: loc.tabId }),
335
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
336
+ });
337
+ // Non-2xx from the bridge (tab not found, internal error, etc.) is treated
338
+ // the same as a network failure — let finally handle context update.
339
+ if (!res.ok)
340
+ throw new Error(`tab-switch HTTP ${res.status}`);
341
+ // Important: do NOT preserve ctx.agentSessionId here. On a handoff or
342
+ // stale shared-config race, that session id may still belong to the old
343
+ // tab. Refresh the binding for the working-location tab instead.
344
+ const registerRes = await this.fetchFn(`${this.baseUrl}/sessions/register`, {
345
+ method: 'POST',
346
+ headers: { 'Content-Type': 'application/json' },
347
+ body: JSON.stringify({ tabId: loc.tabId }),
348
+ signal: AbortSignal.timeout(BRIDGE_REQUEST_TIMEOUT_MS),
349
+ });
350
+ if (registerRes.ok) {
351
+ const registerJson = await registerRes.json();
352
+ reboundAgentSessionId = registerJson?.data?.sessionId;
353
+ }
354
+ }
355
+ catch {
356
+ // Best-effort — finally will update context regardless.
357
+ }
358
+ finally {
359
+ try {
360
+ const { setLocalSessionContext } = await import('../session/context.js');
361
+ setLocalSessionContext(workingTabId, reboundAgentSessionId, loc.windowId);
362
+ }
363
+ catch { /* ignore */ }
364
+ }
365
+ // Re-confirm lock ownership after drift correction. If another process has
366
+ // taken the lock (TAB_LOCKED), this throws and surfaces as an explicit error
367
+ // rather than silent commands targeting the wrong tab.
368
+ // Pass loc.group so the re-acquire does not strip group membership from the lock file.
369
+ const { acquireLock } = await import('../working-location.js');
370
+ try {
371
+ acquireLock(loc.tabId, loc.group);
372
+ }
373
+ catch (error) {
374
+ this.rethrowTabLock(error);
375
+ }
376
+ }
377
+ assertOwnedTab(tabId) {
378
+ const lock = getLockedBy(Number(tabId));
379
+ if (!lock)
380
+ return;
381
+ const currentAgentId = resolveAgentId();
382
+ const ownedByAgent = !!lock.agentId && lock.agentId === currentAgentId;
383
+ const ownedByPid = !lock.agentId && lock.pid === process.pid;
384
+ if (ownedByAgent || ownedByPid)
385
+ return;
386
+ const continuity = assessLocalContinuity(getLocalSessionContext(), getWorkingLocation());
387
+ if (continuity.state === 'stale_reclaimable' && continuity.effectiveTabId === tabId) {
388
+ try {
389
+ acquireLock(Number(tabId), getWorkingLocation()?.group);
390
+ }
391
+ catch (error) {
392
+ this.rethrowTabLock(error);
393
+ }
394
+ return;
395
+ }
396
+ const owner = lock.agentId
397
+ ? `agent ${lock.agentId}`
398
+ : `PID ${lock.ownerShellPid ?? lock.pid}`;
399
+ throw new ApiError(`Tab ${tabId} is owned by another local session (${owner})`, undefined, getNativeCodeHint('TAB_OWNED_BY_OTHER_SESSION'), 'TAB_OWNED_BY_OTHER_SESSION', false);
400
+ }
401
+ /**
402
+ * Returns the active tab ID, validating it still exists against the native
403
+ * host's /tabs list. Results are cached within this adapter instance's lifetime
404
+ * (a single CLI command invocation). The cache does NOT persist between commands —
405
+ * the CLI starts a new process for each invocation so `validatedTabCache` is always
406
+ * empty at command start. Clears the stored local session if the tab is gone.
407
+ */
408
+ async getActiveTabId() {
409
+ // --tab override: bypass working-location.json entirely
410
+ if (this.tabOverride) {
411
+ this.assertOwnedTab(this.tabOverride);
412
+ return this.tabOverride;
413
+ }
414
+ await this.ensureWorkingLocationTab();
415
+ const tabId = getLocalSessionContext()?.tabId;
416
+ if (!tabId) {
417
+ throw localCommandRequiresTabError();
418
+ }
419
+ this.assertOwnedTab(tabId);
420
+ const lastValidated = this.validatedTabCache.get(tabId);
421
+ if (lastValidated && Date.now() - lastValidated < this.TAB_CACHE_TTL_MS) {
422
+ return tabId;
423
+ }
424
+ try {
425
+ const fetchTabs = async () => {
426
+ const response = await this.fetchFn(`${this.baseUrl}/tabs`, {
427
+ signal: AbortSignal.timeout(BRIDGE_HEALTH_PROBE_TIMEOUT_MS),
428
+ });
429
+ if (!response.ok)
430
+ return [];
431
+ const json = (await response.json());
432
+ return json?.data?.tabs ?? [];
433
+ };
434
+ const localContext = getLocalSessionContext();
435
+ const workingLocation = getWorkingLocation();
436
+ const expectedTabId = Number(tabId);
437
+ const expectedWindowId = localContext?.windowId;
438
+ const updatedAtMs = typeof localContext?.updatedAt === 'string'
439
+ ? Date.parse(localContext.updatedAt)
440
+ : Number.NaN;
441
+ const freshBinding = Number.isFinite(updatedAtMs)
442
+ && Date.now() - updatedAtMs < 5_000;
443
+ let tabs = await fetchTabs();
444
+ let found = tabs.find((t) => String(t.id) === tabId);
445
+ const fingerprintMismatch = () => typeof expectedWindowId === 'number'
446
+ && typeof found?.windowId === 'number'
447
+ && found.windowId !== expectedWindowId;
448
+ const sameRuntimeOwner = typeof localContext?.agentSessionId === 'string'
449
+ && localContext.agentSessionId.length > 0
450
+ && typeof found?.id === 'number'
451
+ && found.id === expectedTabId
452
+ && typeof found?.ownerSessionId === 'string'
453
+ && found.ownerSessionId === localContext.agentSessionId;
454
+ if (freshBinding && (!found || fingerprintMismatch())) {
455
+ await new Promise((resolve) => setTimeout(resolve, 500));
456
+ tabs = await fetchTabs();
457
+ found = tabs.find((t) => String(t.id) === tabId);
458
+ }
459
+ if (tabs.length > 0) {
460
+ let healedWindowMismatch = false;
461
+ if (!found) {
462
+ clearLocalSessionContext();
463
+ releaseWorkingLocation(workingLocation?.group);
464
+ throw new SessionError(`Tab ${tabId} is no longer open`, 'Run: thinkrun tabs\nThen: thinkrun attach <tabId>');
465
+ }
466
+ if (fingerprintMismatch()) {
467
+ if (sameRuntimeOwner) {
468
+ const healedAgentSessionId = localContext.agentSessionId;
469
+ // Extension reconnects can legitimately re-home the same owned tab into a
470
+ // different window record. Heal that drift only when the /tabs entry still
471
+ // proves both the expected tab id and the same runtime owner session.
472
+ setWorkingLocation({
473
+ tabId: expectedTabId,
474
+ windowId: found.windowId,
475
+ ...(workingLocation?.group ? { group: workingLocation.group } : {}),
476
+ ...(localContext?.controlSessionId ? { controlSessionId: localContext.controlSessionId } : {}),
477
+ });
478
+ setLocalSessionContext(String(expectedTabId), healedAgentSessionId, found.windowId);
479
+ healedWindowMismatch = true;
480
+ }
481
+ else {
482
+ clearLocalSessionContext();
483
+ releaseWorkingLocation(workingLocation?.group);
484
+ throw new SessionError(`Tab ${tabId} no longer matches the persisted local binding`, `Persisted continuity expected tab ${tabId} in window ${expectedWindowId}, but the live browser now reports window ${found.windowId}. This usually means the original tab was closed and the browser reused the tab ID. Run: thinkrun tabs\nThen: thinkrun attach <tabId>`);
485
+ }
486
+ }
487
+ if (!healedWindowMismatch && typeof found.windowId === 'number' && localContext?.windowId !== found.windowId) {
488
+ setLocalSessionContext(tabId, localContext?.agentSessionId, found.windowId);
489
+ }
490
+ this.validatedTabCache.set(tabId, Date.now());
491
+ }
492
+ // If validation fetch returns non-200, skip caching and let command fail naturally
493
+ }
494
+ catch (error) {
495
+ if (error instanceof SessionError)
496
+ throw error;
497
+ // Swallow network errors during validation — the actual command will give a better error
498
+ }
499
+ return tabId;
500
+ }
501
+ async request(path, options = {}) {
502
+ const httpMs = options.timeoutMs ?? this.defaultTimeout;
503
+ const method = (options.method ?? 'GET').toUpperCase();
504
+ const requestId = `local-${randomUUID().slice(0, 8)}`;
505
+ const retryProfile = getLocalCommandRetryProfileFromPath(path, method);
506
+ if (retryProfile.outcomeRisk === 'unknown_if_request_left_process') {
507
+ this.tryRepairMissingMutatingAuthority();
508
+ }
509
+ for (let attempt = 0; attempt < LOCAL_BRIDGE_TYPED_TRANSPORT_RECOVERY_ATTEMPTS; attempt += 1) {
510
+ try {
511
+ const headers = {
512
+ 'Content-Type': 'application/json',
513
+ 'x-thinkrun-request-id': requestId,
514
+ };
515
+ // When tabOverride is set (--tab flag), don't send x-session-id — the session
516
+ // belongs to whoever attached, not to us. The tabId in the URL path is sufficient.
517
+ const sessionId = this.tabOverride ? undefined : this.getAgentSessionId();
518
+ if (sessionId) {
519
+ headers['x-session-id'] = sessionId;
520
+ }
521
+ this.logDiagnostics('bridge_request_start', {
522
+ requestId,
523
+ method,
524
+ path,
525
+ attempt: attempt + 1,
526
+ sessionId: sessionId ?? null,
527
+ tabOverride: this.tabOverride ?? null,
528
+ timeoutMs: httpMs,
529
+ });
530
+ const response = await this.fetchFn(`${this.baseUrl}${path}`, {
531
+ method: options.method ?? 'GET',
532
+ headers,
533
+ body: options.body,
534
+ signal: AbortSignal.timeout(httpMs),
535
+ });
536
+ const json = (await response.json());
537
+ this.logDiagnostics('bridge_request_end', {
538
+ requestId,
539
+ method,
540
+ path,
541
+ attempt: attempt + 1,
542
+ status: response.status,
543
+ ok: response.ok,
544
+ success: json?.success ?? null,
545
+ code: json?.code ?? null,
546
+ });
547
+ // Blocked-flow contract: bridge may signal blocked with success: true, data.blocked (preferred)
548
+ // or with success: false, data.blocked. In either case we return the response so callers
549
+ // can run classifyFromBridgeResult(response.data) and surface a handoff instead of ApiError.
550
+ const isBlockedFlow = json?.data && typeof json.data === 'object' && json.data.blocked === true;
551
+ if (!response.ok || (json.success === false && !isBlockedFlow)) {
552
+ const code = json.code ?? 'API_ERROR';
553
+ let hint = NATIVE_CODE_HINTS[code];
554
+ if (code === 'SESSION_NOT_FOUND' && attempt === 0) {
555
+ const recovered = await this.tryRecoverMissingSession(path);
556
+ if (recovered) {
557
+ continue;
558
+ }
559
+ const continuity = assessLocalContinuity(getLocalSessionContext(), getWorkingLocation());
560
+ if (continuity.state === 'same_controller_missing_runtime_registration'
561
+ || continuity.state === 'same_controller_read_resumable') {
562
+ hint =
563
+ `${continuity.hint} ` +
564
+ 'Run: thinkrun session debug --json to confirm the local continuity state, then retry the mutating command or re-attach if the runtime session cannot be repaired automatically.';
565
+ }
566
+ }
567
+ if (code === 'TAB_OWNED_BY_OTHER_SESSION') {
568
+ const localContext = getLocalSessionContext();
569
+ const workingLocation = getWorkingLocation();
570
+ const continuity = assessLocalContinuity(localContext, workingLocation);
571
+ const directSameControllerLeaseMismatch = localContext?.tabId
572
+ && workingLocation
573
+ && localContext.tabId === String(workingLocation.tabId)
574
+ && workingLocation.agentId === resolveAgentId()
575
+ && !!localContext.controlSessionId
576
+ && !!workingLocation.controlSessionId
577
+ && localContext.controlSessionId !== workingLocation.controlSessionId;
578
+ if (directSameControllerLeaseMismatch) {
579
+ hint =
580
+ `Targeting continuity still exists for tab ${localContext?.tabId ?? workingLocation?.tabId ?? 'this tab'}, ` +
581
+ 'but the mutating-authority lease is missing or inconsistent between the persisted local context and the working-location lock. ' +
582
+ 'Run: thinkrun session debug --json before retrying. If continuity is still proven, re-attach only to rebuild authority state, not to take over a foreign tab.';
583
+ }
584
+ else if (continuity.state.startsWith('same_controller_') && !continuity.controlLeaseHeld) {
585
+ hint =
586
+ `${continuity.hint} ` +
587
+ 'Run: thinkrun session debug --json before retrying. If continuity is still proven, re-attach only to rebuild authority state, not to take over a foreign tab.';
588
+ }
589
+ else if (continuity.state === 'stale_reclaimable') {
590
+ hint =
591
+ `${continuity.hint} ` +
592
+ 'Use thinkrun session debug --json to inspect the stale owner evidence before reclaiming the tab.';
593
+ }
594
+ else if (continuity.state === 'foreign_controller_live') {
595
+ hint =
596
+ `${continuity.hint} ` +
597
+ 'Use thinkrun tabs --json to inspect ownerSessionId. Cross-agent takeover is not supported safely in v1.';
598
+ }
599
+ }
600
+ if (code === 'EXTENSION_NOT_CONNECTED') {
601
+ const revalidated = await this.revalidateExtensionConnection();
602
+ // EXTENSION_NOT_CONNECTED is raised before the native host can
603
+ // dispatch the request to the extension when the bridge already
604
+ // knows the extension is disconnected. If live health confirms
605
+ // the extension has recovered immediately after that rejection,
606
+ // the original command never reached the page and is safe to
607
+ // replay once even when its generic transport profile is not.
608
+ if (attempt === 0 && revalidated.recovered) {
609
+ await this.tryRecoverMissingSession(path);
610
+ continue;
611
+ }
612
+ if (revalidated.hint) {
613
+ hint = revalidated.hint;
614
+ }
615
+ else if (revalidated.recovered && retryProfile.outcomeRisk === 'unknown_if_request_left_process') {
616
+ hint = `The "${retryProfile.description}" command may already have completed before the extension disconnected. Verify the page state before retrying. If the bridge remains unstable, run thinkrun session debug or thinkrun doctor.`;
617
+ }
618
+ }
619
+ const apiErr = new ApiError(json.error || 'Native host request failed', response.status, hint, code, json.retryable);
620
+ // PRD 0086 6.2b — preserve the daemon's policy-driven recovery signal
621
+ // across the HTTP boundary so it reaches typed CLI callers (FU-4 wires
622
+ // the actual replay decision to these fields).
623
+ if (typeof json.outcome === 'string')
624
+ apiErr.deliveryOutcome = json.outcome;
625
+ if (typeof json.replay === 'string')
626
+ apiErr.replayPolicy = json.replay;
627
+ if (typeof json.retryHint === 'string')
628
+ apiErr.retryHint = json.retryHint;
629
+ throw apiErr;
630
+ }
631
+ return json;
632
+ }
633
+ catch (error) {
634
+ this.logDiagnostics('bridge_request_error', {
635
+ requestId,
636
+ method,
637
+ path,
638
+ attempt: attempt + 1,
639
+ error: error?.message ?? String(error),
640
+ code: error?.code ?? null,
641
+ transient: this.isTransientBridgeError(error),
642
+ retryProfile: retryProfile.key,
643
+ });
644
+ if (error instanceof ApiError)
645
+ throw error;
646
+ if (attempt === 0 && this.isTransientBridgeError(error)) {
647
+ const prevBase = this.baseUrl;
648
+ this.refreshBaseUrl();
649
+ const baseChanged = this.baseUrl !== prevBase;
650
+ if (this.isPreDispatchBridgeDisconnect(error)) {
651
+ const revalidated = await this.revalidateExtensionConnection();
652
+ if (revalidated.recovered) {
653
+ await this.tryRecoverMissingSession(path);
654
+ continue;
655
+ }
656
+ }
657
+ const canReplay = retryProfile.transportReplay === 'safe_same_bridge'
658
+ || (retryProfile.transportReplay === 'only_after_route_change' && baseChanged);
659
+ if (canReplay) {
660
+ continue;
661
+ }
662
+ const suggestion = retryProfile.outcomeRisk === 'unknown_if_request_left_process'
663
+ ? `The "${retryProfile.description}" command may already have completed before the bridge disconnected. Verify the page state before retrying. If the bridge remains unstable, run thinkrun session debug or thinkrun doctor.`
664
+ : `Start the native host by opening a Chromium-based browser (e.g. Chrome or Helium) with the ThinkRun extension, or check if it is running at ${this.baseUrl}`;
665
+ throw new ApiError('Native host not reachable', undefined, suggestion, 'NATIVE_HOST_UNREACHABLE', false);
666
+ }
667
+ if (this.isTransientBridgeError(error)) {
668
+ throw new ApiError('Native host not reachable', undefined, `Start the native host by opening a Chromium-based browser (e.g. Chrome or Helium) with the ThinkRun extension, or check if it is running at ${this.baseUrl}`, 'NATIVE_HOST_UNREACHABLE', true);
669
+ }
670
+ throw new ApiError(error.message || 'Local request failed');
671
+ }
672
+ }
673
+ throw new ApiError('LocalAdapter.request: unreachable (internal)', undefined, undefined, 'INTERNAL_ERROR');
674
+ }
675
+ // --- Session Management ---
676
+ /**
677
+ * In local mode this does not start a cloud browser. However, if an API
678
+ * key is configured the session is registered in the cloud activity feed
679
+ * so screenshots and actions appear alongside cloud/extension sessions.
680
+ * Without an API key this is a silent no-op (no error).
681
+ */
682
+ async startSession(_options = {}) {
683
+ let tabId;
684
+ try {
685
+ tabId = getLocalSessionContext()?.tabId;
686
+ }
687
+ catch { /* mixed session state — treat as no tab */ }
688
+ if (tabId) {
689
+ // Best-effort — result written to disk by initCliSession; ignore here.
690
+ await initCliSession(tabId, this.fetchFn);
691
+ }
692
+ return {
693
+ sessionId: tabId ?? 'local',
694
+ status: 'running',
695
+ createdAt: new Date().toISOString(),
696
+ };
697
+ }
698
+ /**
699
+ * In local mode this does not stop a cloud browser. If a session was
700
+ * registered via startSession(), a fire-and-forget local-close request
701
+ * is sent to mark it completed in the activity feed.
702
+ */
703
+ async stopSession(_sessionId) {
704
+ let tabId;
705
+ try {
706
+ tabId = getLocalSessionContext()?.tabId;
707
+ }
708
+ catch { /* mixed session state — treat as no tab */ }
709
+ if (tabId) {
710
+ closeCliSession(tabId, 'completed', this.fetchFn);
711
+ }
712
+ }
713
+ async getSession(sessionId) {
714
+ const response = await this.request(`/sessions/${sessionId}`);
715
+ const d = response.data ?? {};
716
+ return {
717
+ sessionId,
718
+ status: 'running',
719
+ currentUrl: d.url,
720
+ title: d.title,
721
+ createdAt: new Date().toISOString(),
722
+ };
723
+ }
724
+ async listSessions() {
725
+ const response = await this.request('/tabs');
726
+ const tabs = response.data?.tabs ?? [];
727
+ return tabs.map((t) => ({
728
+ sessionId: String(t.id),
729
+ status: 'running',
730
+ currentUrl: t.url,
731
+ title: t.title,
732
+ createdAt: new Date().toISOString(),
733
+ }));
734
+ }
735
+ async waitForSessionReady(sessionId, _timeout) {
736
+ return this.getSession(sessionId);
737
+ }
738
+ // --- Navigation ---
739
+ async navigate(url, options = {}) {
740
+ const tabId = await this.getActiveTabId();
741
+ try {
742
+ const response = await this.request(`/sessions/${tabId}/navigate`, {
743
+ method: 'POST',
744
+ body: JSON.stringify({
745
+ url,
746
+ waitUntil: options.waitUntil ?? 'load',
747
+ timeout: options.timeout,
748
+ }),
749
+ });
750
+ if (response.success) {
751
+ // Prefer the post-navigation URL from the response (handles redirects/canonicalization).
752
+ const syncedUrl = typeof response.data?.url === 'string' && response.data.url.length > 0
753
+ ? response.data.url
754
+ : url;
755
+ this.syncAction(tabId, 'navigate', { url: syncedUrl });
756
+ }
757
+ return { success: response.success, data: response.data };
758
+ }
759
+ catch (err) {
760
+ if (err instanceof ApiError && err.nativeCode === 'REQUEST_TIMEOUT') {
761
+ // Background tabs are throttled by the browser — bring the tab into focus
762
+ // so the page can load at full speed, then the user can retry.
763
+ this.fetchFn(`${this.baseUrl}/api/tabs/switch`, {
764
+ method: 'POST',
765
+ headers: { 'Content-Type': 'application/json' },
766
+ body: JSON.stringify({ tabId }),
767
+ signal: AbortSignal.timeout(3000),
768
+ }).catch((focusErr) => {
769
+ process.stderr.write(`[local] tab focus after navigate timeout failed: ${focusErr}\n`);
770
+ });
771
+ err.suggestion = `Loading ${url} is taking a long time — the tab may be throttled because it was in the background. Bringing it into focus now. Retry the command once the page has loaded.`;
772
+ }
773
+ throw err;
774
+ }
775
+ }
776
+ async goBack() {
777
+ const tabId = await this.getActiveTabId();
778
+ const response = await this.request(`/sessions/${tabId}/go-back`, { method: 'POST' });
779
+ if (response.success)
780
+ this.syncAction(tabId, 'go-back', {});
781
+ return { success: response.success, data: response.data };
782
+ }
783
+ async goForward() {
784
+ const tabId = await this.getActiveTabId();
785
+ const response = await this.request(`/sessions/${tabId}/go-forward`, { method: 'POST' });
786
+ if (response.success)
787
+ this.syncAction(tabId, 'go-forward', {});
788
+ return { success: response.success, data: response.data };
789
+ }
790
+ // --- Fingerprint + obstacle helpers ─────────────────────────────────────
791
+ /** Evaluate function suitable for captureFingerprint / classifyElementFailure. */
792
+ evaluateForFingerprint = (script) => this.evaluate(script).then((r) => unwrapEvaluatePayload(r.data));
793
+ /** Capture fingerprint with a 3s timeout to avoid blocking the primary action. */
794
+ async tryCaptureFP() {
795
+ let timerId;
796
+ try {
797
+ const timeoutPromise = new Promise(r => { timerId = setTimeout(() => r(null), 3000); });
798
+ return await Promise.race([
799
+ captureFingerprint(this.evaluateForFingerprint),
800
+ timeoutPromise,
801
+ ]);
802
+ }
803
+ catch {
804
+ return null;
805
+ }
806
+ finally {
807
+ if (timerId)
808
+ clearTimeout(timerId);
809
+ }
810
+ }
811
+ /** A fingerprint with empty url means capture failed — skip comparison. */
812
+ static isFingerprintValid(fp) {
813
+ return fp.url !== '';
814
+ }
815
+ /**
816
+ * Enrich an ELEMENT_NOT_FOUND ApiError with obstacle classification.
817
+ * Runs classifyElementFailure to determine WHY the selector failed,
818
+ * then attaches the result as `obstacleType` on the error object.
819
+ * If classification itself fails, returns the original error unchanged.
820
+ */
821
+ async enrichElementNotFound(err, selector) {
822
+ try {
823
+ const obstacleType = await classifyElementFailure(selector, this.evaluateForFingerprint);
824
+ err.obstacleType = obstacleType;
825
+ }
826
+ catch {
827
+ // Classification failed — return original error unchanged
828
+ }
829
+ return err;
830
+ }
831
+ // --- Interaction ---
832
+ async click(selector, options = {}) {
833
+ const tabId = await this.getActiveTabId();
834
+ // Capture pre-action fingerprint (best-effort)
835
+ const pre = await this.tryCaptureFP();
836
+ let response;
837
+ try {
838
+ response = await this.request(`/sessions/${tabId}/click`, {
839
+ method: 'POST',
840
+ body: JSON.stringify({
841
+ selector,
842
+ button: options.button ?? 'left',
843
+ clickCount: options.count ?? 1,
844
+ delay: options.delay,
845
+ timeout: options.timeout ?? 15000,
846
+ }),
847
+ });
848
+ }
849
+ catch (err) {
850
+ if (err instanceof ApiError && err.code === 'ELEMENT_NOT_FOUND') {
851
+ throw await this.enrichElementNotFound(err, selector);
852
+ }
853
+ throw err;
854
+ }
855
+ // Bridge blocked hint takes precedence — it is more specific
856
+ const blocked = classifyFromBridgeResult(response.data);
857
+ if (blocked)
858
+ return { success: false, data: blocked };
859
+ // State fingerprint comparison (best-effort; skip if capture returned empty)
860
+ if (pre && LocalAdapter.isFingerprintValid(pre)) {
861
+ try {
862
+ if (POST_ACTION_SETTLE_MS > 0)
863
+ await new Promise(resolve => setTimeout(resolve, POST_ACTION_SETTLE_MS));
864
+ const post = await this.tryCaptureFP();
865
+ if (!post)
866
+ throw new Error('post fingerprint unavailable');
867
+ const comparison = compareFingerprints(pre, post);
868
+ if (comparison === 'unchanged') {
869
+ const stateBlocked = classifyFromStateCheck('click', pre, post);
870
+ if (stateBlocked)
871
+ return { success: false, data: stateBlocked };
872
+ }
873
+ }
874
+ catch { /* fingerprint failure — do not break normal flow */ }
875
+ }
876
+ if (response.success)
877
+ this.syncAction(tabId, 'click', { selector });
878
+ return { success: response.success };
879
+ }
880
+ async type(selector, text, options = {}) {
881
+ const tabId = await this.getActiveTabId();
882
+ const pre = await this.tryCaptureFP();
883
+ let response;
884
+ try {
885
+ response = await this.request(`/sessions/${tabId}/type`, {
886
+ method: 'POST',
887
+ body: JSON.stringify({ selector, text, delay: options.delay }),
888
+ });
889
+ }
890
+ catch (err) {
891
+ if (err instanceof ApiError && err.code === 'ELEMENT_NOT_FOUND') {
892
+ throw await this.enrichElementNotFound(err, selector);
893
+ }
894
+ throw err;
895
+ }
896
+ const blocked = classifyFromBridgeResult(response.data);
897
+ if (blocked)
898
+ return { success: false, data: blocked };
899
+ if (pre && LocalAdapter.isFingerprintValid(pre)) {
900
+ try {
901
+ if (POST_ACTION_SETTLE_MS > 0)
902
+ await new Promise(resolve => setTimeout(resolve, POST_ACTION_SETTLE_MS));
903
+ const post = await this.tryCaptureFP();
904
+ if (!post)
905
+ throw new Error('post fingerprint unavailable');
906
+ const comparison = compareFingerprints(pre, post);
907
+ if (comparison === 'unchanged') {
908
+ const stateBlocked = classifyFromStateCheck('type', pre, post);
909
+ if (stateBlocked)
910
+ return { success: false, data: stateBlocked };
911
+ }
912
+ }
913
+ catch { /* fingerprint failure — do not break normal flow */ }
914
+ }
915
+ if (response.success)
916
+ this.syncAction(tabId, 'type', { selector });
917
+ return { success: response.success };
918
+ }
919
+ async fill(selector, value) {
920
+ const tabId = await this.getActiveTabId();
921
+ const pre = await this.tryCaptureFP();
922
+ let response;
923
+ try {
924
+ response = await this.request(`/sessions/${tabId}/fill`, {
925
+ method: 'POST',
926
+ body: JSON.stringify({ selector, value }),
927
+ });
928
+ }
929
+ catch (err) {
930
+ if (err instanceof ApiError && err.code === 'ELEMENT_NOT_FOUND') {
931
+ throw await this.enrichElementNotFound(err, selector);
932
+ }
933
+ throw err;
934
+ }
935
+ const blocked = classifyFromBridgeResult(response.data);
936
+ if (blocked)
937
+ return { success: false, data: blocked };
938
+ if (pre && LocalAdapter.isFingerprintValid(pre)) {
939
+ try {
940
+ if (POST_ACTION_SETTLE_MS > 0)
941
+ await new Promise(resolve => setTimeout(resolve, POST_ACTION_SETTLE_MS));
942
+ const post = await this.tryCaptureFP();
943
+ if (!post)
944
+ throw new Error('post fingerprint unavailable');
945
+ const comparison = compareFingerprints(pre, post);
946
+ if (comparison === 'unchanged') {
947
+ const stateBlocked = classifyFromStateCheck('fill', pre, post);
948
+ if (stateBlocked)
949
+ return { success: false, data: stateBlocked };
950
+ }
951
+ }
952
+ catch { /* fingerprint failure — do not break normal flow */ }
953
+ }
954
+ if (response.success)
955
+ this.syncAction(tabId, 'fill', { selector });
956
+ return { success: response.success };
957
+ }
958
+ async press(key) {
959
+ const tabId = await this.getActiveTabId();
960
+ const response = await this.request(`/sessions/${tabId}/press`, {
961
+ method: 'POST',
962
+ body: JSON.stringify({ key }),
963
+ });
964
+ if (response.success)
965
+ this.syncAction(tabId, 'press', { key });
966
+ return { success: response.success };
967
+ }
968
+ async scroll(options) {
969
+ const tabId = await this.getActiveTabId();
970
+ // Selector-based: scroll element into view via evaluate, but sync as 'scroll'
971
+ // (not 'evaluate') so the activity feed shows the user-facing action type.
972
+ if (options.selector) {
973
+ const resp = await this.request(`/sessions/${tabId}/evaluate`, {
974
+ method: 'POST',
975
+ body: JSON.stringify({
976
+ script: `document.querySelector(${JSON.stringify(options.selector)})?.scrollIntoView({behavior:'smooth',block:'center'})`,
977
+ args: [],
978
+ }),
979
+ });
980
+ if (resp.success)
981
+ this.syncAction(tabId, 'scroll', { selector: options.selector });
982
+ return { success: resp.success, data: resp.data };
983
+ }
984
+ // Horizontal scroll is not supported by the native host's handleScroll (which only
985
+ // accepts {direction: 'up'|'down', amount}). Throw rather than silently scrolling
986
+ // vertically when the caller explicitly requests horizontal scrolling.
987
+ if (options.x !== undefined && options.y === undefined) {
988
+ throw new ApiError('Horizontal scrolling (x-only) is not supported in local mode', 400, 'Use --down or --up for vertical scrolling, or --to <selector> to scroll an element into view', 'UNSUPPORTED_OPERATION', false);
989
+ }
990
+ // Translate y offset → {direction, amount} as expected by native host handleScroll
991
+ const direction = (options.y !== undefined && options.y < 0) ? 'up' : 'down';
992
+ const amount = options.y !== undefined ? Math.abs(options.y) : 500;
993
+ const response = await this.request(`/sessions/${tabId}/scroll`, {
994
+ method: 'POST',
995
+ body: JSON.stringify({ direction, amount }),
996
+ });
997
+ if (response.success)
998
+ this.syncAction(tabId, 'scroll', { direction, amount });
999
+ return { success: response.success };
1000
+ }
1001
+ async hover(selector) {
1002
+ const tabId = await this.getActiveTabId();
1003
+ try {
1004
+ const response = await this.request(`/sessions/${tabId}/hover`, {
1005
+ method: 'POST',
1006
+ body: JSON.stringify({ selector }),
1007
+ });
1008
+ if (response.success)
1009
+ this.syncAction(tabId, 'hover', { selector });
1010
+ return { success: response.success };
1011
+ }
1012
+ catch (err) {
1013
+ if (err instanceof ApiError && err.code === 'ELEMENT_NOT_FOUND') {
1014
+ throw await this.enrichElementNotFound(err, selector);
1015
+ }
1016
+ throw err;
1017
+ }
1018
+ }
1019
+ async select(selector, value) {
1020
+ const tabId = await this.getActiveTabId();
1021
+ // Capture pre-action fingerprint (best-effort)
1022
+ const pre = await this.tryCaptureFP();
1023
+ let response;
1024
+ try {
1025
+ response = await this.request(`/sessions/${tabId}/select`, {
1026
+ method: 'POST',
1027
+ body: JSON.stringify({ selector, value }),
1028
+ });
1029
+ }
1030
+ catch (err) {
1031
+ if (err instanceof ApiError && err.code === 'ELEMENT_NOT_FOUND') {
1032
+ throw await this.enrichElementNotFound(err, selector);
1033
+ }
1034
+ throw err;
1035
+ }
1036
+ // Bridge blocked hint takes precedence — it is more specific
1037
+ const blocked = classifyFromBridgeResult(response.data);
1038
+ if (blocked)
1039
+ return { success: false, data: blocked };
1040
+ // State fingerprint comparison (best-effort; skip if capture returned empty)
1041
+ if (pre && LocalAdapter.isFingerprintValid(pre)) {
1042
+ try {
1043
+ if (POST_ACTION_SETTLE_MS > 0)
1044
+ await new Promise(resolve => setTimeout(resolve, POST_ACTION_SETTLE_MS));
1045
+ const post = await this.tryCaptureFP();
1046
+ if (!post)
1047
+ throw new Error('post fingerprint unavailable');
1048
+ const comparison = compareFingerprints(pre, post);
1049
+ if (comparison === 'unchanged') {
1050
+ const stateBlocked = classifyFromStateCheck('select', pre, post);
1051
+ if (stateBlocked)
1052
+ return { success: false, data: stateBlocked };
1053
+ }
1054
+ }
1055
+ catch { /* fingerprint failure — do not break normal flow */ }
1056
+ }
1057
+ if (response.success)
1058
+ this.syncAction(tabId, 'select', { selector });
1059
+ return { success: response.success };
1060
+ }
1061
+ // --- Waiting ---
1062
+ async wait(condition, options = {}) {
1063
+ if (typeof condition === 'number') {
1064
+ // Numeric-timeout path: local setTimeout only — never contacts the bridge,
1065
+ // so there is no bridge action to sync and syncAction is intentionally absent.
1066
+ await new Promise(resolve => setTimeout(resolve, condition));
1067
+ return { success: true };
1068
+ }
1069
+ const tabId = await this.getActiveTabId();
1070
+ const state = options.hidden ? 'hidden' : 'visible';
1071
+ const response = await this.request(`/sessions/${tabId}/wait`, {
1072
+ method: 'POST',
1073
+ body: JSON.stringify({
1074
+ selector: condition,
1075
+ state,
1076
+ timeout: options.timeout ?? 30000,
1077
+ }),
1078
+ });
1079
+ if (response.success)
1080
+ this.syncAction(tabId, 'wait', {});
1081
+ return { success: response.success };
1082
+ }
1083
+ async waitForText(text, options = {}) {
1084
+ const tabId = await this.getActiveTabId();
1085
+ const response = await this.request(`/sessions/${tabId}/wait-for-text`, {
1086
+ method: 'POST',
1087
+ body: JSON.stringify({ text, timeout: options.timeout ?? 30000 }),
1088
+ });
1089
+ return { success: response.success };
1090
+ }
1091
+ // --- Observation ---
1092
+ async snapshot() {
1093
+ const tabId = await this.getActiveTabId();
1094
+ const response = await this.request(`/sessions/${tabId}/snapshot`, { method: 'POST' });
1095
+ // Native host may return snapshot in data.snapshot or data directly
1096
+ const snapshot = response.data?.snapshot ?? response.data;
1097
+ return { success: response.success, data: snapshot };
1098
+ }
1099
+ async screenshot(options = {}) {
1100
+ const tabId = await this.getActiveTabId();
1101
+ const response = await this.request(`/sessions/${tabId}/screenshot`, {
1102
+ method: 'POST',
1103
+ body: JSON.stringify({
1104
+ fullPage: options.fullPage,
1105
+ selector: options.selector,
1106
+ type: options.format ?? 'png',
1107
+ quality: options.quality,
1108
+ maxDimension: options.maxDimension,
1109
+ }),
1110
+ });
1111
+ // Native host returns base64 screenshot — try cloud upload first, fall back to temp file
1112
+ const b64 = response.data?.screenshot ?? response.data;
1113
+ if (b64 && typeof b64 === 'string') {
1114
+ // Filename: human-readable timestamp prefix + UUID suffix.
1115
+ // Timestamp makes recent screenshots easy to identify (ls -t); UUID prevents
1116
+ // collisions from rapid successive calls within the same millisecond.
1117
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('Z', '');
1118
+ const tempPath = join(tmpdir(), `thinkrun-${ts}-${randomUUID().slice(0, 8)}.png`);
1119
+ writeFileSync(tempPath, Buffer.from(b64, 'base64'));
1120
+ // Derive MIME type once — used for both the artifact upload and the sync action.
1121
+ const mimeType = options.format === 'jpeg' ? 'image/jpeg'
1122
+ : options.format === 'webp' ? 'image/webp'
1123
+ : 'image/png';
1124
+ // If this local tab has a registered cloud session (from startSession()), sync
1125
+ // the screenshot as an action in the activity feed. Fire-and-forget — never
1126
+ // blocks the caller.
1127
+ let syncTabId;
1128
+ try {
1129
+ syncTabId = getLocalSessionContext()?.tabId;
1130
+ }
1131
+ catch { /* mixed session state */ }
1132
+ if (syncTabId) {
1133
+ const persistedSessionId = readCliSessionId(syncTabId);
1134
+ if (persistedSessionId) {
1135
+ const caption = options.caption?.trim();
1136
+ if (!caption) {
1137
+ throw new Error('Local screenshots synced to ThinkRun require --caption <text>');
1138
+ }
1139
+ syncScreenshotAction(persistedSessionId, b64, mimeType, caption, this.fetchFn);
1140
+ }
1141
+ }
1142
+ // If API key is configured, upload to cloud artifact storage so the screenshot
1143
+ // has a downloadable URL (same as cloud mode). Uses a client-generated UUID as
1144
+ // the sessionId — the server allows authenticated uploads without a session doc.
1145
+ const apiKey = config.get('apiKey');
1146
+ if (apiKey) {
1147
+ const rawApiUrl = config.get('apiUrl') || 'https://api.thinkbrowse.io';
1148
+ try {
1149
+ let apiBase;
1150
+ try {
1151
+ apiBase = new URL(rawApiUrl);
1152
+ }
1153
+ catch {
1154
+ return { success: true, data: { path: tempPath, local: true } };
1155
+ }
1156
+ // Local HTTP apiUrls are valid for bridge/dev workflows, but skip
1157
+ // credentialed uploads to avoid sending the API key over plaintext.
1158
+ if (apiBase.protocol !== 'https:')
1159
+ return { success: true, data: { path: tempPath, local: true } };
1160
+ const apiUrl = apiBase.href.replace(/\/$/, '');
1161
+ const artifactSessionId = randomUUID();
1162
+ const uploadRes = await this.fetchFn(`${apiUrl}/api/sessions/${artifactSessionId}/artifact-upload`, {
1163
+ method: 'POST',
1164
+ headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
1165
+ body: JSON.stringify({ screenshot: b64, label: options.selector ?? 'local-screenshot', mimeType }),
1166
+ signal: AbortSignal.timeout(15000),
1167
+ });
1168
+ if (uploadRes.ok) {
1169
+ const data = (await uploadRes.json());
1170
+ const objectId = data?.objectId;
1171
+ if (!objectId) {
1172
+ throw new Error('Missing objectId in upload response');
1173
+ }
1174
+ const url = `${apiUrl}/api/storage/artifacts/${objectId}/download`;
1175
+ return { success: true, data: { url, objectId, path: tempPath, local: true } };
1176
+ }
1177
+ }
1178
+ catch (uploadErr) {
1179
+ // Cloud upload failed — fall through to temp-file-only result.
1180
+ // Log to stderr so users/agents can debug without it polluting stdout.
1181
+ process.stderr.write(`[thinkrun] Cloud upload failed: ${uploadErr?.message ?? uploadErr}\n`);
1182
+ }
1183
+ }
1184
+ return { success: true, data: { path: tempPath, local: true } };
1185
+ }
1186
+ return { success: response.success, data: response.data };
1187
+ }
1188
+ async extract(selector, options = {}) {
1189
+ const tabId = await this.getActiveTabId();
1190
+ const response = await this.request(`/sessions/${tabId}/extract`, {
1191
+ method: 'POST',
1192
+ body: JSON.stringify({
1193
+ selector,
1194
+ type: options.attr ? 'attribute' : options.format ?? 'text',
1195
+ attribute: options.attr,
1196
+ multiple: options.all,
1197
+ }),
1198
+ });
1199
+ if (response.success)
1200
+ this.syncAction(tabId, 'extract', { selector: selector || 'page' });
1201
+ return { success: response.success, data: response.data };
1202
+ }
1203
+ async evaluate(script, args = [], evalOpts) {
1204
+ const tabId = await this.getActiveTabId();
1205
+ const timeoutMs = evalOpts?.timeout ?? 30_000;
1206
+ const response = await this.request(`/sessions/${tabId}/evaluate`, {
1207
+ method: 'POST',
1208
+ body: JSON.stringify({ script, args, timeout: timeoutMs }),
1209
+ timeoutMs: Math.min(timeoutMs + 15_000, 615_000),
1210
+ });
1211
+ if (response.success)
1212
+ this.syncAction(tabId, 'evaluate', {});
1213
+ return { success: response.success, data: response.data };
1214
+ }
1215
+ async getUrl() {
1216
+ const tabId = await this.getActiveTabId();
1217
+ const response = await this.request(`/sessions/${tabId}/url`, { method: 'POST' });
1218
+ const url = typeof response.data === 'string' ? response.data : (response.data?.url ?? response.data);
1219
+ return { success: response.success, data: url };
1220
+ }
1221
+ async getTitle() {
1222
+ const tabId = await this.getActiveTabId();
1223
+ const response = await this.request(`/sessions/${tabId}/title`, { method: 'POST' });
1224
+ const title = typeof response.data === 'string' ? response.data : (response.data?.title ?? response.data);
1225
+ return { success: response.success, data: title };
1226
+ }
1227
+ async getHtml() {
1228
+ const tabId = await this.getActiveTabId();
1229
+ const response = await this.request(`/sessions/${tabId}/html`, { method: 'POST' });
1230
+ const html = typeof response.data === 'string' ? response.data : (response.data?.html ?? response.data);
1231
+ return { success: response.success, data: html };
1232
+ }
1233
+ // --- Dialog Handling ---
1234
+ async getDialog() {
1235
+ const tabId = await this.getActiveTabId();
1236
+ return this.request(`/sessions/${tabId}/dialog`);
1237
+ }
1238
+ async handleDialog(action, promptText) {
1239
+ const tabId = await this.getActiveTabId();
1240
+ const response = await this.request(`/sessions/${tabId}/dialog`, {
1241
+ method: 'POST',
1242
+ body: JSON.stringify({ accept: action === 'accept', promptText }),
1243
+ });
1244
+ return { success: response.success };
1245
+ }
1246
+ // --- Monitoring ---
1247
+ async getConsoleMessages() {
1248
+ const tabId = await this.getActiveTabId();
1249
+ const result = await this.request(`/sessions/${tabId}/console`);
1250
+ const logs = result.data?.logs ?? result.data ?? [];
1251
+ return {
1252
+ success: result.success,
1253
+ logs: Array.isArray(logs) ? logs : [],
1254
+ count: Array.isArray(logs) ? logs.length : 0,
1255
+ };
1256
+ }
1257
+ async getNetworkRequests() {
1258
+ const tabId = await this.getActiveTabId();
1259
+ const result = await this.request(`/sessions/${tabId}/network`);
1260
+ const requests = result.data?.requests ?? result.data ?? [];
1261
+ return {
1262
+ success: result.success,
1263
+ requests: Array.isArray(requests) ? requests : [],
1264
+ count: Array.isArray(requests) ? requests.length : 0,
1265
+ };
1266
+ }
1267
+ async clearLogs() {
1268
+ const tabId = await this.getActiveTabId();
1269
+ const response = await this.request(`/sessions/${tabId}/clear-logs`, { method: 'POST' });
1270
+ return { success: response.success };
1271
+ }
1272
+ }
1273
+ //# sourceMappingURL=local.js.map