@flrande/bak-extension 0.6.3 → 0.6.4

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.
@@ -1 +1 @@
1
- 2026-03-12T12:13:27.330Z
1
+ 2026-03-12T15:53:45.600Z
@@ -62,7 +62,7 @@
62
62
  // package.json
63
63
  var package_default = {
64
64
  name: "@flrande/bak-extension",
65
- version: "0.6.3",
65
+ version: "0.6.4",
66
66
  type: "module",
67
67
  scripts: {
68
68
  build: "tsup src/background.ts src/content.ts src/popup.ts --format iife --out-dir dist --clean && node scripts/copy-assets.mjs",
@@ -548,7 +548,7 @@
548
548
  const initialUrl = options.initialUrl ?? DEFAULT_SESSION_BINDING_URL;
549
549
  const persisted = await this.storage.load(bindingId);
550
550
  const created = !persisted;
551
- let state = this.normalizeState(persisted, bindingId);
551
+ let state = this.normalizeState(persisted, bindingId, options.label);
552
552
  const originalWindowId = state.windowId;
553
553
  let window2 = state.windowId !== null ? await this.waitForWindow(state.windowId) : null;
554
554
  let tabs = [];
@@ -681,7 +681,8 @@
681
681
  const ensured = await this.ensureBinding({
682
682
  bindingId,
683
683
  focus: false,
684
- initialUrl: hadBinding ? options.url ?? DEFAULT_SESSION_BINDING_URL : DEFAULT_SESSION_BINDING_URL
684
+ initialUrl: hadBinding ? options.url ?? DEFAULT_SESSION_BINDING_URL : DEFAULT_SESSION_BINDING_URL,
685
+ label: options.label
685
686
  });
686
687
  let state = { ...ensured.binding, tabIds: [...ensured.binding.tabIds], tabs: [...ensured.binding.tabs] };
687
688
  if (state.windowId !== null && state.tabs.length === 0) {
@@ -715,7 +716,8 @@
715
716
  const repaired = await this.ensureBinding({
716
717
  bindingId,
717
718
  focus: false,
718
- initialUrl: desiredUrl
719
+ initialUrl: desiredUrl,
720
+ label: options.label
719
721
  });
720
722
  state = { ...repaired.binding };
721
723
  reusablePrimaryTab = await this.resolveReusablePrimaryTab(state, true);
@@ -827,6 +829,56 @@
827
829
  const refreshed = await this.ensureBinding({ bindingId, focus: false });
828
830
  return { ok: true, binding: refreshed.binding };
829
831
  }
832
+ async closeTab(bindingId, tabId) {
833
+ const ensured = await this.ensureBinding({ bindingId, focus: false });
834
+ const resolvedTabId = typeof tabId === "number" ? tabId : ensured.binding.activeTabId ?? ensured.binding.primaryTabId ?? ensured.binding.tabs[0]?.id;
835
+ if (typeof resolvedTabId !== "number" || !ensured.binding.tabIds.includes(resolvedTabId)) {
836
+ throw new Error(`Tab ${tabId ?? "active"} does not belong to binding ${bindingId}`);
837
+ }
838
+ await this.browser.closeTab(resolvedTabId);
839
+ const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
840
+ if (remainingTabIds.length === 0) {
841
+ const emptied = {
842
+ id: ensured.binding.id,
843
+ label: ensured.binding.label,
844
+ color: ensured.binding.color,
845
+ windowId: null,
846
+ groupId: null,
847
+ tabIds: [],
848
+ activeTabId: null,
849
+ primaryTabId: null
850
+ };
851
+ await this.storage.save(emptied);
852
+ return {
853
+ binding: {
854
+ ...emptied,
855
+ tabs: []
856
+ },
857
+ closedTabId: resolvedTabId
858
+ };
859
+ }
860
+ const tabs = await this.readLooseTrackedTabs(remainingTabIds);
861
+ const nextPrimaryTabId = ensured.binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : ensured.binding.primaryTabId;
862
+ const nextActiveTabId = ensured.binding.activeTabId === resolvedTabId ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null : ensured.binding.activeTabId;
863
+ const nextState = {
864
+ id: ensured.binding.id,
865
+ label: ensured.binding.label,
866
+ color: ensured.binding.color,
867
+ windowId: tabs[0]?.windowId ?? ensured.binding.windowId,
868
+ groupId: tabs[0]?.groupId ?? ensured.binding.groupId,
869
+ tabIds: tabs.map((candidate) => candidate.id),
870
+ activeTabId: nextActiveTabId,
871
+ primaryTabId: nextPrimaryTabId
872
+ };
873
+ await this.storage.save(nextState);
874
+ return {
875
+ binding: {
876
+ ...nextState,
877
+ tabs
878
+ },
879
+ closedTabId: resolvedTabId
880
+ };
881
+ }
830
882
  async reset(options = {}) {
831
883
  const bindingId = this.normalizeBindingId(options.bindingId);
832
884
  await this.close(bindingId);
@@ -842,10 +894,11 @@
842
894
  return { ok: true };
843
895
  }
844
896
  await this.storage.delete(bindingId);
845
- if (state.windowId !== null) {
846
- const existingWindow = await this.browser.getWindow(state.windowId);
847
- if (existingWindow) {
848
- await this.browser.closeWindow(state.windowId);
897
+ const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
898
+ for (const trackedTab of trackedTabs) {
899
+ try {
900
+ await this.browser.closeTab(trackedTab.id);
901
+ } catch {
849
902
  }
850
903
  }
851
904
  return { ok: true };
@@ -896,10 +949,10 @@
896
949
  }
897
950
  return candidate;
898
951
  }
899
- normalizeState(state, bindingId) {
952
+ normalizeState(state, bindingId, label) {
900
953
  return {
901
954
  id: bindingId,
902
- label: state?.label ?? DEFAULT_SESSION_BINDING_LABEL,
955
+ label: label?.trim() ? label.trim() : state?.label ?? DEFAULT_SESSION_BINDING_LABEL,
903
956
  color: state?.color ?? DEFAULT_SESSION_BINDING_COLOR,
904
957
  windowId: state?.windowId ?? null,
905
958
  groupId: state?.groupId ?? null,
@@ -1066,6 +1119,10 @@
1066
1119
  state.tabIds = recreatedTabs.map((tab) => tab.id);
1067
1120
  state.primaryTabId = nextPrimaryTabId;
1068
1121
  state.activeTabId = nextActiveTabId;
1122
+ await this.storage.save({
1123
+ ...state,
1124
+ tabIds: [...state.tabIds]
1125
+ });
1069
1126
  for (const bindingTab of ownership.bindingTabs) {
1070
1127
  await this.browser.closeTab(bindingTab.id);
1071
1128
  }
@@ -1280,6 +1337,7 @@
1280
1337
  var lastError = null;
1281
1338
  var manualDisconnect = false;
1282
1339
  var sessionBindingStateMutationQueue = Promise.resolve();
1340
+ var preserveHumanFocusDepth = 0;
1283
1341
  async function getConfig() {
1284
1342
  const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
1285
1343
  return {
@@ -1322,6 +1380,18 @@
1322
1380
  ws.send(JSON.stringify(payload));
1323
1381
  }
1324
1382
  }
1383
+ function sendEvent(event, data) {
1384
+ if (ws && ws.readyState === WebSocket.OPEN) {
1385
+ ws.send(
1386
+ JSON.stringify({
1387
+ type: "event",
1388
+ event,
1389
+ data,
1390
+ ts: Date.now()
1391
+ })
1392
+ );
1393
+ }
1394
+ }
1325
1395
  function toError(code, message, data) {
1326
1396
  return { code, message, data };
1327
1397
  }
@@ -1409,6 +1479,26 @@
1409
1479
  delete stateMap[bindingId];
1410
1480
  });
1411
1481
  }
1482
+ function toSessionBindingEventBrowser(state) {
1483
+ if (!state) {
1484
+ return null;
1485
+ }
1486
+ return {
1487
+ windowId: state.windowId,
1488
+ groupId: state.groupId,
1489
+ tabIds: [...state.tabIds],
1490
+ activeTabId: state.activeTabId,
1491
+ primaryTabId: state.primaryTabId
1492
+ };
1493
+ }
1494
+ function emitSessionBindingUpdated(bindingId, reason, state, extras = {}) {
1495
+ sendEvent("sessionBinding.updated", {
1496
+ bindingId,
1497
+ reason,
1498
+ browser: toSessionBindingEventBrowser(state),
1499
+ ...extras
1500
+ });
1501
+ }
1412
1502
  var sessionBindingBrowser = {
1413
1503
  async getTab(tabId) {
1414
1504
  try {
@@ -1756,10 +1846,15 @@
1756
1846
  return action();
1757
1847
  }
1758
1848
  const focusContext = await captureFocusContext();
1849
+ preserveHumanFocusDepth += 1;
1759
1850
  try {
1760
1851
  return await action();
1761
1852
  } finally {
1762
- await restoreFocusContext(focusContext);
1853
+ try {
1854
+ await restoreFocusContext(focusContext);
1855
+ } finally {
1856
+ preserveHumanFocusDepth = Math.max(0, preserveHumanFocusDepth - 1);
1857
+ }
1763
1858
  }
1764
1859
  }
1765
1860
  function requireRpcEnvelope(method, value) {
@@ -2622,11 +2717,13 @@
2622
2717
  const result = await bindingManager.ensureBinding({
2623
2718
  bindingId: String(params.bindingId ?? ""),
2624
2719
  focus: params.focus === true,
2625
- initialUrl: typeof params.url === "string" ? params.url : void 0
2720
+ initialUrl: typeof params.url === "string" ? params.url : void 0,
2721
+ label: typeof params.label === "string" ? params.label : void 0
2626
2722
  });
2627
2723
  for (const tab of result.binding.tabs) {
2628
2724
  void ensureNetworkDebugger(tab.id).catch(() => void 0);
2629
2725
  }
2726
+ emitSessionBindingUpdated(result.binding.id, "ensure", result.binding);
2630
2727
  return {
2631
2728
  browser: result.binding,
2632
2729
  created: result.created,
@@ -2647,11 +2744,13 @@
2647
2744
  bindingId: String(params.bindingId ?? ""),
2648
2745
  url: expectedUrl,
2649
2746
  active: params.active === true,
2650
- focus: params.focus === true
2747
+ focus: params.focus === true,
2748
+ label: typeof params.label === "string" ? params.label : void 0
2651
2749
  });
2652
2750
  });
2653
2751
  const finalized = await finalizeOpenedSessionBindingTab(opened, expectedUrl);
2654
2752
  void ensureNetworkDebugger(finalized.tab.id).catch(() => void 0);
2753
+ emitSessionBindingUpdated(finalized.binding.id, "open-tab", finalized.binding);
2655
2754
  return {
2656
2755
  browser: finalized.binding,
2657
2756
  tab: finalized.tab
@@ -2674,6 +2773,7 @@
2674
2773
  case "sessionBinding.setActiveTab": {
2675
2774
  const result = await bindingManager.setActiveTab(Number(params.tabId), String(params.bindingId ?? ""));
2676
2775
  void ensureNetworkDebugger(result.tab.id).catch(() => void 0);
2776
+ emitSessionBindingUpdated(result.binding.id, "set-active-tab", result.binding);
2677
2777
  return {
2678
2778
  browser: result.binding,
2679
2779
  tab: result.tab
@@ -2691,8 +2791,10 @@
2691
2791
  const result = await bindingManager.reset({
2692
2792
  bindingId: String(params.bindingId ?? ""),
2693
2793
  focus: params.focus === true,
2694
- initialUrl: typeof params.url === "string" ? params.url : void 0
2794
+ initialUrl: typeof params.url === "string" ? params.url : void 0,
2795
+ label: typeof params.label === "string" ? params.label : void 0
2695
2796
  });
2797
+ emitSessionBindingUpdated(result.binding.id, "reset", result.binding);
2696
2798
  return {
2697
2799
  browser: result.binding,
2698
2800
  created: result.created,
@@ -2701,8 +2803,22 @@
2701
2803
  };
2702
2804
  });
2703
2805
  }
2806
+ case "sessionBinding.closeTab": {
2807
+ const bindingId = String(params.bindingId ?? "");
2808
+ const result = await bindingManager.closeTab(bindingId, typeof params.tabId === "number" ? params.tabId : void 0);
2809
+ emitSessionBindingUpdated(bindingId, "close-tab", result.binding, {
2810
+ closedTabId: result.closedTabId
2811
+ });
2812
+ return {
2813
+ browser: result.binding,
2814
+ closedTabId: result.closedTabId
2815
+ };
2816
+ }
2704
2817
  case "sessionBinding.close": {
2705
- return await bindingManager.close(String(params.bindingId ?? ""));
2818
+ const bindingId = String(params.bindingId ?? "");
2819
+ const result = await bindingManager.close(bindingId);
2820
+ emitSessionBindingUpdated(bindingId, "close", null);
2821
+ return result;
2706
2822
  }
2707
2823
  case "page.goto": {
2708
2824
  return await preserveHumanFocus(typeof target.tabId !== "number", async () => {
@@ -3263,40 +3379,68 @@
3263
3379
  chrome.tabs.onRemoved.addListener((tabId) => {
3264
3380
  dropNetworkCapture(tabId);
3265
3381
  void mutateSessionBindingStateMap((stateMap) => {
3382
+ const updates = [];
3266
3383
  for (const [bindingId, state] of Object.entries(stateMap)) {
3267
3384
  if (!state.tabIds.includes(tabId)) {
3268
3385
  continue;
3269
3386
  }
3270
3387
  const nextTabIds = state.tabIds.filter((id) => id !== tabId);
3271
- stateMap[bindingId] = {
3388
+ const fallbackTabId = nextTabIds[0] ?? null;
3389
+ const nextState = {
3272
3390
  ...state,
3273
3391
  tabIds: nextTabIds,
3274
- activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
3275
- primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
3392
+ activeTabId: state.activeTabId === tabId ? fallbackTabId : state.activeTabId,
3393
+ primaryTabId: state.primaryTabId === tabId ? fallbackTabId : state.primaryTabId
3276
3394
  };
3395
+ stateMap[bindingId] = nextState;
3396
+ updates.push({ bindingId, state: nextState });
3397
+ }
3398
+ return updates;
3399
+ }).then((updates) => {
3400
+ for (const update of updates) {
3401
+ emitSessionBindingUpdated(update.bindingId, "tab-removed", update.state, {
3402
+ closedTabId: tabId
3403
+ });
3277
3404
  }
3278
3405
  });
3279
3406
  });
3280
3407
  chrome.tabs.onActivated.addListener((activeInfo) => {
3281
- void mutateSessionBindingStateMap((stateMap) => {
3282
- for (const [bindingId, state] of Object.entries(stateMap)) {
3283
- if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
3284
- continue;
3408
+ if (preserveHumanFocusDepth > 0) {
3409
+ return;
3410
+ }
3411
+ void chrome.windows.get(activeInfo.windowId).then((window2) => window2.focused === true).catch(() => false).then((windowFocused) => {
3412
+ if (!windowFocused) {
3413
+ return [];
3414
+ }
3415
+ return mutateSessionBindingStateMap((stateMap) => {
3416
+ const updates = [];
3417
+ for (const [bindingId, state] of Object.entries(stateMap)) {
3418
+ if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
3419
+ continue;
3420
+ }
3421
+ const nextState = {
3422
+ ...state,
3423
+ activeTabId: activeInfo.tabId
3424
+ };
3425
+ stateMap[bindingId] = nextState;
3426
+ updates.push({ bindingId, state: nextState });
3285
3427
  }
3286
- stateMap[bindingId] = {
3287
- ...state,
3288
- activeTabId: activeInfo.tabId
3289
- };
3428
+ return updates;
3429
+ });
3430
+ }).then((updates) => {
3431
+ for (const update of updates) {
3432
+ emitSessionBindingUpdated(update.bindingId, "tab-activated", update.state);
3290
3433
  }
3291
3434
  });
3292
3435
  });
3293
3436
  chrome.windows.onRemoved.addListener((windowId) => {
3294
3437
  void mutateSessionBindingStateMap((stateMap) => {
3438
+ const updates = [];
3295
3439
  for (const [bindingId, state] of Object.entries(stateMap)) {
3296
3440
  if (state.windowId !== windowId) {
3297
3441
  continue;
3298
3442
  }
3299
- stateMap[bindingId] = {
3443
+ const nextState = {
3300
3444
  ...state,
3301
3445
  windowId: null,
3302
3446
  groupId: null,
@@ -3304,6 +3448,13 @@
3304
3448
  activeTabId: null,
3305
3449
  primaryTabId: null
3306
3450
  };
3451
+ stateMap[bindingId] = nextState;
3452
+ updates.push({ bindingId, state: nextState });
3453
+ }
3454
+ return updates;
3455
+ }).then((updates) => {
3456
+ for (const update of updates) {
3457
+ emitSessionBindingUpdated(update.bindingId, "window-removed", update.state);
3307
3458
  }
3308
3459
  });
3309
3460
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Agent Kit",
4
- "version": "0.6.3",
4
+ "version": "0.6.4",
5
5
  "action": {
6
6
  "default_popup": "popup.html"
7
7
  },
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@flrande/bak-extension",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@flrande/bak-protocol": "0.6.3"
6
+ "@flrande/bak-protocol": "0.6.4"
7
7
  },
8
8
  "devDependencies": {
9
9
  "@types/chrome": "^0.1.14",
package/src/background.ts CHANGED
@@ -116,6 +116,7 @@ let reconnectAttempt = 0;
116
116
  let lastError: RuntimeErrorDetails | null = null;
117
117
  let manualDisconnect = false;
118
118
  let sessionBindingStateMutationQueue: Promise<void> = Promise.resolve();
119
+ let preserveHumanFocusDepth = 0;
119
120
 
120
121
  async function getConfig(): Promise<ExtensionConfig> {
121
122
  const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
@@ -158,11 +159,24 @@ function clearReconnectTimer(): void {
158
159
  nextReconnectInMs = null;
159
160
  }
160
161
 
161
- function sendResponse(payload: CliResponse): void {
162
- if (ws && ws.readyState === WebSocket.OPEN) {
163
- ws.send(JSON.stringify(payload));
164
- }
165
- }
162
+ function sendResponse(payload: CliResponse): void {
163
+ if (ws && ws.readyState === WebSocket.OPEN) {
164
+ ws.send(JSON.stringify(payload));
165
+ }
166
+ }
167
+
168
+ function sendEvent(event: string, data: Record<string, unknown>): void {
169
+ if (ws && ws.readyState === WebSocket.OPEN) {
170
+ ws.send(
171
+ JSON.stringify({
172
+ type: 'event',
173
+ event,
174
+ data,
175
+ ts: Date.now()
176
+ })
177
+ );
178
+ }
179
+ }
166
180
 
167
181
  function toError(code: string, message: string, data?: Record<string, unknown>): CliResponse['error'] {
168
182
  return { code, message, data };
@@ -265,6 +279,33 @@ async function deleteSessionBindingState(bindingId: string): Promise<void> {
265
279
  delete stateMap[bindingId];
266
280
  });
267
281
  }
282
+
283
+ function toSessionBindingEventBrowser(state: SessionBindingRecord | null): Record<string, unknown> | null {
284
+ if (!state) {
285
+ return null;
286
+ }
287
+ return {
288
+ windowId: state.windowId,
289
+ groupId: state.groupId,
290
+ tabIds: [...state.tabIds],
291
+ activeTabId: state.activeTabId,
292
+ primaryTabId: state.primaryTabId
293
+ };
294
+ }
295
+
296
+ function emitSessionBindingUpdated(
297
+ bindingId: string,
298
+ reason: string,
299
+ state: SessionBindingRecord | null,
300
+ extras: Record<string, unknown> = {}
301
+ ): void {
302
+ sendEvent('sessionBinding.updated', {
303
+ bindingId,
304
+ reason,
305
+ browser: toSessionBindingEventBrowser(state),
306
+ ...extras
307
+ });
308
+ }
268
309
 
269
310
  const sessionBindingBrowser: SessionBindingBrowser = {
270
311
  async getTab(tabId) {
@@ -673,18 +714,23 @@ async function restoreFocusContext(context: FocusContext): Promise<void> {
673
714
  }
674
715
  }
675
716
 
676
- async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
677
- if (!enabled) {
678
- return action();
679
- }
680
-
681
- const focusContext = await captureFocusContext();
682
- try {
683
- return await action();
684
- } finally {
685
- await restoreFocusContext(focusContext);
686
- }
687
- }
717
+ async function preserveHumanFocus<T>(enabled: boolean, action: () => Promise<T>): Promise<T> {
718
+ if (!enabled) {
719
+ return action();
720
+ }
721
+
722
+ const focusContext = await captureFocusContext();
723
+ preserveHumanFocusDepth += 1;
724
+ try {
725
+ return await action();
726
+ } finally {
727
+ try {
728
+ await restoreFocusContext(focusContext);
729
+ } finally {
730
+ preserveHumanFocusDepth = Math.max(0, preserveHumanFocusDepth - 1);
731
+ }
732
+ }
733
+ }
688
734
 
689
735
  function requireRpcEnvelope(
690
736
  method: string,
@@ -1686,11 +1732,13 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1686
1732
  const result = await bindingManager.ensureBinding({
1687
1733
  bindingId: String(params.bindingId ?? ''),
1688
1734
  focus: params.focus === true,
1689
- initialUrl: typeof params.url === 'string' ? params.url : undefined
1735
+ initialUrl: typeof params.url === 'string' ? params.url : undefined,
1736
+ label: typeof params.label === 'string' ? params.label : undefined
1690
1737
  });
1691
1738
  for (const tab of result.binding.tabs) {
1692
1739
  void ensureNetworkDebugger(tab.id).catch(() => undefined);
1693
1740
  }
1741
+ emitSessionBindingUpdated(result.binding.id, 'ensure', result.binding);
1694
1742
  return {
1695
1743
  browser: result.binding,
1696
1744
  created: result.created,
@@ -1711,11 +1759,13 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1711
1759
  bindingId: String(params.bindingId ?? ''),
1712
1760
  url: expectedUrl,
1713
1761
  active: params.active === true,
1714
- focus: params.focus === true
1762
+ focus: params.focus === true,
1763
+ label: typeof params.label === 'string' ? params.label : undefined
1715
1764
  });
1716
1765
  });
1717
1766
  const finalized = await finalizeOpenedSessionBindingTab(opened, expectedUrl);
1718
1767
  void ensureNetworkDebugger(finalized.tab.id).catch(() => undefined);
1768
+ emitSessionBindingUpdated(finalized.binding.id, 'open-tab', finalized.binding);
1719
1769
  return {
1720
1770
  browser: finalized.binding,
1721
1771
  tab: finalized.tab
@@ -1738,6 +1788,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1738
1788
  case 'sessionBinding.setActiveTab': {
1739
1789
  const result = await bindingManager.setActiveTab(Number(params.tabId), String(params.bindingId ?? ''));
1740
1790
  void ensureNetworkDebugger(result.tab.id).catch(() => undefined);
1791
+ emitSessionBindingUpdated(result.binding.id, 'set-active-tab', result.binding);
1741
1792
  return {
1742
1793
  browser: result.binding,
1743
1794
  tab: result.tab
@@ -1755,8 +1806,10 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1755
1806
  const result = await bindingManager.reset({
1756
1807
  bindingId: String(params.bindingId ?? ''),
1757
1808
  focus: params.focus === true,
1758
- initialUrl: typeof params.url === 'string' ? params.url : undefined
1809
+ initialUrl: typeof params.url === 'string' ? params.url : undefined,
1810
+ label: typeof params.label === 'string' ? params.label : undefined
1759
1811
  });
1812
+ emitSessionBindingUpdated(result.binding.id, 'reset', result.binding);
1760
1813
  return {
1761
1814
  browser: result.binding,
1762
1815
  created: result.created,
@@ -1765,8 +1818,22 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1765
1818
  };
1766
1819
  });
1767
1820
  }
1821
+ case 'sessionBinding.closeTab': {
1822
+ const bindingId = String(params.bindingId ?? '');
1823
+ const result = await bindingManager.closeTab(bindingId, typeof params.tabId === 'number' ? params.tabId : undefined);
1824
+ emitSessionBindingUpdated(bindingId, 'close-tab', result.binding, {
1825
+ closedTabId: result.closedTabId
1826
+ });
1827
+ return {
1828
+ browser: result.binding,
1829
+ closedTabId: result.closedTabId
1830
+ };
1831
+ }
1768
1832
  case 'sessionBinding.close': {
1769
- return await bindingManager.close(String(params.bindingId ?? ''));
1833
+ const bindingId = String(params.bindingId ?? '');
1834
+ const result = await bindingManager.close(bindingId);
1835
+ emitSessionBindingUpdated(bindingId, 'close', null);
1836
+ return result;
1770
1837
  }
1771
1838
  case 'page.goto': {
1772
1839
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
@@ -2352,42 +2419,75 @@ async function connectWebSocket(): Promise<void> {
2352
2419
  chrome.tabs.onRemoved.addListener((tabId) => {
2353
2420
  dropNetworkCapture(tabId);
2354
2421
  void mutateSessionBindingStateMap((stateMap) => {
2422
+ const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
2355
2423
  for (const [bindingId, state] of Object.entries(stateMap)) {
2356
2424
  if (!state.tabIds.includes(tabId)) {
2357
2425
  continue;
2358
2426
  }
2359
2427
  const nextTabIds = state.tabIds.filter((id) => id !== tabId);
2360
- stateMap[bindingId] = {
2428
+ const fallbackTabId = nextTabIds[0] ?? null;
2429
+ const nextState: SessionBindingRecord = {
2361
2430
  ...state,
2362
2431
  tabIds: nextTabIds,
2363
- activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
2364
- primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
2432
+ activeTabId: state.activeTabId === tabId ? fallbackTabId : state.activeTabId,
2433
+ primaryTabId: state.primaryTabId === tabId ? fallbackTabId : state.primaryTabId
2365
2434
  };
2435
+ stateMap[bindingId] = nextState;
2436
+ updates.push({ bindingId, state: nextState });
2437
+ }
2438
+ return updates;
2439
+ }).then((updates) => {
2440
+ for (const update of updates) {
2441
+ emitSessionBindingUpdated(update.bindingId, 'tab-removed', update.state, {
2442
+ closedTabId: tabId
2443
+ });
2366
2444
  }
2367
2445
  });
2368
2446
  });
2369
2447
 
2370
2448
  chrome.tabs.onActivated.addListener((activeInfo) => {
2371
- void mutateSessionBindingStateMap((stateMap) => {
2372
- for (const [bindingId, state] of Object.entries(stateMap)) {
2373
- if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
2374
- continue;
2449
+ if (preserveHumanFocusDepth > 0) {
2450
+ return;
2451
+ }
2452
+ void chrome.windows
2453
+ .get(activeInfo.windowId)
2454
+ .then((window) => window.focused === true)
2455
+ .catch(() => false)
2456
+ .then((windowFocused) => {
2457
+ if (!windowFocused) {
2458
+ return [] as Array<{ bindingId: string; state: SessionBindingRecord }>;
2375
2459
  }
2376
- stateMap[bindingId] = {
2377
- ...state,
2378
- activeTabId: activeInfo.tabId
2379
- };
2380
- }
2381
- });
2460
+ return mutateSessionBindingStateMap((stateMap) => {
2461
+ const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
2462
+ for (const [bindingId, state] of Object.entries(stateMap)) {
2463
+ if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
2464
+ continue;
2465
+ }
2466
+ const nextState: SessionBindingRecord = {
2467
+ ...state,
2468
+ activeTabId: activeInfo.tabId
2469
+ };
2470
+ stateMap[bindingId] = nextState;
2471
+ updates.push({ bindingId, state: nextState });
2472
+ }
2473
+ return updates;
2474
+ });
2475
+ })
2476
+ .then((updates) => {
2477
+ for (const update of updates) {
2478
+ emitSessionBindingUpdated(update.bindingId, 'tab-activated', update.state);
2479
+ }
2480
+ });
2382
2481
  });
2383
2482
 
2384
2483
  chrome.windows.onRemoved.addListener((windowId) => {
2385
2484
  void mutateSessionBindingStateMap((stateMap) => {
2485
+ const updates: Array<{ bindingId: string; state: SessionBindingRecord }> = [];
2386
2486
  for (const [bindingId, state] of Object.entries(stateMap)) {
2387
2487
  if (state.windowId !== windowId) {
2388
2488
  continue;
2389
2489
  }
2390
- stateMap[bindingId] = {
2490
+ const nextState: SessionBindingRecord = {
2391
2491
  ...state,
2392
2492
  windowId: null,
2393
2493
  groupId: null,
@@ -2395,6 +2495,13 @@ chrome.windows.onRemoved.addListener((windowId) => {
2395
2495
  activeTabId: null,
2396
2496
  primaryTabId: null
2397
2497
  };
2498
+ stateMap[bindingId] = nextState;
2499
+ updates.push({ bindingId, state: nextState });
2500
+ }
2501
+ return updates;
2502
+ }).then((updates) => {
2503
+ for (const update of updates) {
2504
+ emitSessionBindingUpdated(update.bindingId, 'window-removed', update.state);
2398
2505
  }
2399
2506
  });
2400
2507
  });
@@ -88,18 +88,20 @@ interface SessionBindingWindowOwnership {
88
88
  foreignTabs: SessionBindingTab[];
89
89
  }
90
90
 
91
- export interface SessionBindingEnsureOptions {
92
- bindingId?: string;
93
- focus?: boolean;
94
- initialUrl?: string;
95
- }
96
-
97
- export interface SessionBindingOpenTabOptions {
98
- bindingId?: string;
99
- url?: string;
100
- active?: boolean;
101
- focus?: boolean;
102
- }
91
+ export interface SessionBindingEnsureOptions {
92
+ bindingId?: string;
93
+ focus?: boolean;
94
+ initialUrl?: string;
95
+ label?: string;
96
+ }
97
+
98
+ export interface SessionBindingOpenTabOptions {
99
+ bindingId?: string;
100
+ url?: string;
101
+ active?: boolean;
102
+ focus?: boolean;
103
+ label?: string;
104
+ }
103
105
 
104
106
  export interface SessionBindingResolveTargetOptions {
105
107
  tabId?: number;
@@ -120,13 +122,13 @@ class SessionBindingManager {
120
122
  return this.inspectBinding(bindingId);
121
123
  }
122
124
 
123
- async ensureBinding(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
124
- const bindingId = this.normalizeBindingId(options.bindingId);
125
- const repairActions: string[] = [];
126
- const initialUrl = options.initialUrl ?? DEFAULT_SESSION_BINDING_URL;
127
- const persisted = await this.storage.load(bindingId);
128
- const created = !persisted;
129
- let state = this.normalizeState(persisted, bindingId);
125
+ async ensureBinding(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
126
+ const bindingId = this.normalizeBindingId(options.bindingId);
127
+ const repairActions: string[] = [];
128
+ const initialUrl = options.initialUrl ?? DEFAULT_SESSION_BINDING_URL;
129
+ const persisted = await this.storage.load(bindingId);
130
+ const created = !persisted;
131
+ let state = this.normalizeState(persisted, bindingId, options.label);
130
132
 
131
133
  const originalWindowId = state.windowId;
132
134
  let window = state.windowId !== null ? await this.waitForWindow(state.windowId) : null;
@@ -266,14 +268,15 @@ class SessionBindingManager {
266
268
  };
267
269
  }
268
270
 
269
- async openTab(options: SessionBindingOpenTabOptions = {}): Promise<{ binding: SessionBindingInfo; tab: SessionBindingTab }> {
270
- const bindingId = this.normalizeBindingId(options.bindingId);
271
- const hadBinding = (await this.loadBindingRecord(bindingId)) !== null;
272
- const ensured = await this.ensureBinding({
273
- bindingId,
274
- focus: false,
275
- initialUrl: hadBinding ? options.url ?? DEFAULT_SESSION_BINDING_URL : DEFAULT_SESSION_BINDING_URL
276
- });
271
+ async openTab(options: SessionBindingOpenTabOptions = {}): Promise<{ binding: SessionBindingInfo; tab: SessionBindingTab }> {
272
+ const bindingId = this.normalizeBindingId(options.bindingId);
273
+ const hadBinding = (await this.loadBindingRecord(bindingId)) !== null;
274
+ const ensured = await this.ensureBinding({
275
+ bindingId,
276
+ focus: false,
277
+ initialUrl: hadBinding ? options.url ?? DEFAULT_SESSION_BINDING_URL : DEFAULT_SESSION_BINDING_URL,
278
+ label: options.label
279
+ });
277
280
  let state = { ...ensured.binding, tabIds: [...ensured.binding.tabIds], tabs: [...ensured.binding.tabs] };
278
281
  if (state.windowId !== null && state.tabs.length === 0) {
279
282
  const rebound = await this.rebindBindingWindow(state);
@@ -309,11 +312,12 @@ class SessionBindingManager {
309
312
  if (!this.isMissingWindowError(error)) {
310
313
  throw error;
311
314
  }
312
- const repaired = await this.ensureBinding({
313
- bindingId,
314
- focus: false,
315
- initialUrl: desiredUrl
316
- });
315
+ const repaired = await this.ensureBinding({
316
+ bindingId,
317
+ focus: false,
318
+ initialUrl: desiredUrl,
319
+ label: options.label
320
+ });
317
321
  state = { ...repaired.binding };
318
322
  reusablePrimaryTab = await this.resolveReusablePrimaryTab(state, true);
319
323
  createdTab = reusablePrimaryTab
@@ -421,44 +425,107 @@ class SessionBindingManager {
421
425
  };
422
426
  }
423
427
 
424
- async focus(bindingId: string): Promise<{ ok: true; binding: SessionBindingInfo }> {
425
- const ensured = await this.ensureBinding({ bindingId, focus: false });
426
- if (ensured.binding.activeTabId !== null) {
427
- await this.browser.updateTab(ensured.binding.activeTabId, { active: true });
428
- }
428
+ async focus(bindingId: string): Promise<{ ok: true; binding: SessionBindingInfo }> {
429
+ const ensured = await this.ensureBinding({ bindingId, focus: false });
430
+ if (ensured.binding.activeTabId !== null) {
431
+ await this.browser.updateTab(ensured.binding.activeTabId, { active: true });
432
+ }
429
433
  if (ensured.binding.windowId !== null) {
430
434
  await this.browser.updateWindow(ensured.binding.windowId, { focused: true });
431
435
  }
432
- const refreshed = await this.ensureBinding({ bindingId, focus: false });
433
- return { ok: true, binding: refreshed.binding };
434
- }
435
-
436
- async reset(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
437
- const bindingId = this.normalizeBindingId(options.bindingId);
438
- await this.close(bindingId);
436
+ const refreshed = await this.ensureBinding({ bindingId, focus: false });
437
+ return { ok: true, binding: refreshed.binding };
438
+ }
439
+
440
+ async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
441
+ const ensured = await this.ensureBinding({ bindingId, focus: false });
442
+ const resolvedTabId =
443
+ typeof tabId === 'number'
444
+ ? tabId
445
+ : ensured.binding.activeTabId ?? ensured.binding.primaryTabId ?? ensured.binding.tabs[0]?.id;
446
+ if (typeof resolvedTabId !== 'number' || !ensured.binding.tabIds.includes(resolvedTabId)) {
447
+ throw new Error(`Tab ${tabId ?? 'active'} does not belong to binding ${bindingId}`);
448
+ }
449
+
450
+ await this.browser.closeTab(resolvedTabId);
451
+ const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
452
+
453
+ if (remainingTabIds.length === 0) {
454
+ const emptied: SessionBindingRecord = {
455
+ id: ensured.binding.id,
456
+ label: ensured.binding.label,
457
+ color: ensured.binding.color,
458
+ windowId: null,
459
+ groupId: null,
460
+ tabIds: [],
461
+ activeTabId: null,
462
+ primaryTabId: null
463
+ };
464
+ await this.storage.save(emptied);
465
+ return {
466
+ binding: {
467
+ ...emptied,
468
+ tabs: []
469
+ },
470
+ closedTabId: resolvedTabId
471
+ };
472
+ }
473
+
474
+ const tabs = await this.readLooseTrackedTabs(remainingTabIds);
475
+ const nextPrimaryTabId =
476
+ ensured.binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : ensured.binding.primaryTabId;
477
+ const nextActiveTabId =
478
+ ensured.binding.activeTabId === resolvedTabId
479
+ ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
480
+ : ensured.binding.activeTabId;
481
+ const nextState: SessionBindingRecord = {
482
+ id: ensured.binding.id,
483
+ label: ensured.binding.label,
484
+ color: ensured.binding.color,
485
+ windowId: tabs[0]?.windowId ?? ensured.binding.windowId,
486
+ groupId: tabs[0]?.groupId ?? ensured.binding.groupId,
487
+ tabIds: tabs.map((candidate) => candidate.id),
488
+ activeTabId: nextActiveTabId,
489
+ primaryTabId: nextPrimaryTabId
490
+ };
491
+ await this.storage.save(nextState);
492
+ return {
493
+ binding: {
494
+ ...nextState,
495
+ tabs
496
+ },
497
+ closedTabId: resolvedTabId
498
+ };
499
+ }
500
+
501
+ async reset(options: SessionBindingEnsureOptions = {}): Promise<SessionBindingEnsureResult> {
502
+ const bindingId = this.normalizeBindingId(options.bindingId);
503
+ await this.close(bindingId);
439
504
  return this.ensureBinding({
440
505
  ...options,
441
506
  bindingId
442
507
  });
443
508
  }
444
509
 
445
- async close(bindingId: string): Promise<{ ok: true }> {
446
- const state = await this.loadBindingRecord(bindingId);
447
- if (!state) {
448
- await this.storage.delete(bindingId);
449
- return { ok: true };
450
- }
451
- // Clear persisted state before closing the window so tab/window removal
452
- // listeners cannot race and resurrect an empty binding record.
453
- await this.storage.delete(bindingId);
454
- if (state.windowId !== null) {
455
- const existingWindow = await this.browser.getWindow(state.windowId);
456
- if (existingWindow) {
457
- await this.browser.closeWindow(state.windowId);
458
- }
459
- }
460
- return { ok: true };
461
- }
510
+ async close(bindingId: string): Promise<{ ok: true }> {
511
+ const state = await this.loadBindingRecord(bindingId);
512
+ if (!state) {
513
+ await this.storage.delete(bindingId);
514
+ return { ok: true };
515
+ }
516
+ // Clear persisted state before closing the window so tab/window removal
517
+ // listeners cannot race and resurrect an empty binding record.
518
+ await this.storage.delete(bindingId);
519
+ const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
520
+ for (const trackedTab of trackedTabs) {
521
+ try {
522
+ await this.browser.closeTab(trackedTab.id);
523
+ } catch {
524
+ // Ignore tabs that were already removed before explicit close.
525
+ }
526
+ }
527
+ return { ok: true };
528
+ }
462
529
 
463
530
  async resolveTarget(options: SessionBindingResolveTargetOptions = {}): Promise<SessionBindingTargetResolution> {
464
531
  if (typeof options.tabId === 'number') {
@@ -511,13 +578,13 @@ class SessionBindingManager {
511
578
  return candidate;
512
579
  }
513
580
 
514
- private normalizeState(state: SessionBindingRecord | null, bindingId: string): SessionBindingRecord {
515
- return {
516
- id: bindingId,
517
- label: state?.label ?? DEFAULT_SESSION_BINDING_LABEL,
518
- color: state?.color ?? DEFAULT_SESSION_BINDING_COLOR,
519
- windowId: state?.windowId ?? null,
520
- groupId: state?.groupId ?? null,
581
+ private normalizeState(state: SessionBindingRecord | null, bindingId: string, label?: string): SessionBindingRecord {
582
+ return {
583
+ id: bindingId,
584
+ label: label?.trim() ? label.trim() : state?.label ?? DEFAULT_SESSION_BINDING_LABEL,
585
+ color: state?.color ?? DEFAULT_SESSION_BINDING_COLOR,
586
+ windowId: state?.windowId ?? null,
587
+ groupId: state?.groupId ?? null,
521
588
  tabIds: state?.tabIds ?? [],
522
589
  activeTabId: state?.activeTabId ?? null,
523
590
  primaryTabId: state?.primaryTabId ?? null
@@ -710,12 +777,16 @@ class SessionBindingManager {
710
777
  await this.browser.updateTab(nextActiveTabId, { active: true });
711
778
  }
712
779
 
713
- state.windowId = window.id;
714
- state.groupId = null;
715
- state.tabIds = recreatedTabs.map((tab) => tab.id);
716
- state.primaryTabId = nextPrimaryTabId;
717
- state.activeTabId = nextActiveTabId;
718
-
780
+ state.windowId = window.id;
781
+ state.groupId = null;
782
+ state.tabIds = recreatedTabs.map((tab) => tab.id);
783
+ state.primaryTabId = nextPrimaryTabId;
784
+ state.activeTabId = nextActiveTabId;
785
+ await this.storage.save({
786
+ ...state,
787
+ tabIds: [...state.tabIds]
788
+ });
789
+
719
790
  for (const bindingTab of ownership.bindingTabs) {
720
791
  await this.browser.closeTab(bindingTab.id);
721
792
  }