@thinkrun/mcp 0.3.5

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 (50) hide show
  1. package/README.md +356 -0
  2. package/dist/bin/thinkrun-mcp.d.ts +3 -0
  3. package/dist/bin/thinkrun-mcp.d.ts.map +1 -0
  4. package/dist/bin/thinkrun-mcp.js +305 -0
  5. package/dist/bin/thinkrun-mcp.js.map +1 -0
  6. package/dist/src/client.d.ts +422 -0
  7. package/dist/src/client.d.ts.map +1 -0
  8. package/dist/src/client.js +2 -0
  9. package/dist/src/client.js.map +1 -0
  10. package/dist/src/cloud-client.d.ts +69 -0
  11. package/dist/src/cloud-client.d.ts.map +1 -0
  12. package/dist/src/cloud-client.js +291 -0
  13. package/dist/src/cloud-client.js.map +1 -0
  14. package/dist/src/extension-proxy-client.d.ts +139 -0
  15. package/dist/src/extension-proxy-client.d.ts.map +1 -0
  16. package/dist/src/extension-proxy-client.js +451 -0
  17. package/dist/src/extension-proxy-client.js.map +1 -0
  18. package/dist/src/index.d.ts +11 -0
  19. package/dist/src/index.d.ts.map +1 -0
  20. package/dist/src/index.js +6 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/local-client.d.ts +120 -0
  23. package/dist/src/local-client.d.ts.map +1 -0
  24. package/dist/src/local-client.js +947 -0
  25. package/dist/src/local-client.js.map +1 -0
  26. package/dist/src/local-locks.d.ts +26 -0
  27. package/dist/src/local-locks.d.ts.map +1 -0
  28. package/dist/src/local-locks.js +315 -0
  29. package/dist/src/local-locks.js.map +1 -0
  30. package/dist/src/package-version.d.ts +2 -0
  31. package/dist/src/package-version.d.ts.map +1 -0
  32. package/dist/src/package-version.js +13 -0
  33. package/dist/src/package-version.js.map +1 -0
  34. package/dist/src/page-cache-http.d.ts +15 -0
  35. package/dist/src/page-cache-http.d.ts.map +1 -0
  36. package/dist/src/page-cache-http.js +44 -0
  37. package/dist/src/page-cache-http.js.map +1 -0
  38. package/dist/src/server.d.ts +135 -0
  39. package/dist/src/server.d.ts.map +1 -0
  40. package/dist/src/server.js +1454 -0
  41. package/dist/src/server.js.map +1 -0
  42. package/dist/src/test-utils/lock-fixtures.d.ts +18 -0
  43. package/dist/src/test-utils/lock-fixtures.d.ts.map +1 -0
  44. package/dist/src/test-utils/lock-fixtures.js +33 -0
  45. package/dist/src/test-utils/lock-fixtures.js.map +1 -0
  46. package/dist/src/unwrap-evaluate-payload.d.ts +8 -0
  47. package/dist/src/unwrap-evaluate-payload.d.ts.map +1 -0
  48. package/dist/src/unwrap-evaluate-payload.js +19 -0
  49. package/dist/src/unwrap-evaluate-payload.js.map +1 -0
  50. package/package.json +65 -0
@@ -0,0 +1,947 @@
1
+ /**
2
+ * Local client — communicates with the ThinkRun native messaging host.
3
+ * Chrome auto-launches the native host binary when the extension loads.
4
+ *
5
+ * Port discovery: the native host writes its HTTP port to ~/.thinkbrowse/port
6
+ * on startup. This client reads that file to find the port, falling back to
7
+ * DEFAULT_PORT (3012) if the file doesn't exist.
8
+ *
9
+ * Use LocalClient.create() for automatic detection (health check with timeout),
10
+ * or pass an explicit port via new LocalClient({ port }).
11
+ */
12
+ import { readFileSync, existsSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { fetchPageCacheJson, PageCacheRequestError } from './page-cache-http.js';
15
+ import { unwrapEvaluatePayload } from './unwrap-evaluate-payload.js';
16
+ import { acquireTabLock, getLastReclaimAudit, getTabLock, isTabLockHeldByCurrentAgent, isTabLockStale, releaseTabLock, resolveAgentId, } from './local-locks.js';
17
+ function isObject(value) {
18
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ }
20
+ /** Default port for the native messaging host. */
21
+ export const DEFAULT_PORT = 3012;
22
+ /** @deprecated Use DEFAULT_PORT instead. */
23
+ export const DEFAULT_NATIVE_HOST_PORT = DEFAULT_PORT;
24
+ /** @deprecated Bridge server has been removed. */
25
+ export const DEFAULT_BRIDGE_PORT = DEFAULT_PORT;
26
+ /** Port discovery file written by the native host on startup. */
27
+ const PORT_DISCOVERY_FILE = join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.thinkbrowse', 'port');
28
+ /**
29
+ * Read the native host port from the discovery file (~/.thinkbrowse/port).
30
+ * Returns null if the file doesn't exist or is invalid.
31
+ */
32
+ function readDiscoveredPort() {
33
+ try {
34
+ if (existsSync(PORT_DISCOVERY_FILE)) {
35
+ const content = readFileSync(PORT_DISCOVERY_FILE, 'utf-8').trim();
36
+ const port = parseInt(content, 10);
37
+ if (!isNaN(port) && port > 0 && port <= 65535)
38
+ return port;
39
+ }
40
+ }
41
+ catch {
42
+ // File doesn't exist or unreadable — fall through
43
+ }
44
+ return null;
45
+ }
46
+ /**
47
+ * Resolve the native host port. Priority:
48
+ * 1. Explicit config (port or bridgePort)
49
+ * 2. Port discovery file (~/.thinkbrowse/port)
50
+ * 3. DEFAULT_PORT (3012)
51
+ */
52
+ function resolvePort(config) {
53
+ const explicit = config.port ?? config.bridgePort;
54
+ if (explicit)
55
+ return explicit;
56
+ return readDiscoveredPort() ?? DEFAULT_PORT;
57
+ }
58
+ /**
59
+ * Check if the native host is reachable. Tries the discovery-file port first,
60
+ * then falls back to DEFAULT_PORT if the discovery port fails.
61
+ *
62
+ * @throws {LocalEndpointNotFoundError} when the native host is not reachable.
63
+ */
64
+ async function detectLocalEndpoint(config) {
65
+ const timeoutMs = config.healthCheckTimeoutMs ?? 1000;
66
+ const explicit = config.port ?? config.bridgePort;
67
+ // Build list of ports to try (deduplicated)
68
+ const portsToTry = [];
69
+ if (explicit) {
70
+ portsToTry.push(explicit);
71
+ }
72
+ else {
73
+ const discovered = readDiscoveredPort();
74
+ if (discovered)
75
+ portsToTry.push(discovered);
76
+ if (!portsToTry.includes(DEFAULT_PORT))
77
+ portsToTry.push(DEFAULT_PORT);
78
+ }
79
+ for (const port of portsToTry) {
80
+ try {
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
83
+ const resp = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
84
+ clearTimeout(timer);
85
+ if (resp.ok) {
86
+ return `http://localhost:${port}`;
87
+ }
88
+ }
89
+ catch {
90
+ // not reachable on this port
91
+ }
92
+ }
93
+ throw new LocalEndpointNotFoundError(portsToTry[0] ?? DEFAULT_PORT);
94
+ }
95
+ export class LocalClient {
96
+ baseUrl;
97
+ tabId = null;
98
+ /** Agent session ID from the extension's session registry. */
99
+ agentSessionId = null;
100
+ windowId = null;
101
+ /** mech-browser-service base URL for `/api/cache/*` only (native host does not serve these routes). */
102
+ serviceBaseUrl;
103
+ serviceApiKey;
104
+ constructor(config) {
105
+ if (config._resolvedBaseUrl) {
106
+ // Created via LocalClient.create() — baseUrl already resolved via health check
107
+ this.baseUrl = config._resolvedBaseUrl;
108
+ }
109
+ else {
110
+ // Direct instantiation: resolve port from config → discovery file → default.
111
+ // Intentionally silent (no throw) — falls back to the discovery file so that
112
+ // new LocalClient({}) "just works" when the native host is running.
113
+ // Use LocalClient.create() if you want a reachability check before connecting.
114
+ const port = resolvePort(config);
115
+ this.baseUrl = `http://localhost:${port}`;
116
+ }
117
+ this.serviceBaseUrl = config.serviceBaseUrl?.replace(/\/$/, '');
118
+ this.serviceApiKey = config.serviceApiKey;
119
+ }
120
+ /**
121
+ * Create a LocalClient after verifying the native host is reachable.
122
+ *
123
+ * @throws {LocalEndpointNotFoundError} when the native host is not reachable.
124
+ */
125
+ static async create(config = {}) {
126
+ const baseUrl = await detectLocalEndpoint(config);
127
+ return new LocalClient({
128
+ ...config,
129
+ _resolvedBaseUrl: baseUrl,
130
+ });
131
+ }
132
+ async request(method, path, body, opts) {
133
+ const url = `${this.baseUrl}${path}`;
134
+ const headers = {};
135
+ const tabId = opts && 'tabId' in opts ? opts.tabId : this.tabId;
136
+ const sessionId = opts && 'sessionId' in opts ? opts.sessionId : this.agentSessionId;
137
+ if (tabId) {
138
+ headers['x-tab-id'] = tabId;
139
+ }
140
+ if (sessionId) {
141
+ headers['x-session-id'] = sessionId;
142
+ }
143
+ if (body) {
144
+ headers['Content-Type'] = 'application/json';
145
+ }
146
+ const response = await fetch(url, {
147
+ method,
148
+ headers,
149
+ body: body ? JSON.stringify(body) : undefined,
150
+ });
151
+ if (!response.ok) {
152
+ const text = await response.text();
153
+ let parsed;
154
+ try {
155
+ parsed = JSON.parse(text);
156
+ }
157
+ catch {
158
+ // not JSON
159
+ }
160
+ throw new BridgeError(parsed?.error || `HTTP ${response.status}: ${text}`, response.status, parsed?.code, parsed?.retryable);
161
+ }
162
+ return response.json();
163
+ }
164
+ async requestWithoutSessionHeaders(method, path, body) {
165
+ const url = `${this.baseUrl}${path}`;
166
+ const headers = body ? { 'Content-Type': 'application/json' } : {};
167
+ const response = await fetch(url, {
168
+ method,
169
+ headers,
170
+ body: body ? JSON.stringify(body) : undefined,
171
+ });
172
+ if (!response.ok) {
173
+ const text = await response.text();
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(text);
177
+ }
178
+ catch {
179
+ // not JSON
180
+ }
181
+ throw new BridgeError(parsed?.error || `HTTP ${response.status}: ${text}`, response.status, parsed?.code, parsed?.retryable);
182
+ }
183
+ return response.json();
184
+ }
185
+ async getBridgeHealth(timeoutMs = 2500) {
186
+ try {
187
+ const response = await fetch(`${this.baseUrl}/health`, {
188
+ signal: AbortSignal.timeout(timeoutMs),
189
+ });
190
+ if (!response.ok) {
191
+ return { ok: false, message: `HTTP ${response.status}` };
192
+ }
193
+ const body = await response.json();
194
+ const inner = isObject(body.data) ? body.data : body;
195
+ const isEmptyObject = isObject(inner) && Object.keys(inner).length === 0;
196
+ const hasRecognizedShape = body.success === true
197
+ || body.status === 'ok'
198
+ || isEmptyObject
199
+ || typeof inner.service === 'string'
200
+ || typeof inner.extensionConnected === 'boolean'
201
+ || typeof inner.recoveryState === 'string';
202
+ if (!hasRecognizedShape) {
203
+ return { ok: false, message: 'Malformed /health response' };
204
+ }
205
+ const extensionConnected = typeof inner?.extensionConnected === 'boolean'
206
+ ? inner.extensionConnected
207
+ : typeof body.extensionConnected === 'boolean'
208
+ ? body.extensionConnected
209
+ : undefined;
210
+ const lastDisconnectReason = inner?.lastExtensionDisconnectReason ?? body.lastExtensionDisconnectReason;
211
+ const recoveryState = inner?.recoveryState ?? body.recoveryState;
212
+ const recentDisconnectCount = typeof inner?.recentDisconnectCount === 'number'
213
+ ? inner.recentDisconnectCount
214
+ : typeof body.recentDisconnectCount === 'number'
215
+ ? body.recentDisconnectCount
216
+ : undefined;
217
+ const lastRecoveredAt = inner?.lastRecoveredAt ?? body.lastRecoveredAt;
218
+ const service = typeof inner?.service === 'string'
219
+ ? inner.service
220
+ : typeof body.service === 'string'
221
+ ? body.service
222
+ : isEmptyObject
223
+ ? 'health (empty response)'
224
+ : body.status === 'ok'
225
+ ? 'ok'
226
+ : 'health';
227
+ const isCircuitBreakerTrip = typeof inner?.isCircuitBreakerTrip === 'boolean'
228
+ ? inner.isCircuitBreakerTrip
229
+ : typeof body.isCircuitBreakerTrip === 'boolean'
230
+ ? body.isCircuitBreakerTrip
231
+ : extensionConnected === false && typeof lastDisconnectReason === 'string'
232
+ // Fallback to the current bridge wording until /health exposes a structured flag everywhere.
233
+ ? lastDisconnectReason.startsWith('Circuit breaker:')
234
+ : undefined;
235
+ let message = service;
236
+ if (typeof extensionConnected === 'boolean') {
237
+ message += ` (extensionConnected=${extensionConnected})`;
238
+ }
239
+ if (typeof recoveryState === 'string' && recoveryState !== 'healthy') {
240
+ message += `, recoveryState=${recoveryState}`;
241
+ }
242
+ if (typeof recentDisconnectCount === 'number' && recentDisconnectCount > 0) {
243
+ message += `, recentDisconnects=${recentDisconnectCount}`;
244
+ }
245
+ if (typeof lastRecoveredAt === 'string' && lastRecoveredAt.length > 0) {
246
+ message += `, lastRecoveredAt=${lastRecoveredAt}`;
247
+ }
248
+ if (typeof lastDisconnectReason === 'string' && lastDisconnectReason.length > 0 && extensionConnected === false) {
249
+ message += `, lastDisconnect=${lastDisconnectReason}`;
250
+ }
251
+ return {
252
+ ok: true,
253
+ ...(extensionConnected !== undefined ? { extensionConnected } : {}),
254
+ ...(typeof lastDisconnectReason === 'string' ? { lastDisconnectReason } : {}),
255
+ ...(typeof recoveryState === 'string' ? { recoveryState } : {}),
256
+ ...(typeof recentDisconnectCount === 'number' ? { recentDisconnectCount } : {}),
257
+ ...(typeof lastRecoveredAt === 'string' ? { lastRecoveredAt } : {}),
258
+ ...(typeof isCircuitBreakerTrip === 'boolean' ? { isCircuitBreakerTrip } : {}),
259
+ message,
260
+ };
261
+ }
262
+ catch (error) {
263
+ return {
264
+ ok: false,
265
+ message: error instanceof Error ? error.message : 'unreachable',
266
+ };
267
+ }
268
+ }
269
+ buildSessionResult(tabId, extras) {
270
+ return {
271
+ sessionId: `local-${tabId}`,
272
+ status: 'active',
273
+ tabId,
274
+ ...(extras?.windowId !== undefined ? { windowId: extras.windowId } : {}),
275
+ ...(extras?.url !== undefined ? { url: extras.url } : {}),
276
+ ...(extras?.title !== undefined ? { title: extras.title } : {}),
277
+ };
278
+ }
279
+ async unregisterCurrentAgentSession() {
280
+ if (!this.agentSessionId)
281
+ return;
282
+ try {
283
+ await this.request('DELETE', '/sessions/unregister');
284
+ }
285
+ catch {
286
+ // best effort
287
+ }
288
+ finally {
289
+ this.agentSessionId = null;
290
+ }
291
+ }
292
+ async registerTabSession(tabId, sessionId) {
293
+ const result = await this.requestWithoutSessionHeaders('POST', '/sessions/register', sessionId ? { tabId, sessionId } : { tabId });
294
+ return {
295
+ sessionId: result.data?.sessionId,
296
+ windowId: result.data?.windowId,
297
+ };
298
+ }
299
+ // --- Session lifecycle ---
300
+ // In local mode, "sessions" map to browser tabs
301
+ async createSession(_options) {
302
+ // Pre-flight: check that the Chrome extension is connected before trying to
303
+ // open a tab. Without this, the error leaks as "X-Tab-Id header required"
304
+ // (an internal routing detail) rather than an actionable message.
305
+ // Uses === false so a missing field (future bridge compat) stays truthy.
306
+ // Note: request() has no explicit timeout beyond the OS default. If the
307
+ // bridge is hung this call can block for ~75-120s — acceptable given the
308
+ // alternative is waiting for a tab creation to hang instead.
309
+ // Note: TOCTOU — the extension can disconnect after the health check passes.
310
+ // The health check reduces but does not eliminate that race; the original
311
+ // error from /api/tabs/new would surface in that case.
312
+ const health = await this.request('GET', '/health');
313
+ if (health.extensionConnected === false) {
314
+ throw new Error('ThinkRun extension is not connected. Open a Chromium-based browser (e.g. Chrome or Helium) with the extension active, then try again.');
315
+ }
316
+ // Create a new tab via the native host.
317
+ // Uses /api/tabs/new — NOT /api/tabs (GET-only list endpoint).
318
+ // The bridge wraps all responses as { success: true, data: { ... } }.
319
+ const result = await this.request('POST', '/api/tabs/new');
320
+ const tab = result.data;
321
+ if (!tab)
322
+ throw new Error('Tab creation failed: bridge returned no data');
323
+ this.tabId = tab.tabId;
324
+ // Register an agent session so the extension tracks this tab
325
+ // and is immune to the user manually switching Chrome tabs.
326
+ try {
327
+ const numericTabId = Number(tab.tabId);
328
+ if (Number.isFinite(numericTabId) && numericTabId > 0) {
329
+ const regResult = await this.request('POST', '/sessions/register', { tabId: numericTabId });
330
+ if (regResult.data?.sessionId) {
331
+ this.agentSessionId = regResult.data.sessionId;
332
+ }
333
+ }
334
+ }
335
+ catch {
336
+ // Session registry is optional — commands still work via _tabId
337
+ }
338
+ return this.buildSessionResult(Number(tab.tabId), { url: tab.url, title: tab.title });
339
+ }
340
+ async getSession(sessionId) {
341
+ const tabId = this.extractTabId(sessionId);
342
+ const result = await this.request('GET', `/api/tabs/${tabId}`);
343
+ return {
344
+ sessionId,
345
+ status: 'active',
346
+ url: result.url,
347
+ title: result.title,
348
+ };
349
+ }
350
+ async deleteSession(sessionId) {
351
+ const tabId = this.extractTabId(sessionId);
352
+ await this.request('DELETE', `/api/tabs/${tabId}`);
353
+ if (this.tabId === tabId) {
354
+ this.tabId = null;
355
+ }
356
+ // Clean up agent session registry — X-Session-Id header carries the UUID
357
+ if (this.agentSessionId) {
358
+ try {
359
+ await this.request('DELETE', '/sessions/unregister');
360
+ }
361
+ catch { /* best-effort */ }
362
+ this.agentSessionId = null;
363
+ }
364
+ return { success: true };
365
+ }
366
+ async listSessions() {
367
+ const result = await this.request('GET', '/api/tabs');
368
+ return (result.tabs || []).map(tab => ({
369
+ sessionId: `local-${tab.tabId}`,
370
+ status: 'active',
371
+ url: tab.url,
372
+ title: tab.title,
373
+ }));
374
+ }
375
+ async attachToTab(tabId) {
376
+ const previousTabId = this.tabId ? parseInt(this.tabId, 10) : undefined;
377
+ const previousSessionId = this.agentSessionId;
378
+ await this.switchToTab(tabId);
379
+ if (previousTabId === tabId && previousSessionId) {
380
+ // Idempotent re-attach refreshes extension-side session state only.
381
+ // We preserve the existing lock when we still hold it, but recreate it if it was removed.
382
+ const rebound = await this.registerTabSession(tabId, previousSessionId);
383
+ if (!isTabLockHeldByCurrentAgent(tabId)) {
384
+ acquireTabLock(tabId);
385
+ }
386
+ this.tabId = String(tabId);
387
+ this.agentSessionId = rebound.sessionId ?? previousSessionId;
388
+ this.windowId = rebound.windowId ?? this.windowId;
389
+ return this.buildSessionResult(tabId, this.windowId !== null ? { windowId: this.windowId } : undefined);
390
+ }
391
+ acquireTabLock(tabId);
392
+ try {
393
+ const rebound = await this.registerTabSession(tabId);
394
+ await this.unregisterCurrentAgentSession();
395
+ if (previousTabId !== undefined && previousTabId !== tabId) {
396
+ releaseTabLock(previousTabId);
397
+ }
398
+ this.tabId = String(tabId);
399
+ this.agentSessionId = rebound.sessionId ?? null;
400
+ this.windowId = rebound.windowId ?? null;
401
+ return this.buildSessionResult(tabId, this.windowId !== null ? { windowId: this.windowId } : undefined);
402
+ }
403
+ catch (err) {
404
+ releaseTabLock(tabId);
405
+ throw err;
406
+ }
407
+ }
408
+ async switchToTab(tabId) {
409
+ await this.request('POST', '/api/tabs/switch', { tabId }, {
410
+ tabId: String(tabId),
411
+ sessionId: this.tabId === String(tabId) ? this.agentSessionId : null,
412
+ });
413
+ return { success: true };
414
+ }
415
+ async openNewWindow(url = 'about:blank') {
416
+ const previousTabId = this.tabId ? parseInt(this.tabId, 10) : undefined;
417
+ const result = await this.requestWithoutSessionHeaders('POST', '/api/sessions/new-window', { url, focused: true });
418
+ const tabId = result.data?.tabId;
419
+ if (!tabId) {
420
+ throw new Error('Native host returned no tabId for new window');
421
+ }
422
+ acquireTabLock(tabId);
423
+ try {
424
+ const rebound = await this.registerTabSession(tabId);
425
+ await this.unregisterCurrentAgentSession();
426
+ if (previousTabId !== undefined && previousTabId !== tabId) {
427
+ releaseTabLock(previousTabId);
428
+ }
429
+ this.tabId = String(tabId);
430
+ this.agentSessionId = rebound.sessionId ?? null;
431
+ this.windowId = result.data?.windowId ?? null;
432
+ return this.buildSessionResult(tabId, {
433
+ windowId: result.data?.windowId,
434
+ url: result.data?.url,
435
+ title: result.data?.title,
436
+ });
437
+ }
438
+ catch (err) {
439
+ releaseTabLock(tabId);
440
+ throw err;
441
+ }
442
+ }
443
+ async releaseSession(sessionId) {
444
+ let tabId = this.tabId ? parseInt(this.tabId, 10) : NaN;
445
+ if (sessionId) {
446
+ tabId = parseInt(this.extractTabId(sessionId), 10);
447
+ }
448
+ if (!Number.isFinite(tabId) || tabId <= 0) {
449
+ if (!sessionId) {
450
+ return { success: true };
451
+ }
452
+ return {
453
+ success: false,
454
+ error: `Invalid local session ID: ${sessionId}`,
455
+ code: 'INVALID_SESSION_ID',
456
+ hint: 'Expected a local session ID like "local-42".',
457
+ };
458
+ }
459
+ await this.unregisterCurrentAgentSession();
460
+ releaseTabLock(tabId);
461
+ if (this.tabId === String(tabId)) {
462
+ this.tabId = null;
463
+ this.windowId = null;
464
+ }
465
+ return { success: true };
466
+ }
467
+ async listArtifacts(_sessionId) {
468
+ return { success: false, error: 'Artifacts listing is only available in cloud mode' };
469
+ }
470
+ // --- Navigation ---
471
+ async navigate(sessionId, params) {
472
+ this.ensureTab(sessionId);
473
+ return this.request('POST', '/api/navigate', params);
474
+ }
475
+ async wait(sessionId, params) {
476
+ this.ensureTab(sessionId);
477
+ return this.request('POST', '/api/wait', params);
478
+ }
479
+ // --- Interaction ---
480
+ async click(sessionId, params) {
481
+ this.ensureTab(sessionId);
482
+ return this.request('POST', '/api/click', params);
483
+ }
484
+ async type(sessionId, params) {
485
+ this.ensureTab(sessionId);
486
+ return this.request('POST', '/api/type', params);
487
+ }
488
+ async fill(sessionId, params) {
489
+ this.ensureTab(sessionId);
490
+ return this.request('POST', '/api/fill', params);
491
+ }
492
+ async press(sessionId, params) {
493
+ this.ensureTab(sessionId);
494
+ return this.request('POST', '/api/press', params);
495
+ }
496
+ async scroll(sessionId, params) {
497
+ this.ensureTab(sessionId);
498
+ return this.request('POST', '/api/scroll', params);
499
+ }
500
+ async hover(sessionId, params) {
501
+ this.ensureTab(sessionId);
502
+ return this.request('POST', '/api/hover', params);
503
+ }
504
+ async select(sessionId, params) {
505
+ this.ensureTab(sessionId);
506
+ return this.request('POST', '/api/select', params);
507
+ }
508
+ // --- Observation ---
509
+ async snapshot(sessionId) {
510
+ this.ensureTab(sessionId);
511
+ // Native host returns { success, data: <snapshot> } — map to { success, snapshot }
512
+ const raw = await this.request('POST', '/api/snapshot');
513
+ const snapshot = typeof raw.data === 'string' ? raw.data
514
+ : raw.data != null ? JSON.stringify(raw.data, null, 2) : undefined;
515
+ return { success: raw.success, snapshot, error: raw.error };
516
+ }
517
+ async screenshot(sessionId, params) {
518
+ this.ensureTab(sessionId);
519
+ // Native host returns { success, data: { screenshot: "base64..." } } — map to { success, screenshot }
520
+ const raw = await this.request('POST', '/api/screenshot', params || {});
521
+ return { success: raw.success, screenshot: raw.data?.screenshot, error: raw.error };
522
+ }
523
+ async extract(sessionId, params) {
524
+ this.ensureTab(sessionId);
525
+ return this.request('POST', '/api/extract', params);
526
+ }
527
+ async evaluate(sessionId, params) {
528
+ this.ensureTab(sessionId);
529
+ // Native host returns { success, data: <value> } but EvaluateResult expects { success, result }.
530
+ // Unwrap sole-key { result } wrappers to match CLI LocalAdapter / thinkrun evaluate.
531
+ const raw = await this.request('POST', '/api/evaluate', params);
532
+ return { success: raw.success, result: unwrapEvaluatePayload(raw.data), error: raw.error };
533
+ }
534
+ // --- Wait for text ---
535
+ async waitForText(sessionId, params) {
536
+ this.ensureTab(sessionId);
537
+ return this.request('POST', '/api/wait-for-text', params);
538
+ }
539
+ // --- Dialog handling ---
540
+ async getDialog(sessionId) {
541
+ this.ensureTab(sessionId);
542
+ const result = await this.request('POST', '/api/dialog');
543
+ return {
544
+ success: true,
545
+ hasDialog: false, // Local mode auto-handles dialogs via interception
546
+ dialog: null,
547
+ history: result.history || [],
548
+ };
549
+ }
550
+ async handleDialog(sessionId, params) {
551
+ // Local mode auto-handles dialogs — return history for visibility
552
+ this.ensureTab(sessionId);
553
+ const result = await this.request('POST', '/api/dialog');
554
+ return {
555
+ success: true,
556
+ data: result,
557
+ };
558
+ }
559
+ // --- Navigation history ---
560
+ async goBack(sessionId) {
561
+ this.ensureTab(sessionId);
562
+ return this.request('POST', '/api/go-back');
563
+ }
564
+ async goForward(sessionId) {
565
+ this.ensureTab(sessionId);
566
+ return this.request('POST', '/api/go-forward');
567
+ }
568
+ // --- Tab management ---
569
+ async listTabs(_sessionId) {
570
+ const result = await this.request('GET', '/api/tabs');
571
+ return {
572
+ success: true,
573
+ tabs: result.tabs || [],
574
+ };
575
+ }
576
+ async newTab(_sessionId, url) {
577
+ const result = await this.request('POST', '/api/tabs/new', url ? { url } : undefined);
578
+ return result.data;
579
+ }
580
+ async closeTab(sessionId, tabId) {
581
+ const id = tabId ?? parseInt(this.extractTabId(sessionId), 10);
582
+ await this.request('POST', '/api/tabs/close', { tabId: id }, {
583
+ tabId: String(id),
584
+ sessionId: this.tabId === String(id) ? this.agentSessionId : null,
585
+ });
586
+ return { success: true };
587
+ }
588
+ // --- Monitoring ---
589
+ async getConsoleMessages(sessionId) {
590
+ this.ensureTab(sessionId);
591
+ const result = await this.request('POST', '/api/console');
592
+ return {
593
+ success: true,
594
+ logs: result.logs || [],
595
+ count: result.count || (result.logs?.length ?? 0),
596
+ };
597
+ }
598
+ async getNetworkRequests(sessionId) {
599
+ this.ensureTab(sessionId);
600
+ const result = await this.request('POST', '/api/network');
601
+ return {
602
+ success: true,
603
+ requests: result.requests || [],
604
+ count: result.count || (result.requests?.length ?? 0),
605
+ };
606
+ }
607
+ async clearLogs(sessionId) {
608
+ this.ensureTab(sessionId);
609
+ const result = await this.request('POST', '/api/clear-logs');
610
+ return { success: result.success !== false };
611
+ }
612
+ // --- Task planning (not available in local mode) ---
613
+ async createTask(_params) {
614
+ return { success: false, error: 'Task planning is only available in cloud mode' };
615
+ }
616
+ async executeTask(_planId) {
617
+ return { success: false, error: 'Task execution is only available in cloud mode' };
618
+ }
619
+ async getTaskStatus(_planId) {
620
+ return { success: false, error: 'Task status is only available in cloud mode' };
621
+ }
622
+ async runLocalAction(params) {
623
+ const tabId = this.requireBoundLocalActionTabId();
624
+ const result = await this.requestWithoutSessionHeaders('POST', '/tasks', {
625
+ task: params.instruction,
626
+ tabId,
627
+ ...(params.maxIterations !== undefined ? { maxIterations: params.maxIterations } : {}),
628
+ });
629
+ return {
630
+ success: result.success !== false,
631
+ actionId: result.data?.taskId,
632
+ status: result.data?.status,
633
+ error: result.error,
634
+ code: result.code,
635
+ };
636
+ }
637
+ async getLocalActionStatus(actionId) {
638
+ const result = await this.requestWithoutSessionHeaders('GET', `/tasks/${encodeURIComponent(actionId)}/status`);
639
+ return {
640
+ success: result.success !== false,
641
+ actionId: result.data?.id,
642
+ status: result.data?.status,
643
+ instruction: result.data?.task,
644
+ startedAt: result.data?.startedAt,
645
+ completedAt: result.data?.completedAt,
646
+ result: result.data?.result,
647
+ error: result.data?.error ?? result.error,
648
+ iteration: result.data?.iteration,
649
+ maxIterations: result.data?.maxIterations,
650
+ code: result.code,
651
+ };
652
+ }
653
+ async cancelLocalAction(actionId) {
654
+ const result = await this.requestWithoutSessionHeaders('POST', `/tasks/${encodeURIComponent(actionId)}/cancel`, {});
655
+ return {
656
+ success: result.success !== false,
657
+ actionId: result.data?.taskId,
658
+ status: result.data?.status,
659
+ error: result.error,
660
+ code: result.code,
661
+ };
662
+ }
663
+ async getLocalDiagnostics(options) {
664
+ const boundTabId = this.tabId && /^\d+$/.test(this.tabId)
665
+ ? Number(this.tabId)
666
+ : null;
667
+ const effectiveTabId = boundTabId !== null ? String(boundTabId) : null;
668
+ const lockOwner = boundTabId !== null ? getTabLock(boundTabId) : null;
669
+ const staleLock = boundTabId !== null ? isTabLockStale(boundTabId) : false;
670
+ const currentAgentId = resolveAgentId();
671
+ const runtimeSessionPresent = typeof this.agentSessionId === 'string' && this.agentSessionId.length > 0;
672
+ const sameControllerLock = !!lockOwner?.agentId && lockOwner.agentId === currentAgentId;
673
+ const continuity = (() => {
674
+ if (boundTabId === null && !lockOwner) {
675
+ return {
676
+ state: 'unbound',
677
+ effectiveTabId: null,
678
+ hint: 'No local tab is currently attached. Use tab_attach or window_new first.',
679
+ runtimeSessionPresent: false,
680
+ controlSessionPresent: false,
681
+ controlLeaseHeld: false,
682
+ staleLock: false,
683
+ lockOwner: null,
684
+ };
685
+ }
686
+ if (lockOwner?.agentId && lockOwner.agentId !== currentAgentId) {
687
+ return {
688
+ state: staleLock ? 'stale_reclaimable' : 'foreign_controller_live',
689
+ effectiveTabId,
690
+ hint: staleLock
691
+ ? `Tab ${effectiveTabId} still has a stale foreign lock. Re-attach to reclaim it safely if the other controller is really gone.`
692
+ : `Tab ${effectiveTabId} is actively controlled by another local agent.`,
693
+ runtimeSessionPresent,
694
+ controlSessionPresent: false,
695
+ controlLeaseHeld: false,
696
+ staleLock,
697
+ lockOwner,
698
+ };
699
+ }
700
+ if (boundTabId !== null && runtimeSessionPresent) {
701
+ return {
702
+ state: 'same_controller_read_resumable',
703
+ effectiveTabId,
704
+ hint: `Same-controller continuity is intact for tab ${effectiveTabId}.`,
705
+ runtimeSessionPresent: true,
706
+ controlSessionPresent: false,
707
+ controlLeaseHeld: false,
708
+ staleLock,
709
+ lockOwner,
710
+ };
711
+ }
712
+ if (boundTabId !== null && sameControllerLock) {
713
+ return {
714
+ state: 'same_controller_missing_runtime_registration',
715
+ effectiveTabId,
716
+ hint: `Same-controller continuity is proven for tab ${effectiveTabId}, but the runtime session registration is missing.`,
717
+ runtimeSessionPresent: false,
718
+ controlSessionPresent: false,
719
+ controlLeaseHeld: false,
720
+ staleLock,
721
+ lockOwner,
722
+ };
723
+ }
724
+ return {
725
+ state: 'ambiguous_blocked',
726
+ effectiveTabId,
727
+ hint: effectiveTabId
728
+ ? `Local continuity for tab ${effectiveTabId} could not be proven from the current MCP binding and lock state.`
729
+ : 'Local continuity could not be proven from the current MCP binding state.',
730
+ runtimeSessionPresent,
731
+ controlSessionPresent: false,
732
+ controlLeaseHeld: false,
733
+ staleLock,
734
+ lockOwner,
735
+ };
736
+ })();
737
+ const bridge = await this.getBridgeHealth(options?.bridgeTimeoutMs);
738
+ const lastReclaimAudit = (() => {
739
+ const audit = getLastReclaimAudit();
740
+ if (!audit || boundTabId === null || audit.tabId !== boundTabId)
741
+ return null;
742
+ return audit;
743
+ })();
744
+ return {
745
+ success: true,
746
+ mode: 'local',
747
+ boundSession: boundTabId !== null
748
+ ? {
749
+ sessionId: `local-${boundTabId}`,
750
+ tabId: boundTabId,
751
+ ...(this.agentSessionId ? { agentSessionId: this.agentSessionId } : {}),
752
+ ...(this.windowId !== null ? { windowId: this.windowId } : {}),
753
+ }
754
+ : null,
755
+ continuity,
756
+ bridge: {
757
+ healthy: bridge.ok,
758
+ ...(bridge.extensionConnected !== undefined ? { extensionConnected: bridge.extensionConnected } : {}),
759
+ ...(bridge.recoveryState ? { recoveryState: bridge.recoveryState } : {}),
760
+ ...(bridge.recentDisconnectCount !== undefined ? { recentDisconnectCount: bridge.recentDisconnectCount } : {}),
761
+ ...(bridge.lastRecoveredAt ? { lastRecoveredAt: bridge.lastRecoveredAt } : {}),
762
+ ...(bridge.lastDisconnectReason ? { lastDisconnectReason: bridge.lastDisconnectReason } : {}),
763
+ ...(bridge.isCircuitBreakerTrip !== undefined ? { isCircuitBreakerTrip: bridge.isCircuitBreakerTrip } : {}),
764
+ message: bridge.message,
765
+ },
766
+ lastReclaimAudit,
767
+ };
768
+ }
769
+ async resetLocalConnection() {
770
+ const getString = (value) => typeof value === 'string' ? value : undefined;
771
+ const getBoolean = (value) => typeof value === 'boolean' ? value : undefined;
772
+ let response;
773
+ try {
774
+ response = await fetch(`${this.baseUrl}/reset-connection`, {
775
+ method: 'POST',
776
+ signal: AbortSignal.timeout(5000),
777
+ });
778
+ }
779
+ catch (error) {
780
+ const message = error instanceof Error ? error.message : 'fetch failed';
781
+ const errorCode = typeof error === 'object' && error !== null && 'code' in error
782
+ ? error.code
783
+ : undefined;
784
+ const causeCode = error instanceof Error
785
+ && typeof error.cause === 'object'
786
+ && error.cause !== null
787
+ && 'code' in error.cause
788
+ ? error.cause.code
789
+ : undefined;
790
+ const errorName = error instanceof Error ? error.name : undefined;
791
+ return {
792
+ success: false,
793
+ code: errorCode === 'ECONNREFUSED' || causeCode === 'ECONNREFUSED'
794
+ ? 'NATIVE_HOST_UNREACHABLE'
795
+ : errorName === 'TimeoutError'
796
+ ? 'BRIDGE_TIMEOUT'
797
+ : 'API_ERROR',
798
+ error: message,
799
+ };
800
+ }
801
+ let body = {};
802
+ let bodyParsed = false;
803
+ try {
804
+ const parsed = await response.json();
805
+ if (isObject(parsed)) {
806
+ body = parsed;
807
+ bodyParsed = true;
808
+ }
809
+ }
810
+ catch {
811
+ // non-JSON body falls through to status/error handling
812
+ }
813
+ if (response.status === 409) {
814
+ const data = isObject(body.data) ? body.data : null;
815
+ const lastDisconnectReason = getString(data?.lastDisconnectReason)
816
+ ?? getString(body.lastDisconnectReason)
817
+ ?? 'unknown';
818
+ return {
819
+ success: false,
820
+ code: 'TRANSPORT_DISCONNECTED',
821
+ error: lastDisconnectReason,
822
+ lastDisconnectReason,
823
+ hint: 'Reload the ThinkRun extension in your Chromium-based browser, then retry.',
824
+ };
825
+ }
826
+ if (!response.ok || body?.success === false) {
827
+ return {
828
+ success: false,
829
+ code: 'API_ERROR',
830
+ error: getString(body.error) ?? `HTTP ${response.status}`,
831
+ };
832
+ }
833
+ const data = isObject(body.data) ? body.data : null;
834
+ const wasConnected = getBoolean(data?.wasConnected);
835
+ const connected = getBoolean(data?.connected);
836
+ if (!bodyParsed || !data || typeof wasConnected !== 'boolean' || typeof connected !== 'boolean') {
837
+ const malformedSuccessMessage = 'Malformed success response: missing wasConnected/connected in data';
838
+ return {
839
+ success: false,
840
+ code: 'API_ERROR',
841
+ error: getString(body.error) ?? malformedSuccessMessage,
842
+ };
843
+ }
844
+ return {
845
+ success: true,
846
+ ok: true,
847
+ wasConnected,
848
+ connected,
849
+ };
850
+ }
851
+ // --- Stateless page cache (HTTP → mech-browser-service; not the native host) ---
852
+ resolvePageCacheAuth() {
853
+ const baseUrl = (this.serviceBaseUrl || process.env.THINKRUN_BASE_URL || '').replace(/\/$/, '');
854
+ const apiKey = this.serviceApiKey || process.env.THINKRUN_API_KEY || '';
855
+ if (!baseUrl || !apiKey)
856
+ return null;
857
+ return { baseUrl, apiKey };
858
+ }
859
+ pageCacheUnavailableError() {
860
+ return new Error('Page cache requires the ThinkRun API (native host has no /api/cache). ' +
861
+ 'Set THINKRUN_API_KEY and THINKRUN_BASE_URL, or run thinkrun-mcp with --api-key and --base-url so LocalClient receives service credentials.');
862
+ }
863
+ pageCacheRetryable(status) {
864
+ return status === 429 || status >= 500;
865
+ }
866
+ async pageCacheHtml(params) {
867
+ const auth = this.resolvePageCacheAuth();
868
+ if (!auth)
869
+ throw this.pageCacheUnavailableError();
870
+ try {
871
+ return await fetchPageCacheJson(auth.baseUrl, auth.apiKey, '/api/cache/html', params);
872
+ }
873
+ catch (e) {
874
+ if (e instanceof PageCacheRequestError) {
875
+ throw new BridgeError(e.message, e.status, e.code, this.pageCacheRetryable(e.status));
876
+ }
877
+ throw e;
878
+ }
879
+ }
880
+ async pageCacheText(params) {
881
+ const auth = this.resolvePageCacheAuth();
882
+ if (!auth)
883
+ throw this.pageCacheUnavailableError();
884
+ try {
885
+ return await fetchPageCacheJson(auth.baseUrl, auth.apiKey, '/api/cache/text', params);
886
+ }
887
+ catch (e) {
888
+ if (e instanceof PageCacheRequestError) {
889
+ throw new BridgeError(e.message, e.status, e.code, this.pageCacheRetryable(e.status));
890
+ }
891
+ throw e;
892
+ }
893
+ }
894
+ async pageCacheScreenshot(params) {
895
+ const auth = this.resolvePageCacheAuth();
896
+ if (!auth)
897
+ throw this.pageCacheUnavailableError();
898
+ try {
899
+ return await fetchPageCacheJson(auth.baseUrl, auth.apiKey, '/api/cache/screenshot', params);
900
+ }
901
+ catch (e) {
902
+ if (e instanceof PageCacheRequestError) {
903
+ throw new BridgeError(e.message, e.status, e.code, this.pageCacheRetryable(e.status));
904
+ }
905
+ throw e;
906
+ }
907
+ }
908
+ // --- Helpers ---
909
+ extractTabId(sessionId) {
910
+ return sessionId.replace(/^local-/, '');
911
+ }
912
+ ensureTab(sessionId) {
913
+ const tabId = this.extractTabId(sessionId);
914
+ this.tabId = tabId;
915
+ }
916
+ requireBoundLocalActionTabId() {
917
+ const tabId = this.tabId ? parseInt(this.tabId, 10) : NaN;
918
+ if (!Number.isFinite(tabId) || tabId <= 0) {
919
+ throw new Error('No local tab is currently attached. Use tab_attach or window_new first.');
920
+ }
921
+ if (!isTabLockHeldByCurrentAgent(tabId)) {
922
+ throw new Error(`Current agent does not own local tab ${tabId}. Re-attach before running local_action_run.`);
923
+ }
924
+ return tabId;
925
+ }
926
+ }
927
+ export class BridgeError extends Error {
928
+ status;
929
+ code;
930
+ retryable;
931
+ constructor(message, status, code, retryable) {
932
+ super(message);
933
+ this.status = status;
934
+ this.code = code;
935
+ this.retryable = retryable;
936
+ this.name = 'BridgeError';
937
+ }
938
+ }
939
+ export class LocalEndpointNotFoundError extends Error {
940
+ constructor(port) {
941
+ super(`Native host not reachable on port ${port}. ` +
942
+ `Make sure a Chromium-based browser (e.g. Chrome or Helium) is open with the ThinkRun extension installed and the native host set up. ` +
943
+ `See: https://thinkbrowse.io/docs#local-mode`);
944
+ this.name = 'LocalEndpointNotFoundError';
945
+ }
946
+ }
947
+ //# sourceMappingURL=local-client.js.map