@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.
@@ -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
- : { matched: false, elapsed_ms: elapsedMs, checks, reason: 'timeout' };
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
- const flowResult = await runFlow(rawSteps, {
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
- // Strict `=== true`: any other wire value (missing, "true", 1) runs
1242
- // the flow FOR REAL. A dry run that silently became a real run would
1243
- // be the worst possible failure of this flag, so only the exact
1244
- // boolean opts into it.
1245
- { dryRun: p.dry_run === true });
1246
- // The wire-level response is ok:true whenever the flow RAN (even a
1247
- // failed step is a legitimate business outcome, same pattern as
1248
- // wait_for's matched:false) — flowResult itself carries the
1249
- // ok:true/false the agent reads.
1250
- return { kind: 'tool.response', id: msg.id, ok: true, result: flowResult };
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
  *