@jobshimo/browser-link 0.26.0 → 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 (35) 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/support.js +2 -0
  13. package/dist/cdp/support.js.map +1 -1
  14. package/dist/cdp/transport.js +94 -20
  15. package/dist/cdp/transport.js.map +1 -1
  16. package/dist/extension/background.js +371 -37
  17. package/dist/extension/background.js.map +1 -1
  18. package/dist/extension/flow-registry.d.ts +287 -13
  19. package/dist/extension/flow-registry.js +415 -37
  20. package/dist/extension/flow-registry.js.map +1 -1
  21. package/dist/extension/manifest.json +1 -1
  22. package/dist/extension/popup.html +15 -0
  23. package/dist/extension/popup.js +6 -0
  24. package/dist/extension/popup.js.map +1 -1
  25. package/dist/messages.d.ts +9 -5
  26. package/dist/permissions.js +12 -0
  27. package/dist/permissions.js.map +1 -1
  28. package/dist/server.js +10 -1
  29. package/dist/server.js.map +1 -1
  30. package/dist/tools/browser-definitions.js +63 -2
  31. package/dist/tools/browser-definitions.js.map +1 -1
  32. package/dist/tools/browser-dispatch.d.ts +13 -0
  33. package/dist/tools/browser-dispatch.js +149 -22
  34. package/dist/tools/browser-dispatch.js.map +1 -1
  35. package/package.json +1 -1
@@ -2,7 +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
+ import { MAX_DETACHED_FLOW_MS, appendFlowHistory, createFlowRegistry, detachedFlowKey, detachedFlowRecordsToEvict, finishedDetachedFlowRecord, flowHistoryKey, flowIdFromDetachedFlowKey, minimalDetachedFlowRecord, parseDetachedFlowRecord, parseFlowHistory, startedDetachedFlowRecord, terminatedDetachedFlowRecord, terminatedFlowHistoryEntry, toFlowStatus, toHistoryEntry, } from './flow-registry.js';
6
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';
7
7
  import { DEFAULT_FLOW_RECORDING_ENABLED, FLOW_RECORDING_STORAGE_KEY, FLOW_RECORDING_UPDATED_AT_STORAGE_KEY, normalizeFlowRecordingEnabled, parseIncomingFlowRecordingSettings, } from './flow-recording-policy.js';
8
8
  import { buildRecorderJs, buildStopRecorderJs } from './inpage/recorder.js';
@@ -173,6 +173,12 @@ const tabStates = new Map();
173
173
  * the worker mid-flow the flow dies with it, the entry goes with it, and
174
174
  * the popup shows nothing running — which is the truth. A registry that
175
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.
176
182
  */
177
183
  const flowRegistry = createFlowRegistry();
178
184
  /** Fallback id for a `browser.flow` that arrived without one (a server
@@ -1298,6 +1304,9 @@ async function handleFlow(tabId, state, msg, p) {
1298
1304
  // be the worst possible failure of this flag, so only the exact
1299
1305
  // boolean opts into it.
1300
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;
1301
1310
  // The server mints the id (see `browser-dispatch.ts`) so the agent gets
1302
1311
  // it back even when the extension never answers. The local fallback
1303
1312
  // covers a server too old to send one: the popup's Stop button must work
@@ -1306,39 +1315,113 @@ async function handleFlow(tabId, state, msg, p) {
1306
1315
  // A dry run dispatches nothing and finishes in milliseconds, so it is
1307
1316
  // deliberately NOT registered: there is nothing to stop, and listing it
1308
1317
  // would push real runs out of a 20-entry history with entries that never
1309
- // touched the page.
1318
+ // touched the page. It also ignores `detach` — projecting a flow is
1319
+ // fast and synchronous whichever way the real run would go.
1310
1320
  if (dryRun) {
1311
1321
  const projection = await runFlow(rawSteps, buildFlowDeps(tabId, state), {
1312
1322
  dryRun: true,
1313
1323
  });
1314
1324
  return { kind: 'tool.response', id: msg.id, ok: true, result: projection };
1315
1325
  }
1316
- flowRegistry.register({ flowId, tabId, steps: rawSteps.length });
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
+ });
1317
1351
  // Best effort and deliberately not awaited: the title is only there so
1318
1352
  // the popup can tell two concurrent flows apart, and no flow should pay
1319
1353
  // a round trip to chrome.tabs before its first step.
1320
1354
  void chrome.tabs
1321
1355
  .get(tabId)
1322
1356
  .then((tab) => {
1323
- const entry = flowRegistry.get(flowId);
1324
- if (entry && typeof tab.title === 'string')
1357
+ if (typeof tab.title === 'string')
1325
1358
  entry.title = tab.title;
1326
1359
  })
1327
1360
  .catch(() => {
1328
1361
  // Tab gone or title unavailable — the panel falls back to the id.
1329
1362
  });
1330
- const shouldCancel = () => flowRegistry.get(flowId)?.cancelled === true;
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
+ }
1331
1422
  let flowResult = null;
1332
1423
  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
- });
1424
+ flowResult = await run();
1342
1425
  // The wire-level response is ok:true whenever the flow RAN (even a
1343
1426
  // failed step, and even a cancellation, is a legitimate business
1344
1427
  // outcome — same pattern as wait_for's matched:false) — flowResult
@@ -1353,6 +1436,20 @@ async function handleFlow(tabId, state, msg, p) {
1353
1436
  recordFinishedFlow(flowId, flowResult);
1354
1437
  }
1355
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
+ }
1356
1453
  /** The perform* bindings `runFlow` needs, in one place so the real run and
1357
1454
  * the dry-run projection cannot drift apart. `shouldCancel` is threaded
1358
1455
  * into `performWaitFor` alone: it is the only perform* with a loop of its
@@ -1368,35 +1465,245 @@ function buildFlowDeps(tabId, state, shouldCancel) {
1368
1465
  buildRecoverySnapshot: () => evaluateInTab(tabId, buildSnapshotJs({ only_interactive: true, max_interactive: 40 })),
1369
1466
  };
1370
1467
  }
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. */
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. */
1380
1511
  function recordFinishedFlow(flowId, result) {
1381
1512
  const entry = flowRegistry.finish(flowId);
1382
1513
  if (!entry)
1383
1514
  return;
1384
- const historyEntry = toHistoryEntry(entry, result, Date.now());
1385
- const key = flowHistoryKey(entry.tabId);
1386
- flowHistoryWrites = flowHistoryWrites
1387
- .then(async () => {
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 () => {
1388
1542
  const data = await chrome.storage.session.get(key);
1389
1543
  await chrome.storage.session.set({
1390
1544
  [key]: appendFlowHistory(parseFlowHistory(data[key]), historyEntry),
1391
1545
  });
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
1546
  });
1399
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
+ }
1706
+ }
1400
1707
  async function handleDrag(tabId, msg, p, optStr) {
1401
1708
  // Thin wire wrapper over performDrag — the same pattern the standalone
1402
1709
  // click/type/press/wait_for cases use over their perform* functions.
@@ -1571,6 +1878,13 @@ async function handleTool(state, msg) {
1571
1878
  }
1572
1879
  case 'flow':
1573
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);
1574
1888
  case 'drag':
1575
1889
  return await handleDrag(tabId, msg, p, optStr);
1576
1890
  case 'evaluate': {
@@ -1805,6 +2119,10 @@ async function resumePendingReconnects() {
1805
2119
  }
1806
2120
  }
1807
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();
1808
2126
  /** Tabs with a `connectTab` currently in flight. The `tabStates.has` check
1809
2127
  * inside `doConnectTab` alone cannot prevent two concurrent connects — the
1810
2128
  * state lands only after three awaited chrome.* calls — so this SYNCHRONOUS
@@ -1833,6 +2151,11 @@ async function doConnectTab(tabId) {
1833
2151
  if (tabStates.has(tabId)) {
1834
2152
  return { ok: false, error: 'This tab is already connected' };
1835
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);
1836
2159
  const tab = await chrome.tabs.get(tabId);
1837
2160
  if (!tab.url || !tab.title) {
1838
2161
  return { ok: false, error: 'Tab has no URL or title' };
@@ -1920,6 +2243,11 @@ async function doConnectTab(tabId) {
1920
2243
  // asks the new primary to honour it. The primary emits
1921
2244
  // `tab-renamed` in the bridge event log if it can't.
1922
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);
1923
2251
  settle({ ok: true, serverTabId: msg.payload.tabId });
1924
2252
  return;
1925
2253
  }
@@ -2481,9 +2809,15 @@ chrome.tabs.onRemoved.addListener((tabId) => {
2481
2809
  void forgetTabId(tabId);
2482
2810
  // …and its flow history. Chrome reuses tab ids within a session, so a
2483
2811
  // 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
- });
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)));
2487
2821
  });
2488
2822
  /* Auto-connect tabs spawned by a connected tab.
2489
2823
  *