@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.
- package/README.md +349 -0
- package/dist/bin/thinkrun.d.ts +6 -0
- package/dist/bin/thinkrun.d.ts.map +1 -0
- package/dist/bin/thinkrun.js +124 -0
- package/dist/bin/thinkrun.js.map +1 -0
- package/dist/scripts/browse.sh +1107 -0
- package/dist/src/adapters/cloud.d.ts +79 -0
- package/dist/src/adapters/cloud.d.ts.map +1 -0
- package/dist/src/adapters/cloud.js +637 -0
- package/dist/src/adapters/cloud.js.map +1 -0
- package/dist/src/adapters/index.d.ts +47 -0
- package/dist/src/adapters/index.d.ts.map +1 -0
- package/dist/src/adapters/index.js +211 -0
- package/dist/src/adapters/index.js.map +1 -0
- package/dist/src/adapters/local-command-retry.d.ts +12 -0
- package/dist/src/adapters/local-command-retry.d.ts.map +1 -0
- package/dist/src/adapters/local-command-retry.js +224 -0
- package/dist/src/adapters/local-command-retry.js.map +1 -0
- package/dist/src/adapters/local.d.ts +136 -0
- package/dist/src/adapters/local.d.ts.map +1 -0
- package/dist/src/adapters/local.js +1273 -0
- package/dist/src/adapters/local.js.map +1 -0
- package/dist/src/adapters/types.d.ts +45 -0
- package/dist/src/adapters/types.d.ts.map +1 -0
- package/dist/src/adapters/types.js +6 -0
- package/dist/src/adapters/types.js.map +1 -0
- package/dist/src/commands/actions.d.ts +135 -0
- package/dist/src/commands/actions.d.ts.map +1 -0
- package/dist/src/commands/actions.js +2207 -0
- package/dist/src/commands/actions.js.map +1 -0
- package/dist/src/commands/agent-init.d.ts +16 -0
- package/dist/src/commands/agent-init.d.ts.map +1 -0
- package/dist/src/commands/agent-init.js +222 -0
- package/dist/src/commands/agent-init.js.map +1 -0
- package/dist/src/commands/analyze.d.ts +11 -0
- package/dist/src/commands/analyze.d.ts.map +1 -0
- package/dist/src/commands/analyze.js +238 -0
- package/dist/src/commands/analyze.js.map +1 -0
- package/dist/src/commands/cache.d.ts +6 -0
- package/dist/src/commands/cache.d.ts.map +1 -0
- package/dist/src/commands/cache.js +147 -0
- package/dist/src/commands/cache.js.map +1 -0
- package/dist/src/commands/cloud.d.ts +6 -0
- package/dist/src/commands/cloud.d.ts.map +1 -0
- package/dist/src/commands/cloud.js +332 -0
- package/dist/src/commands/cloud.js.map +1 -0
- package/dist/src/commands/config.d.ts +7 -0
- package/dist/src/commands/config.d.ts.map +1 -0
- package/dist/src/commands/config.js +208 -0
- package/dist/src/commands/config.js.map +1 -0
- package/dist/src/commands/doctor.d.ts +127 -0
- package/dist/src/commands/doctor.d.ts.map +1 -0
- package/dist/src/commands/doctor.js +684 -0
- package/dist/src/commands/doctor.js.map +1 -0
- package/dist/src/commands/evaluate-helpers.d.ts +6 -0
- package/dist/src/commands/evaluate-helpers.d.ts.map +1 -0
- package/dist/src/commands/evaluate-helpers.js +13 -0
- package/dist/src/commands/evaluate-helpers.js.map +1 -0
- package/dist/src/commands/install.d.ts +118 -0
- package/dist/src/commands/install.d.ts.map +1 -0
- package/dist/src/commands/install.js +975 -0
- package/dist/src/commands/install.js.map +1 -0
- package/dist/src/commands/release.d.ts +7 -0
- package/dist/src/commands/release.d.ts.map +1 -0
- package/dist/src/commands/release.js +123 -0
- package/dist/src/commands/release.js.map +1 -0
- package/dist/src/commands/reset-connection.d.ts +17 -0
- package/dist/src/commands/reset-connection.d.ts.map +1 -0
- package/dist/src/commands/reset-connection.js +141 -0
- package/dist/src/commands/reset-connection.js.map +1 -0
- package/dist/src/commands/session-debug.d.ts +23 -0
- package/dist/src/commands/session-debug.d.ts.map +1 -0
- package/dist/src/commands/session-debug.js +267 -0
- package/dist/src/commands/session-debug.js.map +1 -0
- package/dist/src/commands/setup.d.ts +53 -0
- package/dist/src/commands/setup.d.ts.map +1 -0
- package/dist/src/commands/setup.js +249 -0
- package/dist/src/commands/setup.js.map +1 -0
- package/dist/src/config/store.d.ts +39 -0
- package/dist/src/config/store.d.ts.map +1 -0
- package/dist/src/config/store.js +290 -0
- package/dist/src/config/store.js.map +1 -0
- package/dist/src/daemon/access.d.ts +53 -0
- package/dist/src/daemon/access.d.ts.map +1 -0
- package/dist/src/daemon/access.js +87 -0
- package/dist/src/daemon/access.js.map +1 -0
- package/dist/src/daemon/bridge-envelope.d.ts +96 -0
- package/dist/src/daemon/bridge-envelope.d.ts.map +1 -0
- package/dist/src/daemon/bridge-envelope.js +235 -0
- package/dist/src/daemon/bridge-envelope.js.map +1 -0
- package/dist/src/daemon/utils.d.ts +43 -0
- package/dist/src/daemon/utils.d.ts.map +1 -0
- package/dist/src/daemon/utils.js +134 -0
- package/dist/src/daemon/utils.js.map +1 -0
- package/dist/src/errors.d.ts +60 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +87 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/local-bridge-timing.d.ts +31 -0
- package/dist/src/local-bridge-timing.d.ts.map +1 -0
- package/dist/src/local-bridge-timing.js +41 -0
- package/dist/src/local-bridge-timing.js.map +1 -0
- package/dist/src/obstacle-recovery/classify-script.d.ts +16 -0
- package/dist/src/obstacle-recovery/classify-script.d.ts.map +1 -0
- package/dist/src/obstacle-recovery/classify-script.js +53 -0
- package/dist/src/obstacle-recovery/classify-script.js.map +1 -0
- package/dist/src/obstacle-recovery/obstacle-classifier.d.ts +21 -0
- package/dist/src/obstacle-recovery/obstacle-classifier.d.ts.map +1 -0
- package/dist/src/obstacle-recovery/obstacle-classifier.js +37 -0
- package/dist/src/obstacle-recovery/obstacle-classifier.js.map +1 -0
- package/dist/src/obstacle-recovery/state-fingerprint.d.ts +26 -0
- package/dist/src/obstacle-recovery/state-fingerprint.d.ts.map +1 -0
- package/dist/src/obstacle-recovery/state-fingerprint.js +85 -0
- package/dist/src/obstacle-recovery/state-fingerprint.js.map +1 -0
- package/dist/src/obstacle-recovery/types.d.ts +44 -0
- package/dist/src/obstacle-recovery/types.d.ts.map +1 -0
- package/dist/src/obstacle-recovery/types.js +16 -0
- package/dist/src/obstacle-recovery/types.js.map +1 -0
- package/dist/src/output/formatter.d.ts +55 -0
- package/dist/src/output/formatter.d.ts.map +1 -0
- package/dist/src/output/formatter.js +55 -0
- package/dist/src/output/formatter.js.map +1 -0
- package/dist/src/output/mode.d.ts +11 -0
- package/dist/src/output/mode.d.ts.map +1 -0
- package/dist/src/output/mode.js +16 -0
- package/dist/src/output/mode.js.map +1 -0
- package/dist/src/protected-flow/detector.d.ts +26 -0
- package/dist/src/protected-flow/detector.d.ts.map +1 -0
- package/dist/src/protected-flow/detector.js +75 -0
- package/dist/src/protected-flow/detector.js.map +1 -0
- package/dist/src/protected-flow/types.d.ts +24 -0
- package/dist/src/protected-flow/types.d.ts.map +1 -0
- package/dist/src/protected-flow/types.js +28 -0
- package/dist/src/protected-flow/types.js.map +1 -0
- package/dist/src/session/agent-identity.d.ts +65 -0
- package/dist/src/session/agent-identity.d.ts.map +1 -0
- package/dist/src/session/agent-identity.js +133 -0
- package/dist/src/session/agent-identity.js.map +1 -0
- package/dist/src/session/cli-session-sync.d.ts +72 -0
- package/dist/src/session/cli-session-sync.d.ts.map +1 -0
- package/dist/src/session/cli-session-sync.js +244 -0
- package/dist/src/session/cli-session-sync.js.map +1 -0
- package/dist/src/session/context.d.ts +24 -0
- package/dist/src/session/context.d.ts.map +1 -0
- package/dist/src/session/context.js +165 -0
- package/dist/src/session/context.js.map +1 -0
- package/dist/src/session/continuity.d.ts +33 -0
- package/dist/src/session/continuity.d.ts.map +1 -0
- package/dist/src/session/continuity.js +179 -0
- package/dist/src/session/continuity.js.map +1 -0
- package/dist/src/session/errors.d.ts +9 -0
- package/dist/src/session/errors.d.ts.map +1 -0
- package/dist/src/session/errors.js +31 -0
- package/dist/src/session/errors.js.map +1 -0
- package/dist/src/session/local-continuity.d.ts +16 -0
- package/dist/src/session/local-continuity.d.ts.map +1 -0
- package/dist/src/session/local-continuity.js +146 -0
- package/dist/src/session/local-continuity.js.map +1 -0
- package/dist/src/session/signal-handler.d.ts +24 -0
- package/dist/src/session/signal-handler.d.ts.map +1 -0
- package/dist/src/session/signal-handler.js +35 -0
- package/dist/src/session/signal-handler.js.map +1 -0
- package/dist/src/shared/local-recovery-policy.d.ts +40 -0
- package/dist/src/shared/local-recovery-policy.d.ts.map +1 -0
- package/dist/src/shared/local-recovery-policy.js +59 -0
- package/dist/src/shared/local-recovery-policy.js.map +1 -0
- package/dist/src/shared/recovery-state.d.ts +3 -0
- package/dist/src/shared/recovery-state.d.ts.map +1 -0
- package/dist/src/shared/recovery-state.js +9 -0
- package/dist/src/shared/recovery-state.js.map +1 -0
- package/dist/src/types.d.ts +131 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +5 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils.d.ts +50 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +147 -0
- package/dist/src/utils.js.map +1 -0
- package/dist/src/working-location.d.ts +107 -0
- package/dist/src/working-location.d.ts.map +1 -0
- package/dist/src/working-location.js +651 -0
- package/dist/src/working-location.js.map +1 -0
- 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
|