@jobshimo/browser-link 0.25.2 → 0.27.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.
Files changed (41) hide show
  1. package/dist/agent-instructions/content.js +53 -6
  2. package/dist/agent-instructions/content.js.map +1 -1
  3. package/dist/bridge/events.d.ts +1 -1
  4. package/dist/bridge/events.js +11 -1
  5. package/dist/bridge/events.js.map +1 -1
  6. package/dist/bridge/ws-bridge.d.ts +18 -0
  7. package/dist/bridge/ws-bridge.js +24 -0
  8. package/dist/bridge/ws-bridge.js.map +1 -1
  9. package/dist/cdp/detached-flows.d.ts +114 -0
  10. package/dist/cdp/detached-flows.js +242 -0
  11. package/dist/cdp/detached-flows.js.map +1 -0
  12. package/dist/cdp/flow.d.ts +76 -8
  13. package/dist/cdp/flow.js +91 -8
  14. package/dist/cdp/flow.js.map +1 -1
  15. package/dist/cdp/support.js +2 -0
  16. package/dist/cdp/support.js.map +1 -1
  17. package/dist/cdp/transport.js +132 -19
  18. package/dist/cdp/transport.js.map +1 -1
  19. package/dist/extension/background.js +535 -16
  20. package/dist/extension/background.js.map +1 -1
  21. package/dist/extension/flow-registry.d.ts +436 -0
  22. package/dist/extension/flow-registry.js +638 -0
  23. package/dist/extension/flow-registry.js.map +1 -0
  24. package/dist/extension/flow.d.ts +81 -5
  25. package/dist/extension/flow.js +96 -5
  26. package/dist/extension/flow.js.map +1 -1
  27. package/dist/extension/manifest.json +1 -1
  28. package/dist/extension/popup.html +138 -0
  29. package/dist/extension/popup.js +144 -0
  30. package/dist/extension/popup.js.map +1 -1
  31. package/dist/messages.d.ts +32 -1
  32. package/dist/permissions.js +12 -0
  33. package/dist/permissions.js.map +1 -1
  34. package/dist/server.js +10 -1
  35. package/dist/server.js.map +1 -1
  36. package/dist/tools/browser-definitions.js +65 -3
  37. package/dist/tools/browser-definitions.js.map +1 -1
  38. package/dist/tools/browser-dispatch.d.ts +13 -0
  39. package/dist/tools/browser-dispatch.js +190 -17
  40. package/dist/tools/browser-dispatch.js.map +1 -1
  41. 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 { MAX_DETACHED_FLOW_MS, appendFlowHistory, createFlowRegistry, detachedFlowKey, detachedFlowRecordsToEvict, finishedDetachedFlowRecord, flowHistoryKey, flowIdFromDetachedFlowKey, minimalDetachedFlowRecord, parseDetachedFlowRecord, parseFlowHistory, startedDetachedFlowRecord, terminatedDetachedFlowRecord, terminatedFlowHistoryEntry, toFlowStatus, 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,53 @@ 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
+ * A DETACHED flow additionally leaves a `detached-flow:<id>` record in
178
+ * `chrome.storage.session` (see `flow-registry.ts`), which is a different
179
+ * thing entirely: not a live registry, but the paper trail that lets
180
+ * `recoverDetachedFlows` below notice a worker termination and report it
181
+ * as `failed` / `worker-terminated` instead of leaving a phantom.
182
+ */
183
+ const flowRegistry = createFlowRegistry();
184
+ /** Fallback id for a `browser.flow` that arrived without one (a server
185
+ * older than this extension). `crypto.randomUUID` is available in the MV3
186
+ * service worker; the `flow_` prefix keeps a locally-minted id visually
187
+ * distinguishable from a server-minted one in the popup. */
188
+ function newFlowId() {
189
+ return `flow_${crypto.randomUUID()}`;
190
+ }
191
+ /** Answer the popup's Flows panel: everything running (across ALL tabs —
192
+ * the Stop button has to reach a flow on a tab the user is not looking at)
193
+ * plus the completed history of the ONE tab the popup is showing. */
194
+ async function getFlowsStatus(tabId) {
195
+ const running = flowRegistry.listRunning(Date.now());
196
+ let history = [];
197
+ try {
198
+ const key = flowHistoryKey(tabId);
199
+ const data = await chrome.storage.session.get(key);
200
+ history = parseFlowHistory(data[key]);
201
+ }
202
+ catch {
203
+ // Session storage unavailable — show the running half rather than
204
+ // failing the whole panel.
205
+ }
206
+ return { running, history };
207
+ }
161
208
  /** Per-tab `chrome.tabs.onUpdated` listener installed while that tab is
162
209
  * recording, so `handleRecordingNavigation` fires on every navigation and
163
210
  * `stopRecording`/`cleanup` can remove EXACTLY this listener rather than
@@ -778,7 +825,15 @@ async function performPress(tabId, params) {
778
825
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
779
826
  }
780
827
  }
781
- async function performWaitFor(tabId, state, params) {
828
+ async function performWaitFor(tabId, state, params,
829
+ /** Cancellation flag of the flow this wait belongs to, when it is a flow
830
+ * step (see `handleFlow`). Checked on EVERY poll tick, which is what
831
+ * makes the advertised worst-case cancellation latency of one step true
832
+ * for a step that can legitimately park for 30 seconds — without it,
833
+ * pressing Stop during a long `wait_for` would do nothing visible until
834
+ * the wait timed out on its own. Absent for the standalone
835
+ * `browser.wait_for` tool, which has no flow to be cancelled with. */
836
+ shouldCancel) {
782
837
  try {
783
838
  const waitSelector = params.selector;
784
839
  const waitExpression = params.expression;
@@ -846,7 +901,12 @@ async function performWaitFor(tabId, state, params) {
846
901
  const startedAt = Date.now();
847
902
  let checks = 0;
848
903
  let matched = false;
904
+ let cancelled = false;
849
905
  while (check) {
906
+ if (shouldCancel?.() === true) {
907
+ cancelled = true;
908
+ break;
909
+ }
850
910
  checks++;
851
911
  if (await check()) {
852
912
  matched = true;
@@ -857,9 +917,19 @@ async function performWaitFor(tabId, state, params) {
857
917
  await sleep(pollIntervalMs);
858
918
  }
859
919
  const elapsedMs = Date.now() - startedAt;
920
+ // 'cancelled' is deliberately distinct from 'timeout': the step
921
+ // executor turns a timeout into a flow FAILURE (with a recovery
922
+ // snapshot) and a cancellation into a clean `stopped_by: 'cancelled'`.
923
+ // Reporting a stopped wait as a timeout would invent a diagnosis for a
924
+ // condition that was never given its budget.
860
925
  const result = matched
861
926
  ? { matched: true, elapsed_ms: elapsedMs, checks }
862
- : { matched: false, elapsed_ms: elapsedMs, checks, reason: 'timeout' };
927
+ : {
928
+ matched: false,
929
+ elapsed_ms: elapsedMs,
930
+ checks,
931
+ reason: cancelled ? 'cancelled' : 'timeout',
932
+ };
863
933
  return { ok: true, result };
864
934
  }
865
935
  catch (err) {
@@ -1229,25 +1299,410 @@ async function handleFlow(tabId, state, msg, p) {
1229
1299
  const rawSteps = Array.isArray(p.steps)
1230
1300
  ? p.steps.filter((s) => typeof s === 'object' && s !== null)
1231
1301
  : [];
1232
- const flowResult = await runFlow(rawSteps, {
1302
+ // Strict `=== true`: any other wire value (missing, "true", 1) runs
1303
+ // the flow FOR REAL. A dry run that silently became a real run would
1304
+ // be the worst possible failure of this flag, so only the exact
1305
+ // boolean opts into it.
1306
+ const dryRun = p.dry_run === true;
1307
+ // Strict `=== true` for the same reason: a flow that silently detached
1308
+ // would keep acting after the caller believed it had returned.
1309
+ const detach = p.detach === true;
1310
+ // The server mints the id (see `browser-dispatch.ts`) so the agent gets
1311
+ // it back even when the extension never answers. The local fallback
1312
+ // covers a server too old to send one: the popup's Stop button must work
1313
+ // regardless of which half was upgraded first.
1314
+ const flowId = typeof p.flow_id === 'string' && p.flow_id.length > 0 ? p.flow_id : newFlowId();
1315
+ // A dry run dispatches nothing and finishes in milliseconds, so it is
1316
+ // deliberately NOT registered: there is nothing to stop, and listing it
1317
+ // would push real runs out of a 20-entry history with entries that never
1318
+ // touched the page. It also ignores `detach` — projecting a flow is
1319
+ // fast and synchronous whichever way the real run would go.
1320
+ if (dryRun) {
1321
+ const projection = await runFlow(rawSteps, buildFlowDeps(tabId, state), {
1322
+ dryRun: true,
1323
+ });
1324
+ return { kind: 'tool.response', id: msg.id, ok: true, result: projection };
1325
+ }
1326
+ if (detach) {
1327
+ // ONE detached flow per tab, enforced HERE because the registry is the
1328
+ // only place that knows. Two loops clicking through the same page have
1329
+ // no use case and every failure mode: they interleave on the same DOM,
1330
+ // race each other's `while_found` probes, and leave a human with two
1331
+ // Stop buttons and no way to tell which is which. The error names the
1332
+ // running id so the caller can inspect or stop it.
1333
+ const running = flowRegistry.detachedForTab(tabId);
1334
+ if (running) {
1335
+ return {
1336
+ kind: 'tool.response',
1337
+ id: msg.id,
1338
+ ok: false,
1339
+ error: `flow: a detached flow is already running on this tab (${running.flowId}). ` +
1340
+ `Wait for it, or stop it with browser.flow_cancel, before detaching another.`,
1341
+ };
1342
+ }
1343
+ }
1344
+ const entry = flowRegistry.register({
1345
+ flowId,
1346
+ tabId,
1347
+ steps: rawSteps.length,
1348
+ detached: detach,
1349
+ ...(detach ? { expiresAt: Date.now() + MAX_DETACHED_FLOW_MS } : {}),
1350
+ });
1351
+ // Best effort and deliberately not awaited: the title is only there so
1352
+ // the popup can tell two concurrent flows apart, and no flow should pay
1353
+ // a round trip to chrome.tabs before its first step.
1354
+ void chrome.tabs
1355
+ .get(tabId)
1356
+ .then((tab) => {
1357
+ if (typeof tab.title === 'string')
1358
+ entry.title = tab.title;
1359
+ })
1360
+ .catch(() => {
1361
+ // Tab gone or title unavailable — the panel falls back to the id.
1362
+ });
1363
+ // Also covers the detached ceiling: `shouldStop` flips the entry to
1364
+ // `expired` the first time the deadline has passed, so the outcome
1365
+ // distinguishes "ran out of time" from "someone pressed Stop".
1366
+ const shouldCancel = () => flowRegistry.shouldStop(flowId, Date.now());
1367
+ const run = () => runFlow(rawSteps, {
1368
+ ...buildFlowDeps(tabId, state, shouldCancel),
1369
+ shouldCancel,
1370
+ onProgress: (progress) => {
1371
+ entry.progress = progress;
1372
+ },
1373
+ });
1374
+ if (detach) {
1375
+ // The record lands BEFORE the first step is dispatched: it is the only
1376
+ // evidence a worker termination leaves behind, and a flow that acted
1377
+ // before it was recorded would be exactly the invisible run this whole
1378
+ // slice exists to make impossible. A record that will not store is
1379
+ // therefore a refusal, not a warning: nothing has run yet, and that is
1380
+ // the only moment refusing is still free.
1381
+ if (!(await persistLaunchRecord(entry))) {
1382
+ flowRegistry.finish(flowId);
1383
+ return {
1384
+ kind: 'tool.response',
1385
+ id: msg.id,
1386
+ ok: false,
1387
+ error: 'flow: could not persist the detached-flow launch record ' +
1388
+ '(chrome.storage.session write failed). NOTHING was executed: a ' +
1389
+ 'detached flow with no record acts irreversibly with no evidence.',
1390
+ };
1391
+ }
1392
+ // Deliberately floating. This is the entire point of `detach`: the
1393
+ // tool.response below goes out now, the loop keeps running, and the
1394
+ // agent's turn is free. `recordFinishedFlow` in the `finally` is what
1395
+ // eventually retires it.
1396
+ void (async () => {
1397
+ let flowResult = null;
1398
+ try {
1399
+ flowResult = await run();
1400
+ }
1401
+ catch {
1402
+ // A throw (detached debugger, closed tab) is a `failed` outcome —
1403
+ // recorded below from a null result. Nothing to rethrow to: there
1404
+ // is no caller left.
1405
+ }
1406
+ finally {
1407
+ recordFinishedFlow(flowId, flowResult);
1408
+ }
1409
+ })();
1410
+ return {
1411
+ kind: 'tool.response',
1412
+ id: msg.id,
1413
+ ok: true,
1414
+ result: {
1415
+ detached: true,
1416
+ started_at: entry.startedAt,
1417
+ steps: rawSteps.length,
1418
+ expires_at: entry.expiresAt,
1419
+ },
1420
+ };
1421
+ }
1422
+ let flowResult = null;
1423
+ try {
1424
+ flowResult = await run();
1425
+ // The wire-level response is ok:true whenever the flow RAN (even a
1426
+ // failed step, and even a cancellation, is a legitimate business
1427
+ // outcome — same pattern as wait_for's matched:false) — flowResult
1428
+ // itself carries the ok:true/false and stopped_by the agent reads.
1429
+ return { kind: 'tool.response', id: msg.id, ok: true, result: flowResult };
1430
+ }
1431
+ finally {
1432
+ // In `finally` so a throw (detached debugger, closed tab) still retires
1433
+ // the registry entry — a phantom "running" flow in the popup would be
1434
+ // worse than no panel at all, since it offers a Stop button that can
1435
+ // never do anything.
1436
+ recordFinishedFlow(flowId, flowResult);
1437
+ }
1438
+ }
1439
+ /** Answer the `flow_status` wire tool (behind `browser.flow_status`).
1440
+ * Reads the live registry first and falls back to the persisted detached
1441
+ * record; an id neither knows comes back as `state: 'unknown'`, never as an
1442
+ * error — see `FlowState`. */
1443
+ async function handleFlowStatus(msg, p) {
1444
+ const flowId = typeof p.flow_id === 'string' ? p.flow_id : '';
1445
+ if (flowId.length === 0) {
1446
+ return { kind: 'tool.response', id: msg.id, ok: false, error: 'flow_status: flow_id required' };
1447
+ }
1448
+ const entry = flowRegistry.get(flowId);
1449
+ const record = entry ? undefined : await readDetachedFlowRecord(flowId);
1450
+ const status = toFlowStatus(flowId, entry, record);
1451
+ return { kind: 'tool.response', id: msg.id, ok: true, result: status };
1452
+ }
1453
+ /** The perform* bindings `runFlow` needs, in one place so the real run and
1454
+ * the dry-run projection cannot drift apart. `shouldCancel` is threaded
1455
+ * into `performWaitFor` alone: it is the only perform* with a loop of its
1456
+ * own to break out of. */
1457
+ function buildFlowDeps(tabId, state, shouldCancel) {
1458
+ return {
1233
1459
  performFind: (params) => performFind(tabId, params),
1234
1460
  performClick: (params) => performClick(tabId, params),
1235
1461
  performType: (params) => performType(tabId, params),
1236
1462
  performPress: (params) => performPress(tabId, params),
1237
- performWaitFor: (params) => performWaitFor(tabId, state, params),
1463
+ performWaitFor: (params) => performWaitFor(tabId, state, params, shouldCancel),
1238
1464
  performDrag: (params) => performDrag(tabId, params),
1239
1465
  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 };
1466
+ };
1467
+ }
1468
+ /** Serializes EVERY flow-related `chrome.storage.session` mutation —
1469
+ * history appends, detached-record writes, and the history drop when a tab
1470
+ * closes. Appending is a read-modify-write over an async store, so two
1471
+ * flows on the same tab finishing microseconds apart would otherwise both
1472
+ * read the same list and the second `set` would drop the first's entry;
1473
+ * and a tab-removal delete OUTSIDE the chain could be overtaken by a
1474
+ * still-running flow's late append, resurrecting the key it just dropped.
1475
+ * One global chain (rather than one per tab or one per concern) is enough:
1476
+ * these writes happen a handful of times per flow, never in a hot path. */
1477
+ let flowStorageWrites = Promise.resolve();
1478
+ /** Append one unit of work to the serialized chain. Swallows failures so a
1479
+ * single storage error cannot poison every later write — the session store
1480
+ * dies with the browser anyway, so a lost entry is not worth surfacing. */
1481
+ function queueFlowStorageWrite(work) {
1482
+ flowStorageWrites = flowStorageWrites.then(work).catch(() => {
1483
+ // Best effort, and the chain must survive.
1484
+ });
1485
+ return flowStorageWrites;
1486
+ }
1487
+ /** Same chain, but the caller learns whether the write landed. A lost
1488
+ * detached-flow record is not a lost entry: the launch record is a flow's
1489
+ * only evidence, and a lost terminal write leaves a stale `running` record
1490
+ * the next worker start reads as an orphan. The rejection is converted to
1491
+ * `false`, never rethrown, so the chain survives as it does above. */
1492
+ function queueTrackedFlowStorageWrite(work) {
1493
+ const tracked = flowStorageWrites.then(work).then(() => true, () => false);
1494
+ flowStorageWrites = tracked.then(() => undefined);
1495
+ return tracked;
1496
+ }
1497
+ /** Chrome tab ids whose flow history has been dropped because the tab
1498
+ * closed, and WHEN. Chrome reuses tab ids within a session, so the
1499
+ * timestamp matters: a flow that started before the removal belongs to the
1500
+ * tab that is gone and must not be written back (that is the resurrection
1501
+ * the serialized chain alone cannot prevent, since the late write is
1502
+ * queued after the delete); a flow that started after it belongs to
1503
+ * whatever tab holds the id now. Cleared when a tab connects under the id
1504
+ * again, so this never grows past live-plus-recently-closed tabs. */
1505
+ const flowHistoryDroppedAt = new Map();
1506
+ /** Retire a finished flow: drop it from the in-flight registry, prepend its
1507
+ * outcome to the tab's session-scoped history, and — for a detached flow —
1508
+ * finalize its record and emit the one-per-flow audit event. Never throws:
1509
+ * a storage failure must not turn a completed flow into a failed tool call,
1510
+ * and for a detached flow there is no caller left to fail anyway. */
1511
+ function recordFinishedFlow(flowId, result) {
1512
+ const entry = flowRegistry.finish(flowId);
1513
+ if (!entry)
1514
+ return;
1515
+ const endedAt = Date.now();
1516
+ const droppedAt = flowHistoryDroppedAt.get(entry.tabId);
1517
+ // Skip the append entirely for a tab that has already been removed — see
1518
+ // `flowHistoryDroppedAt`. Ordering on the chain is not enough on its own,
1519
+ // because this write is queued AFTER the delete was.
1520
+ if (droppedAt === undefined || droppedAt <= entry.startedAt) {
1521
+ appendFlowHistoryEntry(entry.tabId, toHistoryEntry(entry, result, endedAt));
1522
+ }
1523
+ if (!entry.detached)
1524
+ return;
1525
+ const record = finishedDetachedFlowRecord(entry, result, endedAt);
1526
+ // Exactly ONE summary event per detached flow, pushed here and nowhere
1527
+ // else — never per iteration. `BridgeEventLog` holds 200 entries, so a
1528
+ // single 200-iteration run emitting per-iteration events would silently
1529
+ // evict every other event in the log, which is the opposite of an audit
1530
+ // trail. If the socket is already gone the event is deferred onto the
1531
+ // record and flushed when the tab next registers.
1532
+ if (!publishFlowFinished(entry.tabId, record))
1533
+ record.eventPending = true;
1534
+ void persistTerminalRecord(record);
1535
+ }
1536
+ /** Prepend one entry to a tab's session-scoped flow history, on the
1537
+ * serialized chain (the append is a read-modify-write, so two flows
1538
+ * finishing microseconds apart would otherwise lose one). */
1539
+ function appendFlowHistoryEntry(tabId, historyEntry) {
1540
+ const key = flowHistoryKey(tabId);
1541
+ void queueFlowStorageWrite(async () => {
1542
+ const data = await chrome.storage.session.get(key);
1543
+ await chrome.storage.session.set({
1544
+ [key]: appendFlowHistory(parseFlowHistory(data[key]), historyEntry),
1545
+ });
1546
+ });
1547
+ }
1548
+ /** Push the one-per-flow summary `bridge.event`. Returns whether it
1549
+ * actually went out — the caller defers it onto the record when it did
1550
+ * not, so an audit trail is never quietly dropped just because the tab's
1551
+ * socket happened to be closed. Counts and enums only: the manifest itself
1552
+ * is addressable by `flow_id` through `browser.flow_status` and has no
1553
+ * business in a 200-entry ring buffer. */
1554
+ function publishFlowFinished(tabId, record) {
1555
+ const state = tabStates.get(tabId);
1556
+ if (!state?.ws || state.ws.readyState !== WebSocket.OPEN)
1557
+ return false;
1558
+ send(state.ws, {
1559
+ kind: 'bridge.event',
1560
+ eventKind: 'flow-finished',
1561
+ ...(state.serverTabId === undefined ? {} : { tabId: state.serverTabId }),
1562
+ data: {
1563
+ flow_id: record.flowId,
1564
+ state: record.state,
1565
+ detached: true,
1566
+ steps: record.steps,
1567
+ steps_completed: record.stepsCompleted,
1568
+ ...(record.iterationsCompleted === undefined
1569
+ ? {}
1570
+ : { iterations_completed: record.iterationsCompleted }),
1571
+ duration_ms: Math.max(0, (record.endedAt ?? record.startedAt) - record.startedAt),
1572
+ ...(record.stoppedBy === undefined ? {} : { stopped_by: record.stoppedBy }),
1573
+ manifest_available: record.manifest !== undefined,
1574
+ },
1575
+ });
1576
+ return true;
1577
+ }
1578
+ /** Persist one detached-flow record and enforce the record cap. Runs on the
1579
+ * serialized chain so a launch write and the matching terminal write can
1580
+ * never land out of order. Reports whether THE RECORD landed; the cap sweep
1581
+ * that follows is best effort and must not make a stored record look lost. */
1582
+ function writeDetachedFlowRecord(record) {
1583
+ return queueTrackedFlowStorageWrite(async () => {
1584
+ await chrome.storage.session.set({ [detachedFlowKey(record.flowId)]: record });
1585
+ try {
1586
+ const evict = detachedFlowRecordsToEvict(await readAllDetachedFlowRecords());
1587
+ if (evict.length === 0)
1588
+ return;
1589
+ await chrome.storage.session.remove(evict.map((r) => detachedFlowKey(r.flowId)));
1590
+ }
1591
+ catch {
1592
+ // Over the cap until the next write sweeps again.
1593
+ }
1594
+ });
1595
+ }
1596
+ /** Free session-storage quota by dropping every terminal record not still
1597
+ * owing its summary event. Only after a rejected write: at that point old
1598
+ * manifests are worth less than the launch record of a flow about to act. */
1599
+ function pruneDetachedFlowRecords() {
1600
+ return queueTrackedFlowStorageWrite(async () => {
1601
+ const stale = (await readAllDetachedFlowRecords()).filter((r) => r.state !== 'running' && r.eventPending !== true);
1602
+ if (stale.length === 0)
1603
+ return;
1604
+ await chrome.storage.session.remove(stale.map((r) => detachedFlowKey(r.flowId)));
1605
+ });
1606
+ }
1607
+ /** Write the launch record and prove it landed, pruning old manifests and
1608
+ * retrying ONCE when storage refuses (the plausible refusal is a quota one:
1609
+ * ten records of up to 128 KB against pre-112 Chrome's 1 MB session cap).
1610
+ * False means genuinely unstored — the caller must not start the flow. */
1611
+ async function persistLaunchRecord(entry) {
1612
+ const record = startedDetachedFlowRecord(entry);
1613
+ if (await writeDetachedFlowRecord(record))
1614
+ return true;
1615
+ await pruneDetachedFlowRecords();
1616
+ return writeDetachedFlowRecord(record);
1617
+ }
1618
+ /** Write the terminal record and prove it landed. The retry STRIPS the
1619
+ * manifest instead of repeating the same write: a failed terminal write
1620
+ * does not lose an outcome, it invents a wrong one, because the stale
1621
+ * `running` record left behind is read as `worker-terminated` next start.
1622
+ * If the minimal record is refused too there is nothing left to try. */
1623
+ async function persistTerminalRecord(record) {
1624
+ if (await writeDetachedFlowRecord(record))
1625
+ return;
1626
+ if (await writeDetachedFlowRecord(minimalDetachedFlowRecord(record)))
1627
+ return;
1628
+ console.warn('browser-link: detached-flow terminal record not persisted', record.flowId);
1629
+ }
1630
+ /** Read one detached-flow record. Drains the write chain first so a status
1631
+ * poll landing microseconds after a flow ended never reports the stale
1632
+ * `running` record the launch write left behind. */
1633
+ async function readDetachedFlowRecord(flowId) {
1634
+ try {
1635
+ await flowStorageWrites;
1636
+ const key = detachedFlowKey(flowId);
1637
+ const data = await chrome.storage.session.get(key);
1638
+ return parseDetachedFlowRecord(data[key]) ?? undefined;
1639
+ }
1640
+ catch {
1641
+ return undefined;
1642
+ }
1643
+ }
1644
+ /** Every detached-flow record currently in session storage. One
1645
+ * `get(null)` rather than N keyed reads — the same sweep
1646
+ * `resumePendingReconnects` does, and the record set is capped at
1647
+ * `MAX_DETACHED_FLOW_RECORDS`. */
1648
+ async function readAllDetachedFlowRecords() {
1649
+ let stored;
1650
+ try {
1651
+ stored = await chrome.storage.session.get(null);
1652
+ }
1653
+ catch {
1654
+ return [];
1655
+ }
1656
+ const records = [];
1657
+ for (const [key, value] of Object.entries(stored)) {
1658
+ if (flowIdFromDetachedFlowKey(key) === null)
1659
+ continue;
1660
+ const record = parseDetachedFlowRecord(value);
1661
+ if (record)
1662
+ records.push(record);
1663
+ }
1664
+ return records;
1665
+ }
1666
+ /**
1667
+ * Reconcile detached-flow records left behind by a service worker Chrome
1668
+ * terminated. Runs ONCE per worker start, before anything can register a
1669
+ * flow, so every `running` record it finds is by definition orphaned: the
1670
+ * loop that owned it lived in the dead worker's memory.
1671
+ *
1672
+ * The settled rule (see `terminatedDetachedFlowRecord`, and `DECISIONS.md`
1673
+ * §13): mark it `failed` / `worker-terminated`, write the history entry,
1674
+ * and NEVER resume. A detached flow does irreversible bulk work; resuming
1675
+ * one whose real progress died with the worker would double-act on some
1676
+ * unknowable prefix of it. Failing loudly is the only honest option, and a
1677
+ * flow that looks `running` while nothing executes it is worse than either.
1678
+ */
1679
+ async function recoverDetachedFlows() {
1680
+ const now = Date.now();
1681
+ for (const record of await readAllDetachedFlowRecords()) {
1682
+ const terminated = terminatedDetachedFlowRecord(record, now);
1683
+ if (!terminated)
1684
+ continue;
1685
+ void writeDetachedFlowRecord(terminated);
1686
+ appendFlowHistoryEntry(terminated.tabId, terminatedFlowHistoryEntry(terminated));
1687
+ }
1688
+ }
1689
+ /** Flush the summary events of any detached flow that ended while this
1690
+ * tab's socket was down — including every flow the worker was killed
1691
+ * under, whose event could not possibly have been sent at the time (no
1692
+ * worker, no socket). Called from the `tab.registered` handler, which is
1693
+ * the first moment there is somewhere to send them. */
1694
+ async function flushPendingFlowEvents(tabId) {
1695
+ // Drain the chain first, as `readDetachedFlowRecord` does: a terminal
1696
+ // record still queued reads as stale `running`, with no event to flush.
1697
+ await flowStorageWrites;
1698
+ for (const record of await readAllDetachedFlowRecords()) {
1699
+ if (record.tabId !== tabId || record.eventPending !== true)
1700
+ continue;
1701
+ if (!publishFlowFinished(tabId, record))
1702
+ return;
1703
+ const { eventPending: _sent, ...flushed } = record;
1704
+ void writeDetachedFlowRecord(flushed);
1705
+ }
1251
1706
  }
1252
1707
  async function handleDrag(tabId, msg, p, optStr) {
1253
1708
  // Thin wire wrapper over performDrag — the same pattern the standalone
@@ -1423,6 +1878,13 @@ async function handleTool(state, msg) {
1423
1878
  }
1424
1879
  case 'flow':
1425
1880
  return await handleFlow(tabId, state, msg, p);
1881
+ case 'flow_status':
1882
+ // Deliberately NOT scoped to `tabId`: the registry and the
1883
+ // detached records are worker-global, keyed by flow_id. The tab
1884
+ // this request arrived on is a transport route, not a filter — so
1885
+ // an agent can still read a finished flow's manifest through any
1886
+ // connected tab after the flow's own tab has closed.
1887
+ return await handleFlowStatus(msg, p);
1426
1888
  case 'drag':
1427
1889
  return await handleDrag(tabId, msg, p, optStr);
1428
1890
  case 'evaluate': {
@@ -1475,6 +1937,12 @@ async function cleanup(tabId) {
1475
1937
  // `state` and raw CDP calls, never the map entry — and deleting first
1476
1938
  // also makes a re-entrant cleanup a no-op instead of a double detach.
1477
1939
  tabStates.delete(tabId);
1940
+ // A flow cannot outlive its bridge: the debugger is about to detach, so
1941
+ // every remaining step would fail one by one anyway. Cancelling instead
1942
+ // makes the flow stop AT ONCE and report what it completed — and, just
1943
+ // as importantly, keeps the popup from offering a Stop button for a flow
1944
+ // whose tab is already being torn down.
1945
+ flowRegistry.cancelForTab(tabId);
1478
1946
  // Recording never survives a disconnect — there is no server connection
1479
1947
  // left to save to, and leaving captured-but-unsaved steps around after
1480
1948
  // the tab drops its bridge would be a surprising place for them to
@@ -1651,6 +2119,10 @@ async function resumePendingReconnects() {
1651
2119
  }
1652
2120
  }
1653
2121
  void resumePendingReconnects();
2122
+ // Same worker-start slot, same rationale: `chrome.storage.session` outlives
2123
+ // the service worker but not the browser, which is exactly the lifetime a
2124
+ // detached flow's record should have. See `recoverDetachedFlows`.
2125
+ void recoverDetachedFlows();
1654
2126
  /** Tabs with a `connectTab` currently in flight. The `tabStates.has` check
1655
2127
  * inside `doConnectTab` alone cannot prevent two concurrent connects — the
1656
2128
  * state lands only after three awaited chrome.* calls — so this SYNCHRONOUS
@@ -1679,6 +2151,11 @@ async function doConnectTab(tabId) {
1679
2151
  if (tabStates.has(tabId)) {
1680
2152
  return { ok: false, error: 'This tab is already connected' };
1681
2153
  }
2154
+ // Whatever tab holds this Chrome id now, it is not the closed one whose
2155
+ // history was dropped — so its own flows are free to write history
2156
+ // again. Also what keeps `flowHistoryDroppedAt` from growing past
2157
+ // live-plus-recently-closed tabs.
2158
+ flowHistoryDroppedAt.delete(tabId);
1682
2159
  const tab = await chrome.tabs.get(tabId);
1683
2160
  if (!tab.url || !tab.title) {
1684
2161
  return { ok: false, error: 'Tab has no URL or title' };
@@ -1766,6 +2243,11 @@ async function doConnectTab(tabId) {
1766
2243
  // asks the new primary to honour it. The primary emits
1767
2244
  // `tab-renamed` in the bridge event log if it can't.
1768
2245
  void storeTabId(tabId, msg.payload.tabId);
2246
+ // First moment there is a socket to send them on: deliver the
2247
+ // summary events of any detached flow that ended while this
2248
+ // tab was disconnected, including everything the worker was
2249
+ // killed under.
2250
+ void flushPendingFlowEvents(tabId);
1769
2251
  settle({ ok: true, serverTabId: msg.payload.tabId });
1770
2252
  return;
1771
2253
  }
@@ -1808,6 +2290,20 @@ async function doConnectTab(tabId) {
1808
2290
  send(ws, response);
1809
2291
  return;
1810
2292
  }
2293
+ case 'tool.cancel': {
2294
+ // Deliberately answered with SILENCE: the cancelled flow is
2295
+ // still parked in its own tool.request and replies there,
2296
+ // cleanly, with stopped_by:'cancelled' and its partial
2297
+ // results. An unknown or already-finished flow_id is a no-op —
2298
+ // the caller wanted the flow not running, and it is not.
2299
+ //
2300
+ // Counts as activity: an agent reaching for the kill switch is
2301
+ // very much using this tab, and letting the idle sweeper close
2302
+ // the bridge underneath it would be perverse.
2303
+ state.lastActivityAt = Date.now();
2304
+ flowRegistry.cancel(msg.flow_id);
2305
+ return;
2306
+ }
1811
2307
  default: {
1812
2308
  // Compile-time exhaustiveness check; at runtime an unknown kind
1813
2309
  // (newer server, older extension) is ignored defensively.
@@ -2287,6 +2783,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
2287
2783
  void saveRecording(msg.tabId, msg.name, msg.description).then(sendResponse);
2288
2784
  return true;
2289
2785
  }
2786
+ if (msg.action === 'flowsStatus') {
2787
+ void getFlowsStatus(msg.tabId).then(sendResponse);
2788
+ return true;
2789
+ }
2790
+ if (msg.action === 'cancelFlow') {
2791
+ // Synchronous and unconditional: one boolean flip, then the runner
2792
+ // stops at its next check. `cancelled` reports whether THIS call is
2793
+ // what stopped it, so the popup can distinguish "stopping…" from an
2794
+ // id that had already finished — never an error either way.
2795
+ sendResponse({ cancelled: flowRegistry.cancel(msg.flowId) });
2796
+ return false;
2797
+ }
2290
2798
  // 'status'
2291
2799
  sendResponse(getTabStatus(msg.tabId));
2292
2800
  return false;
@@ -2299,6 +2807,17 @@ chrome.tabs.onRemoved.addListener((tabId) => {
2299
2807
  });
2300
2808
  // The Chrome tab is gone — drop any cached browser-link id for it.
2301
2809
  void forgetTabId(tabId);
2810
+ // …and its flow history. Chrome reuses tab ids within a session, so a
2811
+ // stale key would eventually show one tab's flows under another's.
2812
+ //
2813
+ // Two guards, because the delete alone is not enough. Routing it through
2814
+ // the SAME serialized chain as the appends keeps it ordered against
2815
+ // every write already queued; the `flowHistoryDroppedAt` stamp handles
2816
+ // the other half — `cleanup` above only ASKS the tab's flows to stop, so
2817
+ // one of them can still finish (and try to append) after this delete has
2818
+ // run, resurrecting the key for a tab that no longer exists.
2819
+ flowHistoryDroppedAt.set(tabId, Date.now());
2820
+ void queueFlowStorageWrite(() => chrome.storage.session.remove(flowHistoryKey(tabId)));
2302
2821
  });
2303
2822
  /* Auto-connect tabs spawned by a connected tab.
2304
2823
  *