@jobshimo/browser-link 0.25.2 → 0.26.0
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/dist/cdp/flow.d.ts +76 -8
- package/dist/cdp/flow.js +91 -8
- package/dist/cdp/flow.js.map +1 -1
- package/dist/cdp/transport.js +42 -3
- package/dist/cdp/transport.js.map +1 -1
- package/dist/extension/background.js +201 -16
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/flow-registry.d.ts +162 -0
- package/dist/extension/flow-registry.js +260 -0
- package/dist/extension/flow-registry.js.map +1 -0
- package/dist/extension/flow.d.ts +81 -5
- package/dist/extension/flow.js +96 -5
- package/dist/extension/flow.js.map +1 -1
- package/dist/extension/manifest.json +1 -1
- package/dist/extension/popup.html +123 -0
- package/dist/extension/popup.js +138 -0
- package/dist/extension/popup.js.map +1 -1
- package/dist/messages.d.ts +28 -1
- package/dist/tools/browser-definitions.js +2 -1
- package/dist/tools/browser-definitions.js.map +1 -1
- package/dist/tools/browser-dispatch.js +51 -5
- package/dist/tools/browser-dispatch.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs,
|
|
|
2
2
|
import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, } from './keymap.js';
|
|
3
3
|
import { resolveSettleParams, settleSafely } from './settle.js';
|
|
4
4
|
import { runFlow, } from './flow.js';
|
|
5
|
+
import { appendFlowHistory, createFlowRegistry, flowHistoryKey, parseFlowHistory, toHistoryEntry, } from './flow-registry.js';
|
|
5
6
|
import { DEFAULT_IDLE_TTL_MINUTES, IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STORAGE_KEY, clampIdleTtlMinutes, parseIncomingSettings, shouldAcceptIncomingSettings, shouldDisconnectForIdle, shouldScheduleIdleSweep, } from './idle-policy.js';
|
|
6
7
|
import { DEFAULT_FLOW_RECORDING_ENABLED, FLOW_RECORDING_STORAGE_KEY, FLOW_RECORDING_UPDATED_AT_STORAGE_KEY, normalizeFlowRecordingEnabled, parseIncomingFlowRecordingSettings, } from './flow-recording-policy.js';
|
|
7
8
|
import { buildRecorderJs, buildStopRecorderJs } from './inpage/recorder.js';
|
|
@@ -145,6 +146,8 @@ function isRuntimeMessage(msg) {
|
|
|
145
146
|
if (m.action === 'saveRecording') {
|
|
146
147
|
return typeof m.tabId === 'number' && typeof m.name === 'string';
|
|
147
148
|
}
|
|
149
|
+
if (m.action === 'cancelFlow')
|
|
150
|
+
return typeof m.flowId === 'string';
|
|
148
151
|
if (typeof m.tabId !== 'number')
|
|
149
152
|
return false;
|
|
150
153
|
return (m.action === 'connect' ||
|
|
@@ -155,9 +158,47 @@ function isRuntimeMessage(msg) {
|
|
|
155
158
|
m.action === 'recordingStatus' ||
|
|
156
159
|
m.action === 'startRecording' ||
|
|
157
160
|
m.action === 'stopRecording' ||
|
|
158
|
-
m.action === 'discardRecording'
|
|
161
|
+
m.action === 'discardRecording' ||
|
|
162
|
+
m.action === 'flowsStatus');
|
|
159
163
|
}
|
|
160
164
|
const tabStates = new Map();
|
|
165
|
+
/**
|
|
166
|
+
* Every `browser.flow` currently running in this service worker, across
|
|
167
|
+
* every connected tab. This is the whole basis of the kill switch: the
|
|
168
|
+
* runner polls `cancelled` off an entry here before each step, so stopping
|
|
169
|
+
* a flow means flipping one boolean and letting the loop decline to
|
|
170
|
+
* dispatch step N+1 — never interrupting an in-flight CDP command.
|
|
171
|
+
*
|
|
172
|
+
* Lives in the service worker's memory ON PURPOSE. If Chrome terminates
|
|
173
|
+
* the worker mid-flow the flow dies with it, the entry goes with it, and
|
|
174
|
+
* the popup shows nothing running — which is the truth. A registry that
|
|
175
|
+
* survived the worker would have to describe flows that no longer exist.
|
|
176
|
+
*/
|
|
177
|
+
const flowRegistry = createFlowRegistry();
|
|
178
|
+
/** Fallback id for a `browser.flow` that arrived without one (a server
|
|
179
|
+
* older than this extension). `crypto.randomUUID` is available in the MV3
|
|
180
|
+
* service worker; the `flow_` prefix keeps a locally-minted id visually
|
|
181
|
+
* distinguishable from a server-minted one in the popup. */
|
|
182
|
+
function newFlowId() {
|
|
183
|
+
return `flow_${crypto.randomUUID()}`;
|
|
184
|
+
}
|
|
185
|
+
/** Answer the popup's Flows panel: everything running (across ALL tabs —
|
|
186
|
+
* the Stop button has to reach a flow on a tab the user is not looking at)
|
|
187
|
+
* plus the completed history of the ONE tab the popup is showing. */
|
|
188
|
+
async function getFlowsStatus(tabId) {
|
|
189
|
+
const running = flowRegistry.listRunning(Date.now());
|
|
190
|
+
let history = [];
|
|
191
|
+
try {
|
|
192
|
+
const key = flowHistoryKey(tabId);
|
|
193
|
+
const data = await chrome.storage.session.get(key);
|
|
194
|
+
history = parseFlowHistory(data[key]);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Session storage unavailable — show the running half rather than
|
|
198
|
+
// failing the whole panel.
|
|
199
|
+
}
|
|
200
|
+
return { running, history };
|
|
201
|
+
}
|
|
161
202
|
/** Per-tab `chrome.tabs.onUpdated` listener installed while that tab is
|
|
162
203
|
* recording, so `handleRecordingNavigation` fires on every navigation and
|
|
163
204
|
* `stopRecording`/`cleanup` can remove EXACTLY this listener rather than
|
|
@@ -778,7 +819,15 @@ async function performPress(tabId, params) {
|
|
|
778
819
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
779
820
|
}
|
|
780
821
|
}
|
|
781
|
-
async function performWaitFor(tabId, state, params
|
|
822
|
+
async function performWaitFor(tabId, state, params,
|
|
823
|
+
/** Cancellation flag of the flow this wait belongs to, when it is a flow
|
|
824
|
+
* step (see `handleFlow`). Checked on EVERY poll tick, which is what
|
|
825
|
+
* makes the advertised worst-case cancellation latency of one step true
|
|
826
|
+
* for a step that can legitimately park for 30 seconds — without it,
|
|
827
|
+
* pressing Stop during a long `wait_for` would do nothing visible until
|
|
828
|
+
* the wait timed out on its own. Absent for the standalone
|
|
829
|
+
* `browser.wait_for` tool, which has no flow to be cancelled with. */
|
|
830
|
+
shouldCancel) {
|
|
782
831
|
try {
|
|
783
832
|
const waitSelector = params.selector;
|
|
784
833
|
const waitExpression = params.expression;
|
|
@@ -846,7 +895,12 @@ async function performWaitFor(tabId, state, params) {
|
|
|
846
895
|
const startedAt = Date.now();
|
|
847
896
|
let checks = 0;
|
|
848
897
|
let matched = false;
|
|
898
|
+
let cancelled = false;
|
|
849
899
|
while (check) {
|
|
900
|
+
if (shouldCancel?.() === true) {
|
|
901
|
+
cancelled = true;
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
850
904
|
checks++;
|
|
851
905
|
if (await check()) {
|
|
852
906
|
matched = true;
|
|
@@ -857,9 +911,19 @@ async function performWaitFor(tabId, state, params) {
|
|
|
857
911
|
await sleep(pollIntervalMs);
|
|
858
912
|
}
|
|
859
913
|
const elapsedMs = Date.now() - startedAt;
|
|
914
|
+
// 'cancelled' is deliberately distinct from 'timeout': the step
|
|
915
|
+
// executor turns a timeout into a flow FAILURE (with a recovery
|
|
916
|
+
// snapshot) and a cancellation into a clean `stopped_by: 'cancelled'`.
|
|
917
|
+
// Reporting a stopped wait as a timeout would invent a diagnosis for a
|
|
918
|
+
// condition that was never given its budget.
|
|
860
919
|
const result = matched
|
|
861
920
|
? { matched: true, elapsed_ms: elapsedMs, checks }
|
|
862
|
-
: {
|
|
921
|
+
: {
|
|
922
|
+
matched: false,
|
|
923
|
+
elapsed_ms: elapsedMs,
|
|
924
|
+
checks,
|
|
925
|
+
reason: cancelled ? 'cancelled' : 'timeout',
|
|
926
|
+
};
|
|
863
927
|
return { ok: true, result };
|
|
864
928
|
}
|
|
865
929
|
catch (err) {
|
|
@@ -1229,25 +1293,109 @@ async function handleFlow(tabId, state, msg, p) {
|
|
|
1229
1293
|
const rawSteps = Array.isArray(p.steps)
|
|
1230
1294
|
? p.steps.filter((s) => typeof s === 'object' && s !== null)
|
|
1231
1295
|
: [];
|
|
1232
|
-
|
|
1296
|
+
// Strict `=== true`: any other wire value (missing, "true", 1) runs
|
|
1297
|
+
// the flow FOR REAL. A dry run that silently became a real run would
|
|
1298
|
+
// be the worst possible failure of this flag, so only the exact
|
|
1299
|
+
// boolean opts into it.
|
|
1300
|
+
const dryRun = p.dry_run === true;
|
|
1301
|
+
// The server mints the id (see `browser-dispatch.ts`) so the agent gets
|
|
1302
|
+
// it back even when the extension never answers. The local fallback
|
|
1303
|
+
// covers a server too old to send one: the popup's Stop button must work
|
|
1304
|
+
// regardless of which half was upgraded first.
|
|
1305
|
+
const flowId = typeof p.flow_id === 'string' && p.flow_id.length > 0 ? p.flow_id : newFlowId();
|
|
1306
|
+
// A dry run dispatches nothing and finishes in milliseconds, so it is
|
|
1307
|
+
// deliberately NOT registered: there is nothing to stop, and listing it
|
|
1308
|
+
// would push real runs out of a 20-entry history with entries that never
|
|
1309
|
+
// touched the page.
|
|
1310
|
+
if (dryRun) {
|
|
1311
|
+
const projection = await runFlow(rawSteps, buildFlowDeps(tabId, state), {
|
|
1312
|
+
dryRun: true,
|
|
1313
|
+
});
|
|
1314
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: projection };
|
|
1315
|
+
}
|
|
1316
|
+
flowRegistry.register({ flowId, tabId, steps: rawSteps.length });
|
|
1317
|
+
// Best effort and deliberately not awaited: the title is only there so
|
|
1318
|
+
// the popup can tell two concurrent flows apart, and no flow should pay
|
|
1319
|
+
// a round trip to chrome.tabs before its first step.
|
|
1320
|
+
void chrome.tabs
|
|
1321
|
+
.get(tabId)
|
|
1322
|
+
.then((tab) => {
|
|
1323
|
+
const entry = flowRegistry.get(flowId);
|
|
1324
|
+
if (entry && typeof tab.title === 'string')
|
|
1325
|
+
entry.title = tab.title;
|
|
1326
|
+
})
|
|
1327
|
+
.catch(() => {
|
|
1328
|
+
// Tab gone or title unavailable — the panel falls back to the id.
|
|
1329
|
+
});
|
|
1330
|
+
const shouldCancel = () => flowRegistry.get(flowId)?.cancelled === true;
|
|
1331
|
+
let flowResult = null;
|
|
1332
|
+
try {
|
|
1333
|
+
flowResult = await runFlow(rawSteps, {
|
|
1334
|
+
...buildFlowDeps(tabId, state, shouldCancel),
|
|
1335
|
+
shouldCancel,
|
|
1336
|
+
onProgress: (progress) => {
|
|
1337
|
+
const entry = flowRegistry.get(flowId);
|
|
1338
|
+
if (entry)
|
|
1339
|
+
entry.progress = progress;
|
|
1340
|
+
},
|
|
1341
|
+
});
|
|
1342
|
+
// The wire-level response is ok:true whenever the flow RAN (even a
|
|
1343
|
+
// failed step, and even a cancellation, is a legitimate business
|
|
1344
|
+
// outcome — same pattern as wait_for's matched:false) — flowResult
|
|
1345
|
+
// itself carries the ok:true/false and stopped_by the agent reads.
|
|
1346
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: flowResult };
|
|
1347
|
+
}
|
|
1348
|
+
finally {
|
|
1349
|
+
// In `finally` so a throw (detached debugger, closed tab) still retires
|
|
1350
|
+
// the registry entry — a phantom "running" flow in the popup would be
|
|
1351
|
+
// worse than no panel at all, since it offers a Stop button that can
|
|
1352
|
+
// never do anything.
|
|
1353
|
+
recordFinishedFlow(flowId, flowResult);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
/** The perform* bindings `runFlow` needs, in one place so the real run and
|
|
1357
|
+
* the dry-run projection cannot drift apart. `shouldCancel` is threaded
|
|
1358
|
+
* into `performWaitFor` alone: it is the only perform* with a loop of its
|
|
1359
|
+
* own to break out of. */
|
|
1360
|
+
function buildFlowDeps(tabId, state, shouldCancel) {
|
|
1361
|
+
return {
|
|
1233
1362
|
performFind: (params) => performFind(tabId, params),
|
|
1234
1363
|
performClick: (params) => performClick(tabId, params),
|
|
1235
1364
|
performType: (params) => performType(tabId, params),
|
|
1236
1365
|
performPress: (params) => performPress(tabId, params),
|
|
1237
|
-
performWaitFor: (params) => performWaitFor(tabId, state, params),
|
|
1366
|
+
performWaitFor: (params) => performWaitFor(tabId, state, params, shouldCancel),
|
|
1238
1367
|
performDrag: (params) => performDrag(tabId, params),
|
|
1239
1368
|
buildRecoverySnapshot: () => evaluateInTab(tabId, buildSnapshotJs({ only_interactive: true, max_interactive: 40 })),
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
/** Serializes history writes. Appending is a read-modify-write over an
|
|
1372
|
+
* async store, so two flows on the same tab finishing microseconds apart
|
|
1373
|
+
* would otherwise both read the same list and the second `set` would drop
|
|
1374
|
+
* the first's entry. One global chain (rather than one per tab) is enough:
|
|
1375
|
+
* these writes happen once per finished flow, never in a hot path. */
|
|
1376
|
+
let flowHistoryWrites = Promise.resolve();
|
|
1377
|
+
/** Retire a finished flow: drop it from the in-flight registry and prepend
|
|
1378
|
+
* its outcome to the tab's session-scoped history. Never throws — a
|
|
1379
|
+
* storage failure must not turn a completed flow into a failed tool call. */
|
|
1380
|
+
function recordFinishedFlow(flowId, result) {
|
|
1381
|
+
const entry = flowRegistry.finish(flowId);
|
|
1382
|
+
if (!entry)
|
|
1383
|
+
return;
|
|
1384
|
+
const historyEntry = toHistoryEntry(entry, result, Date.now());
|
|
1385
|
+
const key = flowHistoryKey(entry.tabId);
|
|
1386
|
+
flowHistoryWrites = flowHistoryWrites
|
|
1387
|
+
.then(async () => {
|
|
1388
|
+
const data = await chrome.storage.session.get(key);
|
|
1389
|
+
await chrome.storage.session.set({
|
|
1390
|
+
[key]: appendFlowHistory(parseFlowHistory(data[key]), historyEntry),
|
|
1391
|
+
});
|
|
1392
|
+
})
|
|
1393
|
+
.catch(() => {
|
|
1394
|
+
// Best effort, and the chain must survive: the history is an
|
|
1395
|
+
// operator convenience that dies with the browser session anyway, so
|
|
1396
|
+
// one lost entry is not worth surfacing — but swallowing here is
|
|
1397
|
+
// also what keeps a single failure from poisoning every later write.
|
|
1398
|
+
});
|
|
1251
1399
|
}
|
|
1252
1400
|
async function handleDrag(tabId, msg, p, optStr) {
|
|
1253
1401
|
// Thin wire wrapper over performDrag — the same pattern the standalone
|
|
@@ -1475,6 +1623,12 @@ async function cleanup(tabId) {
|
|
|
1475
1623
|
// `state` and raw CDP calls, never the map entry — and deleting first
|
|
1476
1624
|
// also makes a re-entrant cleanup a no-op instead of a double detach.
|
|
1477
1625
|
tabStates.delete(tabId);
|
|
1626
|
+
// A flow cannot outlive its bridge: the debugger is about to detach, so
|
|
1627
|
+
// every remaining step would fail one by one anyway. Cancelling instead
|
|
1628
|
+
// makes the flow stop AT ONCE and report what it completed — and, just
|
|
1629
|
+
// as importantly, keeps the popup from offering a Stop button for a flow
|
|
1630
|
+
// whose tab is already being torn down.
|
|
1631
|
+
flowRegistry.cancelForTab(tabId);
|
|
1478
1632
|
// Recording never survives a disconnect — there is no server connection
|
|
1479
1633
|
// left to save to, and leaving captured-but-unsaved steps around after
|
|
1480
1634
|
// the tab drops its bridge would be a surprising place for them to
|
|
@@ -1808,6 +1962,20 @@ async function doConnectTab(tabId) {
|
|
|
1808
1962
|
send(ws, response);
|
|
1809
1963
|
return;
|
|
1810
1964
|
}
|
|
1965
|
+
case 'tool.cancel': {
|
|
1966
|
+
// Deliberately answered with SILENCE: the cancelled flow is
|
|
1967
|
+
// still parked in its own tool.request and replies there,
|
|
1968
|
+
// cleanly, with stopped_by:'cancelled' and its partial
|
|
1969
|
+
// results. An unknown or already-finished flow_id is a no-op —
|
|
1970
|
+
// the caller wanted the flow not running, and it is not.
|
|
1971
|
+
//
|
|
1972
|
+
// Counts as activity: an agent reaching for the kill switch is
|
|
1973
|
+
// very much using this tab, and letting the idle sweeper close
|
|
1974
|
+
// the bridge underneath it would be perverse.
|
|
1975
|
+
state.lastActivityAt = Date.now();
|
|
1976
|
+
flowRegistry.cancel(msg.flow_id);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1811
1979
|
default: {
|
|
1812
1980
|
// Compile-time exhaustiveness check; at runtime an unknown kind
|
|
1813
1981
|
// (newer server, older extension) is ignored defensively.
|
|
@@ -2287,6 +2455,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
2287
2455
|
void saveRecording(msg.tabId, msg.name, msg.description).then(sendResponse);
|
|
2288
2456
|
return true;
|
|
2289
2457
|
}
|
|
2458
|
+
if (msg.action === 'flowsStatus') {
|
|
2459
|
+
void getFlowsStatus(msg.tabId).then(sendResponse);
|
|
2460
|
+
return true;
|
|
2461
|
+
}
|
|
2462
|
+
if (msg.action === 'cancelFlow') {
|
|
2463
|
+
// Synchronous and unconditional: one boolean flip, then the runner
|
|
2464
|
+
// stops at its next check. `cancelled` reports whether THIS call is
|
|
2465
|
+
// what stopped it, so the popup can distinguish "stopping…" from an
|
|
2466
|
+
// id that had already finished — never an error either way.
|
|
2467
|
+
sendResponse({ cancelled: flowRegistry.cancel(msg.flowId) });
|
|
2468
|
+
return false;
|
|
2469
|
+
}
|
|
2290
2470
|
// 'status'
|
|
2291
2471
|
sendResponse(getTabStatus(msg.tabId));
|
|
2292
2472
|
return false;
|
|
@@ -2299,6 +2479,11 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
|
|
2299
2479
|
});
|
|
2300
2480
|
// The Chrome tab is gone — drop any cached browser-link id for it.
|
|
2301
2481
|
void forgetTabId(tabId);
|
|
2482
|
+
// …and its flow history. Chrome reuses tab ids within a session, so a
|
|
2483
|
+
// stale key would eventually show one tab's flows under another's.
|
|
2484
|
+
void chrome.storage.session.remove(flowHistoryKey(tabId)).catch(() => {
|
|
2485
|
+
// Best effort — session storage dies with the browser anyway.
|
|
2486
|
+
});
|
|
2302
2487
|
});
|
|
2303
2488
|
/* Auto-connect tabs spawned by a connected tab.
|
|
2304
2489
|
*
|