@flrande/bak-extension 0.6.7 → 0.6.8

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-13T07:58:24.806Z
1
+ 2026-03-13T08:51:15.417Z
@@ -62,7 +62,7 @@
62
62
  // package.json
63
63
  var package_default = {
64
64
  name: "@flrande/bak-extension",
65
- version: "0.6.7",
65
+ version: "0.6.8",
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",
@@ -563,26 +563,37 @@
563
563
  }
564
564
  }
565
565
  if (!window2) {
566
- const createdWindow = await this.browser.createWindow({
567
- url: initialUrl,
568
- focused: options.focus === true
569
- });
570
- state.windowId = createdWindow.id;
571
- state.groupId = null;
572
- state.tabIds = [];
573
- state.activeTabId = null;
574
- state.primaryTabId = null;
575
- window2 = createdWindow;
576
- const initialTab = typeof createdWindow.initialTabId === "number" ? await this.waitForTrackedTab(createdWindow.initialTabId, createdWindow.id) : null;
577
- tabs = initialTab ? [initialTab] : await this.waitForWindowTabs(createdWindow.id);
578
- state.tabIds = tabs.map((tab) => tab.id);
579
- if (state.primaryTabId === null) {
580
- state.primaryTabId = initialTab?.id ?? tabs[0]?.id ?? null;
581
- }
582
- if (state.activeTabId === null) {
583
- state.activeTabId = tabs.find((tab) => tab.active)?.id ?? initialTab?.id ?? tabs[0]?.id ?? null;
584
- }
585
- repairActions.push(created ? "created-window" : "recreated-window");
566
+ const sharedWindow = await this.findSharedBindingWindow(bindingId);
567
+ if (sharedWindow) {
568
+ state.windowId = sharedWindow.id;
569
+ state.groupId = null;
570
+ state.tabIds = [];
571
+ state.activeTabId = null;
572
+ state.primaryTabId = null;
573
+ window2 = sharedWindow;
574
+ repairActions.push("attached-shared-window");
575
+ } else {
576
+ const createdWindow = await this.browser.createWindow({
577
+ url: initialUrl,
578
+ focused: options.focus === true
579
+ });
580
+ state.windowId = createdWindow.id;
581
+ state.groupId = null;
582
+ state.tabIds = [];
583
+ state.activeTabId = null;
584
+ state.primaryTabId = null;
585
+ window2 = createdWindow;
586
+ const initialTab = typeof createdWindow.initialTabId === "number" ? await this.waitForTrackedTab(createdWindow.initialTabId, createdWindow.id) : null;
587
+ tabs = initialTab ? [initialTab] : await this.waitForWindowTabs(createdWindow.id);
588
+ state.tabIds = tabs.map((tab) => tab.id);
589
+ if (state.primaryTabId === null) {
590
+ state.primaryTabId = initialTab?.id ?? tabs[0]?.id ?? null;
591
+ }
592
+ if (state.activeTabId === null) {
593
+ state.activeTabId = tabs.find((tab) => tab.active)?.id ?? initialTab?.id ?? tabs[0]?.id ?? null;
594
+ }
595
+ repairActions.push(created ? "created-window" : "recreated-window");
596
+ }
586
597
  }
587
598
  tabs = tabs.length > 0 ? tabs : await this.readTrackedTabs(state.tabIds, state.windowId);
588
599
  const recoveredTabs = await this.recoverBindingTabs(state, tabs);
@@ -596,12 +607,12 @@
596
607
  state.tabIds = tabs.map((tab) => tab.id);
597
608
  if (state.windowId !== null) {
598
609
  const ownership = await this.inspectBindingWindowOwnership(state, state.windowId);
599
- if (ownership.foreignTabs.length > 0) {
600
- const migrated = await this.moveBindingIntoDedicatedWindow(state, ownership, initialUrl);
610
+ if (ownership.foreignTabs.length > 0 && ownership.bindingTabs.length > 0) {
611
+ const migrated = await this.moveBindingIntoBakWindow(state, ownership, initialUrl);
601
612
  window2 = migrated.window;
602
613
  tabs = migrated.tabs;
603
614
  state.tabIds = tabs.map((tab) => tab.id);
604
- repairActions.push("migrated-dirty-window");
615
+ repairActions.push("evacuated-foreign-window");
605
616
  }
606
617
  }
607
618
  if (tabs.length === 0) {
@@ -698,7 +709,7 @@
698
709
  const desiredUrl = options.url ?? DEFAULT_SESSION_BINDING_URL;
699
710
  let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
700
711
  state,
701
- ensured.created || ensured.repairActions.includes("recreated-window") || ensured.repairActions.includes("created-primary-tab") || ensured.repairActions.includes("migrated-dirty-window")
712
+ ensured.created || ensured.repairActions.includes("recreated-window") || ensured.repairActions.includes("created-primary-tab")
702
713
  );
703
714
  let createdTab;
704
715
  try {
@@ -831,29 +842,32 @@
831
842
  return { ok: true, binding: refreshed.binding };
832
843
  }
833
844
  async closeTab(bindingId, tabId) {
834
- const ensured = await this.ensureBinding({ bindingId, focus: false });
835
- const resolvedTabId = typeof tabId === "number" ? tabId : ensured.binding.activeTabId ?? ensured.binding.primaryTabId ?? ensured.binding.tabs[0]?.id;
836
- if (typeof resolvedTabId !== "number" || !ensured.binding.tabIds.includes(resolvedTabId)) {
845
+ const binding = await this.inspectBinding(bindingId);
846
+ if (!binding) {
847
+ throw new Error(`Binding ${bindingId} does not exist`);
848
+ }
849
+ const resolvedTabId = typeof tabId === "number" ? tabId : binding.activeTabId ?? binding.primaryTabId ?? binding.tabs[0]?.id;
850
+ if (typeof resolvedTabId !== "number" || !binding.tabIds.includes(resolvedTabId)) {
837
851
  throw new Error(`Tab ${tabId ?? "active"} does not belong to binding ${bindingId}`);
838
852
  }
839
853
  await this.browser.closeTab(resolvedTabId);
840
- const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
854
+ const remainingTabIds = binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
841
855
  if (remainingTabIds.length === 0) {
842
- await this.storage.delete(ensured.binding.id);
856
+ await this.storage.delete(binding.id);
843
857
  return {
844
858
  binding: null,
845
859
  closedTabId: resolvedTabId
846
860
  };
847
861
  }
848
862
  const tabs = await this.readLooseTrackedTabs(remainingTabIds);
849
- const nextPrimaryTabId = ensured.binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : ensured.binding.primaryTabId;
850
- const nextActiveTabId = ensured.binding.activeTabId === resolvedTabId ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null : ensured.binding.activeTabId;
863
+ const nextPrimaryTabId = binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
864
+ const nextActiveTabId = binding.activeTabId === resolvedTabId ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null : binding.activeTabId;
851
865
  const nextState = {
852
- id: ensured.binding.id,
853
- label: ensured.binding.label,
854
- color: ensured.binding.color,
855
- windowId: tabs[0]?.windowId ?? ensured.binding.windowId,
856
- groupId: tabs[0]?.groupId ?? ensured.binding.groupId,
866
+ id: binding.id,
867
+ label: binding.label,
868
+ color: binding.color,
869
+ windowId: tabs[0]?.windowId ?? binding.windowId,
870
+ groupId: tabs[0]?.groupId ?? binding.groupId,
857
871
  tabIds: tabs.map((candidate) => candidate.id),
858
872
  activeTabId: nextActiveTabId,
859
873
  primaryTabId: nextPrimaryTabId
@@ -882,7 +896,7 @@
882
896
  return { ok: true };
883
897
  }
884
898
  await this.storage.delete(bindingId);
885
- const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
899
+ const trackedTabs = await this.collectBindingTabsForClose(state);
886
900
  for (const trackedTab of trackedTabs) {
887
901
  try {
888
902
  await this.browser.closeTab(trackedTab.id);
@@ -1069,24 +1083,59 @@
1069
1083
  }
1070
1084
  async inspectBindingWindowOwnership(state, windowId) {
1071
1085
  const windowTabs = await this.waitForWindowTabs(windowId, 500);
1072
- const trackedIds = new Set(this.collectCandidateTabIds(state));
1086
+ const bindingTabIds = new Set(this.collectCandidateTabIds(state));
1087
+ const peerBindings = (await this.storage.list()).filter((candidate) => candidate.id !== state.id);
1088
+ const peerTabIds = /* @__PURE__ */ new Set();
1089
+ const peerGroupIds = /* @__PURE__ */ new Set();
1090
+ for (const peer of peerBindings) {
1091
+ for (const tabId of this.collectCandidateTabIds(peer)) {
1092
+ peerTabIds.add(tabId);
1093
+ }
1094
+ if (peer.groupId !== null) {
1095
+ peerGroupIds.add(peer.groupId);
1096
+ }
1097
+ }
1098
+ const bindingTabs = [];
1099
+ const sharedBindingTabs = [];
1100
+ const foreignTabs = [];
1101
+ for (const tab of windowTabs) {
1102
+ if (bindingTabIds.has(tab.id) || state.groupId !== null && tab.groupId === state.groupId) {
1103
+ bindingTabs.push(tab);
1104
+ continue;
1105
+ }
1106
+ if (peerTabIds.has(tab.id) || tab.groupId !== null && peerGroupIds.has(tab.groupId)) {
1107
+ sharedBindingTabs.push(tab);
1108
+ continue;
1109
+ }
1110
+ foreignTabs.push(tab);
1111
+ }
1073
1112
  return {
1074
- bindingTabs: windowTabs.filter((tab) => trackedIds.has(tab.id) || state.groupId !== null && tab.groupId === state.groupId),
1075
- foreignTabs: windowTabs.filter((tab) => !trackedIds.has(tab.id) && (state.groupId === null || tab.groupId !== state.groupId))
1113
+ bindingTabs,
1114
+ sharedBindingTabs,
1115
+ foreignTabs
1076
1116
  };
1077
1117
  }
1078
- async moveBindingIntoDedicatedWindow(state, ownership, initialUrl) {
1118
+ async moveBindingIntoBakWindow(state, ownership, initialUrl) {
1079
1119
  const sourceTabs = this.orderSessionBindingTabsForMigration(state, ownership.bindingTabs);
1080
1120
  const seedUrl = sourceTabs[0]?.url ?? initialUrl;
1081
- const window2 = await this.browser.createWindow({
1121
+ const sharedWindow = await this.findSharedBindingWindow(state.id, state.windowId === null ? [] : [state.windowId]);
1122
+ const window2 = sharedWindow ?? await this.browser.createWindow({
1082
1123
  url: seedUrl || DEFAULT_SESSION_BINDING_URL,
1083
1124
  focused: false
1084
1125
  });
1085
- const initialTab = typeof window2.initialTabId === "number" ? await this.waitForTrackedTab(window2.initialTabId, window2.id) : null;
1086
- const recreatedTabs = initialTab ? [initialTab] : await this.waitForWindowTabs(window2.id);
1087
- const firstTab = recreatedTabs[0] ?? null;
1126
+ const initialTab = sharedWindow || typeof window2.initialTabId !== "number" ? null : await this.waitForTrackedTab(window2.initialTabId, window2.id);
1127
+ const recreatedTabs = [];
1088
1128
  const tabIdMap = /* @__PURE__ */ new Map();
1089
- if (sourceTabs[0] && firstTab) {
1129
+ if (sourceTabs[0]) {
1130
+ const firstTab = initialTab ? await this.browser.updateTab(initialTab.id, {
1131
+ url: sourceTabs[0].url,
1132
+ active: false
1133
+ }) : await this.createBindingTab({
1134
+ windowId: window2.id,
1135
+ url: sourceTabs[0].url,
1136
+ active: false
1137
+ });
1138
+ recreatedTabs.push(firstTab);
1090
1139
  tabIdMap.set(sourceTabs[0].id, firstTab.id);
1091
1140
  }
1092
1141
  for (const sourceTab of sourceTabs.slice(1)) {
@@ -1098,7 +1147,7 @@
1098
1147
  recreatedTabs.push(recreated);
1099
1148
  tabIdMap.set(sourceTab.id, recreated.id);
1100
1149
  }
1101
- const nextPrimaryTabId = (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : void 0) ?? firstTab?.id ?? recreatedTabs[0]?.id ?? null;
1150
+ const nextPrimaryTabId = (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : void 0) ?? recreatedTabs[0]?.id ?? null;
1102
1151
  const nextActiveTabId = (state.activeTabId !== null ? tabIdMap.get(state.activeTabId) : void 0) ?? nextPrimaryTabId ?? recreatedTabs[0]?.id ?? null;
1103
1152
  if (nextActiveTabId !== null) {
1104
1153
  await this.browser.updateTab(nextActiveTabId, { active: true });
@@ -1113,7 +1162,10 @@
1113
1162
  tabIds: [...state.tabIds]
1114
1163
  });
1115
1164
  for (const bindingTab of ownership.bindingTabs) {
1116
- await this.browser.closeTab(bindingTab.id);
1165
+ try {
1166
+ await this.browser.closeTab(bindingTab.id);
1167
+ } catch {
1168
+ }
1117
1169
  }
1118
1170
  return {
1119
1171
  window: window2,
@@ -1145,6 +1197,57 @@
1145
1197
  }
1146
1198
  return ordered;
1147
1199
  }
1200
+ async findSharedBindingWindow(bindingId, excludedWindowIds = []) {
1201
+ const peers = (await this.storage.list()).filter((candidate) => candidate.id !== bindingId);
1202
+ const candidateWindowIds = [];
1203
+ const peerTabIds = /* @__PURE__ */ new Set();
1204
+ const peerGroupIds = /* @__PURE__ */ new Set();
1205
+ const pushWindowId = (windowId) => {
1206
+ if (typeof windowId !== "number") {
1207
+ return;
1208
+ }
1209
+ if (excludedWindowIds.includes(windowId) || candidateWindowIds.includes(windowId)) {
1210
+ return;
1211
+ }
1212
+ candidateWindowIds.push(windowId);
1213
+ };
1214
+ for (const peer of peers) {
1215
+ pushWindowId(peer.windowId);
1216
+ if (peer.groupId !== null) {
1217
+ peerGroupIds.add(peer.groupId);
1218
+ const group = await this.waitForGroup(peer.groupId, 300);
1219
+ pushWindowId(group?.windowId);
1220
+ }
1221
+ const trackedTabIds = this.collectCandidateTabIds(peer);
1222
+ for (const trackedTabId of trackedTabIds) {
1223
+ peerTabIds.add(trackedTabId);
1224
+ }
1225
+ const trackedTabs = await this.readLooseTrackedTabs(trackedTabIds);
1226
+ for (const tab of trackedTabs) {
1227
+ pushWindowId(tab.windowId);
1228
+ }
1229
+ }
1230
+ for (const windowId of candidateWindowIds) {
1231
+ const window2 = await this.waitForWindow(windowId, 300);
1232
+ if (window2) {
1233
+ const windowTabs = await this.waitForWindowTabs(window2.id, 300);
1234
+ const ownedTabs = windowTabs.filter(
1235
+ (tab) => peerTabIds.has(tab.id) || tab.groupId !== null && peerGroupIds.has(tab.groupId)
1236
+ );
1237
+ if (ownedTabs.length === 0) {
1238
+ continue;
1239
+ }
1240
+ const foreignTabs = windowTabs.filter(
1241
+ (tab) => !peerTabIds.has(tab.id) && (tab.groupId === null || !peerGroupIds.has(tab.groupId))
1242
+ );
1243
+ if (foreignTabs.length > 0) {
1244
+ continue;
1245
+ }
1246
+ return window2;
1247
+ }
1248
+ }
1249
+ return null;
1250
+ }
1148
1251
  async recoverBindingTabs(state, existingTabs) {
1149
1252
  if (state.windowId === null) {
1150
1253
  return existingTabs;
@@ -1171,6 +1274,19 @@
1171
1274
  }
1172
1275
  return existingTabs;
1173
1276
  }
1277
+ async collectBindingTabsForClose(state) {
1278
+ const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
1279
+ if (state.windowId === null || state.groupId === null) {
1280
+ return trackedTabs;
1281
+ }
1282
+ const windowTabs = await this.waitForWindowTabs(state.windowId, 300);
1283
+ const groupedTabs = windowTabs.filter((tab) => tab.groupId === state.groupId);
1284
+ const merged = /* @__PURE__ */ new Map();
1285
+ for (const tab of [...trackedTabs, ...groupedTabs]) {
1286
+ merged.set(tab.id, tab);
1287
+ }
1288
+ return [...merged.values()];
1289
+ }
1174
1290
  async createBindingTab(options) {
1175
1291
  if (options.windowId === null) {
1176
1292
  throw new Error("Binding window is unavailable");
@@ -1200,6 +1316,7 @@
1200
1316
  return null;
1201
1317
  }
1202
1318
  let tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
1319
+ tabs = await this.recoverBindingTabs(state, tabs);
1203
1320
  const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId, 300) : null;
1204
1321
  if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
1205
1322
  tabs = [...tabs, activeTab];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Agent Kit",
4
- "version": "0.6.7",
4
+ "version": "0.6.8",
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.7",
3
+ "version": "0.6.8",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@flrande/bak-protocol": "0.6.7"
6
+ "@flrande/bak-protocol": "0.6.8"
7
7
  },
8
8
  "devDependencies": {
9
9
  "@types/chrome": "^0.1.14",
@@ -68,8 +68,8 @@ export interface SessionBindingStorage {
68
68
  list(): Promise<SessionBindingRecord[]>;
69
69
  }
70
70
 
71
- export interface SessionBindingBrowser {
72
- getTab(tabId: number): Promise<SessionBindingTab | null>;
71
+ export interface SessionBindingBrowser {
72
+ getTab(tabId: number): Promise<SessionBindingTab | null>;
73
73
  getActiveTab(): Promise<SessionBindingTab | null>;
74
74
  listTabs(filter?: { windowId?: number }): Promise<SessionBindingTab[]>;
75
75
  createTab(options: { windowId?: number; url?: string; active?: boolean }): Promise<SessionBindingTab>;
@@ -81,14 +81,15 @@ export interface SessionBindingBrowser {
81
81
  closeWindow(windowId: number): Promise<void>;
82
82
  getGroup(groupId: number): Promise<SessionBindingGroup | null>;
83
83
  groupTabs(tabIds: number[], groupId?: number): Promise<number>;
84
- updateGroup(groupId: number, options: { title?: string; color?: SessionBindingColor; collapsed?: boolean }): Promise<SessionBindingGroup>;
85
- }
86
-
87
- interface SessionBindingWindowOwnership {
88
- bindingTabs: SessionBindingTab[];
89
- foreignTabs: SessionBindingTab[];
90
- }
91
-
84
+ updateGroup(groupId: number, options: { title?: string; color?: SessionBindingColor; collapsed?: boolean }): Promise<SessionBindingGroup>;
85
+ }
86
+
87
+ interface SessionBindingWindowOwnership {
88
+ bindingTabs: SessionBindingTab[];
89
+ sharedBindingTabs: SessionBindingTab[];
90
+ foreignTabs: SessionBindingTab[];
91
+ }
92
+
92
93
  export interface SessionBindingEnsureOptions {
93
94
  bindingId?: string;
94
95
  focus?: boolean;
@@ -145,54 +146,65 @@ class SessionBindingManager {
145
146
  }
146
147
  }
147
148
  if (!window) {
148
- const createdWindow = await this.browser.createWindow({
149
- url: initialUrl,
150
- focused: options.focus === true
151
- });
152
- state.windowId = createdWindow.id;
153
- state.groupId = null;
154
- state.tabIds = [];
155
- state.activeTabId = null;
156
- state.primaryTabId = null;
157
- window = createdWindow;
158
- const initialTab =
159
- typeof createdWindow.initialTabId === 'number'
160
- ? await this.waitForTrackedTab(createdWindow.initialTabId, createdWindow.id)
161
- : null;
162
- tabs = initialTab ? [initialTab] : await this.waitForWindowTabs(createdWindow.id);
163
- state.tabIds = tabs.map((tab) => tab.id);
164
- if (state.primaryTabId === null) {
165
- state.primaryTabId = initialTab?.id ?? tabs[0]?.id ?? null;
166
- }
167
- if (state.activeTabId === null) {
168
- state.activeTabId = tabs.find((tab) => tab.active)?.id ?? initialTab?.id ?? tabs[0]?.id ?? null;
149
+ const sharedWindow = await this.findSharedBindingWindow(bindingId);
150
+ if (sharedWindow) {
151
+ state.windowId = sharedWindow.id;
152
+ state.groupId = null;
153
+ state.tabIds = [];
154
+ state.activeTabId = null;
155
+ state.primaryTabId = null;
156
+ window = sharedWindow;
157
+ repairActions.push('attached-shared-window');
158
+ } else {
159
+ const createdWindow = await this.browser.createWindow({
160
+ url: initialUrl,
161
+ focused: options.focus === true
162
+ });
163
+ state.windowId = createdWindow.id;
164
+ state.groupId = null;
165
+ state.tabIds = [];
166
+ state.activeTabId = null;
167
+ state.primaryTabId = null;
168
+ window = createdWindow;
169
+ const initialTab =
170
+ typeof createdWindow.initialTabId === 'number'
171
+ ? await this.waitForTrackedTab(createdWindow.initialTabId, createdWindow.id)
172
+ : null;
173
+ tabs = initialTab ? [initialTab] : await this.waitForWindowTabs(createdWindow.id);
174
+ state.tabIds = tabs.map((tab) => tab.id);
175
+ if (state.primaryTabId === null) {
176
+ state.primaryTabId = initialTab?.id ?? tabs[0]?.id ?? null;
177
+ }
178
+ if (state.activeTabId === null) {
179
+ state.activeTabId = tabs.find((tab) => tab.active)?.id ?? initialTab?.id ?? tabs[0]?.id ?? null;
180
+ }
181
+ repairActions.push(created ? 'created-window' : 'recreated-window');
169
182
  }
170
- repairActions.push(created ? 'created-window' : 'recreated-window');
171
183
  }
172
-
173
- tabs = tabs.length > 0 ? tabs : await this.readTrackedTabs(state.tabIds, state.windowId);
174
- const recoveredTabs = await this.recoverBindingTabs(state, tabs);
175
- if (recoveredTabs.length > tabs.length) {
184
+
185
+ tabs = tabs.length > 0 ? tabs : await this.readTrackedTabs(state.tabIds, state.windowId);
186
+ const recoveredTabs = await this.recoverBindingTabs(state, tabs);
187
+ if (recoveredTabs.length > tabs.length) {
176
188
  tabs = recoveredTabs;
177
189
  repairActions.push('recovered-tracked-tabs');
178
190
  }
179
191
  if (tabs.length !== state.tabIds.length) {
180
192
  repairActions.push('pruned-missing-tabs');
181
- }
182
- state.tabIds = tabs.map((tab) => tab.id);
183
-
184
- if (state.windowId !== null) {
185
- const ownership = await this.inspectBindingWindowOwnership(state, state.windowId);
186
- if (ownership.foreignTabs.length > 0) {
187
- const migrated = await this.moveBindingIntoDedicatedWindow(state, ownership, initialUrl);
188
- window = migrated.window;
189
- tabs = migrated.tabs;
190
- state.tabIds = tabs.map((tab) => tab.id);
191
- repairActions.push('migrated-dirty-window');
192
- }
193
- }
194
-
195
- if (tabs.length === 0) {
193
+ }
194
+ state.tabIds = tabs.map((tab) => tab.id);
195
+
196
+ if (state.windowId !== null) {
197
+ const ownership = await this.inspectBindingWindowOwnership(state, state.windowId);
198
+ if (ownership.foreignTabs.length > 0 && ownership.bindingTabs.length > 0) {
199
+ const migrated = await this.moveBindingIntoBakWindow(state, ownership, initialUrl);
200
+ window = migrated.window;
201
+ tabs = migrated.tabs;
202
+ state.tabIds = tabs.map((tab) => tab.id);
203
+ repairActions.push('evacuated-foreign-window');
204
+ }
205
+ }
206
+
207
+ if (tabs.length === 0) {
196
208
  const primary = await this.createBindingTab({
197
209
  windowId: state.windowId,
198
210
  url: initialUrl,
@@ -293,13 +305,12 @@ class SessionBindingManager {
293
305
  }
294
306
  const active = options.active === true;
295
307
  const desiredUrl = options.url ?? DEFAULT_SESSION_BINDING_URL;
296
- let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
297
- state,
298
- ensured.created ||
299
- ensured.repairActions.includes('recreated-window') ||
300
- ensured.repairActions.includes('created-primary-tab') ||
301
- ensured.repairActions.includes('migrated-dirty-window')
302
- );
308
+ let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
309
+ state,
310
+ ensured.created ||
311
+ ensured.repairActions.includes('recreated-window') ||
312
+ ensured.repairActions.includes('created-primary-tab')
313
+ );
303
314
 
304
315
  let createdTab: SessionBindingTab;
305
316
  try {
@@ -443,20 +454,23 @@ class SessionBindingManager {
443
454
  }
444
455
 
445
456
  async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
446
- const ensured = await this.ensureBinding({ bindingId, focus: false });
457
+ const binding = await this.inspectBinding(bindingId);
458
+ if (!binding) {
459
+ throw new Error(`Binding ${bindingId} does not exist`);
460
+ }
447
461
  const resolvedTabId =
448
462
  typeof tabId === 'number'
449
463
  ? tabId
450
- : ensured.binding.activeTabId ?? ensured.binding.primaryTabId ?? ensured.binding.tabs[0]?.id;
451
- if (typeof resolvedTabId !== 'number' || !ensured.binding.tabIds.includes(resolvedTabId)) {
464
+ : binding.activeTabId ?? binding.primaryTabId ?? binding.tabs[0]?.id;
465
+ if (typeof resolvedTabId !== 'number' || !binding.tabIds.includes(resolvedTabId)) {
452
466
  throw new Error(`Tab ${tabId ?? 'active'} does not belong to binding ${bindingId}`);
453
467
  }
454
468
 
455
469
  await this.browser.closeTab(resolvedTabId);
456
- const remainingTabIds = ensured.binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
470
+ const remainingTabIds = binding.tabIds.filter((candidate) => candidate !== resolvedTabId);
457
471
 
458
472
  if (remainingTabIds.length === 0) {
459
- await this.storage.delete(ensured.binding.id);
473
+ await this.storage.delete(binding.id);
460
474
  return {
461
475
  binding: null,
462
476
  closedTabId: resolvedTabId
@@ -465,17 +479,17 @@ class SessionBindingManager {
465
479
 
466
480
  const tabs = await this.readLooseTrackedTabs(remainingTabIds);
467
481
  const nextPrimaryTabId =
468
- ensured.binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : ensured.binding.primaryTabId;
482
+ binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
469
483
  const nextActiveTabId =
470
- ensured.binding.activeTabId === resolvedTabId
484
+ binding.activeTabId === resolvedTabId
471
485
  ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
472
- : ensured.binding.activeTabId;
486
+ : binding.activeTabId;
473
487
  const nextState: SessionBindingRecord = {
474
- id: ensured.binding.id,
475
- label: ensured.binding.label,
476
- color: ensured.binding.color,
477
- windowId: tabs[0]?.windowId ?? ensured.binding.windowId,
478
- groupId: tabs[0]?.groupId ?? ensured.binding.groupId,
488
+ id: binding.id,
489
+ label: binding.label,
490
+ color: binding.color,
491
+ windowId: tabs[0]?.windowId ?? binding.windowId,
492
+ groupId: tabs[0]?.groupId ?? binding.groupId,
479
493
  tabIds: tabs.map((candidate) => candidate.id),
480
494
  activeTabId: nextActiveTabId,
481
495
  primaryTabId: nextPrimaryTabId
@@ -508,7 +522,7 @@ class SessionBindingManager {
508
522
  // Clear persisted state before closing the window so tab/window removal
509
523
  // listeners cannot race and resurrect an empty binding record.
510
524
  await this.storage.delete(bindingId);
511
- const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
525
+ const trackedTabs = await this.collectBindingTabsForClose(state);
512
526
  for (const trackedTab of trackedTabs) {
513
527
  try {
514
528
  await this.browser.closeTab(trackedTab.id);
@@ -673,7 +687,7 @@ class SessionBindingManager {
673
687
  return [...new Set(state.tabIds.concat([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number')))];
674
688
  }
675
689
 
676
- private async rebindBindingWindow(state: SessionBindingRecord): Promise<{ window: SessionBindingWindow; tabs: SessionBindingTab[] } | null> {
690
+ private async rebindBindingWindow(state: SessionBindingRecord): Promise<{ window: SessionBindingWindow; tabs: SessionBindingTab[] } | null> {
677
691
  const candidateWindowIds: number[] = [];
678
692
  const pushWindowId = (windowId: number | null | undefined): void => {
679
693
  if (typeof windowId !== 'number') {
@@ -718,59 +732,100 @@ class SessionBindingManager {
718
732
  return { window, tabs };
719
733
  }
720
734
 
721
- return null;
722
- }
723
-
724
- private async inspectBindingWindowOwnership(state: SessionBindingRecord, windowId: number): Promise<SessionBindingWindowOwnership> {
725
- const windowTabs = await this.waitForWindowTabs(windowId, 500);
726
- const trackedIds = new Set(this.collectCandidateTabIds(state));
727
- return {
728
- bindingTabs: windowTabs.filter((tab) => trackedIds.has(tab.id) || (state.groupId !== null && tab.groupId === state.groupId)),
729
- foreignTabs: windowTabs.filter((tab) => !trackedIds.has(tab.id) && (state.groupId === null || tab.groupId !== state.groupId))
730
- };
731
- }
732
-
733
- private async moveBindingIntoDedicatedWindow(
735
+ return null;
736
+ }
737
+
738
+ private async inspectBindingWindowOwnership(state: SessionBindingRecord, windowId: number): Promise<SessionBindingWindowOwnership> {
739
+ const windowTabs = await this.waitForWindowTabs(windowId, 500);
740
+ const bindingTabIds = new Set(this.collectCandidateTabIds(state));
741
+ const peerBindings = (await this.storage.list()).filter((candidate) => candidate.id !== state.id);
742
+ const peerTabIds = new Set<number>();
743
+ const peerGroupIds = new Set<number>();
744
+ for (const peer of peerBindings) {
745
+ for (const tabId of this.collectCandidateTabIds(peer)) {
746
+ peerTabIds.add(tabId);
747
+ }
748
+ if (peer.groupId !== null) {
749
+ peerGroupIds.add(peer.groupId);
750
+ }
751
+ }
752
+
753
+ const bindingTabs: SessionBindingTab[] = [];
754
+ const sharedBindingTabs: SessionBindingTab[] = [];
755
+ const foreignTabs: SessionBindingTab[] = [];
756
+ for (const tab of windowTabs) {
757
+ if (bindingTabIds.has(tab.id) || (state.groupId !== null && tab.groupId === state.groupId)) {
758
+ bindingTabs.push(tab);
759
+ continue;
760
+ }
761
+ if (peerTabIds.has(tab.id) || (tab.groupId !== null && peerGroupIds.has(tab.groupId))) {
762
+ sharedBindingTabs.push(tab);
763
+ continue;
764
+ }
765
+ foreignTabs.push(tab);
766
+ }
767
+
768
+ return {
769
+ bindingTabs,
770
+ sharedBindingTabs,
771
+ foreignTabs
772
+ };
773
+ }
774
+
775
+ private async moveBindingIntoBakWindow(
734
776
  state: SessionBindingRecord,
735
777
  ownership: SessionBindingWindowOwnership,
736
778
  initialUrl: string
737
779
  ): Promise<{ window: SessionBindingWindow; tabs: SessionBindingTab[] }> {
738
- const sourceTabs = this.orderSessionBindingTabsForMigration(state, ownership.bindingTabs);
739
- const seedUrl = sourceTabs[0]?.url ?? initialUrl;
740
- const window = await this.browser.createWindow({
741
- url: seedUrl || DEFAULT_SESSION_BINDING_URL,
742
- focused: false
743
- });
780
+ const sourceTabs = this.orderSessionBindingTabsForMigration(state, ownership.bindingTabs);
781
+ const seedUrl = sourceTabs[0]?.url ?? initialUrl;
782
+ const sharedWindow = await this.findSharedBindingWindow(state.id, state.windowId === null ? [] : [state.windowId]);
783
+ const window =
784
+ sharedWindow ??
785
+ (await this.browser.createWindow({
786
+ url: seedUrl || DEFAULT_SESSION_BINDING_URL,
787
+ focused: false
788
+ }));
744
789
  const initialTab =
745
- typeof window.initialTabId === 'number' ? await this.waitForTrackedTab(window.initialTabId, window.id) : null;
746
- const recreatedTabs = initialTab ? [initialTab] : await this.waitForWindowTabs(window.id);
747
- const firstTab = recreatedTabs[0] ?? null;
748
- const tabIdMap = new Map<number, number>();
749
- if (sourceTabs[0] && firstTab) {
750
- tabIdMap.set(sourceTabs[0].id, firstTab.id);
751
- }
752
-
753
- for (const sourceTab of sourceTabs.slice(1)) {
754
- const recreated = await this.createBindingTab({
755
- windowId: window.id,
756
- url: sourceTab.url,
757
- active: false
758
- });
759
- recreatedTabs.push(recreated);
760
- tabIdMap.set(sourceTab.id, recreated.id);
761
- }
762
-
763
- const nextPrimaryTabId =
764
- (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : undefined) ??
765
- firstTab?.id ??
766
- recreatedTabs[0]?.id ??
767
- null;
768
- const nextActiveTabId =
769
- (state.activeTabId !== null ? tabIdMap.get(state.activeTabId) : undefined) ?? nextPrimaryTabId ?? recreatedTabs[0]?.id ?? null;
770
- if (nextActiveTabId !== null) {
771
- await this.browser.updateTab(nextActiveTabId, { active: true });
772
- }
773
-
790
+ sharedWindow || typeof window.initialTabId !== 'number' ? null : await this.waitForTrackedTab(window.initialTabId, window.id);
791
+ const recreatedTabs: SessionBindingTab[] = [];
792
+ const tabIdMap = new Map<number, number>();
793
+
794
+ if (sourceTabs[0]) {
795
+ const firstTab = initialTab
796
+ ? await this.browser.updateTab(initialTab.id, {
797
+ url: sourceTabs[0].url,
798
+ active: false
799
+ })
800
+ : await this.createBindingTab({
801
+ windowId: window.id,
802
+ url: sourceTabs[0].url,
803
+ active: false
804
+ });
805
+ recreatedTabs.push(firstTab);
806
+ tabIdMap.set(sourceTabs[0].id, firstTab.id);
807
+ }
808
+
809
+ for (const sourceTab of sourceTabs.slice(1)) {
810
+ const recreated = await this.createBindingTab({
811
+ windowId: window.id,
812
+ url: sourceTab.url,
813
+ active: false
814
+ });
815
+ recreatedTabs.push(recreated);
816
+ tabIdMap.set(sourceTab.id, recreated.id);
817
+ }
818
+
819
+ const nextPrimaryTabId =
820
+ (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : undefined) ??
821
+ recreatedTabs[0]?.id ??
822
+ null;
823
+ const nextActiveTabId =
824
+ (state.activeTabId !== null ? tabIdMap.get(state.activeTabId) : undefined) ?? nextPrimaryTabId ?? recreatedTabs[0]?.id ?? null;
825
+ if (nextActiveTabId !== null) {
826
+ await this.browser.updateTab(nextActiveTabId, { active: true });
827
+ }
828
+
774
829
  state.windowId = window.id;
775
830
  state.groupId = null;
776
831
  state.tabIds = recreatedTabs.map((tab) => tab.id);
@@ -782,43 +837,102 @@ class SessionBindingManager {
782
837
  });
783
838
 
784
839
  for (const bindingTab of ownership.bindingTabs) {
785
- await this.browser.closeTab(bindingTab.id);
840
+ try {
841
+ await this.browser.closeTab(bindingTab.id);
842
+ } catch {
843
+ // Ignore tabs that were already removed while evacuating the binding.
844
+ }
786
845
  }
846
+
847
+ return {
848
+ window,
849
+ tabs: await this.readTrackedTabs(state.tabIds, state.windowId)
850
+ };
851
+ }
852
+
853
+ private orderSessionBindingTabsForMigration(state: SessionBindingRecord, tabs: SessionBindingTab[]): SessionBindingTab[] {
854
+ const ordered: SessionBindingTab[] = [];
855
+ const seen = new Set<number>();
856
+ const pushById = (tabId: number | null): void => {
857
+ if (typeof tabId !== 'number') {
858
+ return;
859
+ }
860
+ const tab = tabs.find((candidate) => candidate.id === tabId);
861
+ if (!tab || seen.has(tab.id)) {
862
+ return;
863
+ }
864
+ ordered.push(tab);
865
+ seen.add(tab.id);
866
+ };
867
+
868
+ pushById(state.primaryTabId);
869
+ pushById(state.activeTabId);
870
+ for (const tab of tabs) {
871
+ if (seen.has(tab.id)) {
872
+ continue;
873
+ }
874
+ ordered.push(tab);
875
+ seen.add(tab.id);
876
+ }
877
+ return ordered;
878
+ }
879
+
880
+ private async findSharedBindingWindow(bindingId: string, excludedWindowIds: number[] = []): Promise<SessionBindingWindow | null> {
881
+ const peers = (await this.storage.list()).filter((candidate) => candidate.id !== bindingId);
882
+ const candidateWindowIds: number[] = [];
883
+ const peerTabIds = new Set<number>();
884
+ const peerGroupIds = new Set<number>();
885
+ const pushWindowId = (windowId: number | null | undefined): void => {
886
+ if (typeof windowId !== 'number') {
887
+ return;
888
+ }
889
+ if (excludedWindowIds.includes(windowId) || candidateWindowIds.includes(windowId)) {
890
+ return;
891
+ }
892
+ candidateWindowIds.push(windowId);
893
+ };
894
+
895
+ for (const peer of peers) {
896
+ pushWindowId(peer.windowId);
897
+ if (peer.groupId !== null) {
898
+ peerGroupIds.add(peer.groupId);
899
+ const group = await this.waitForGroup(peer.groupId, 300);
900
+ pushWindowId(group?.windowId);
901
+ }
902
+ const trackedTabIds = this.collectCandidateTabIds(peer);
903
+ for (const trackedTabId of trackedTabIds) {
904
+ peerTabIds.add(trackedTabId);
905
+ }
906
+ const trackedTabs = await this.readLooseTrackedTabs(trackedTabIds);
907
+ for (const tab of trackedTabs) {
908
+ pushWindowId(tab.windowId);
909
+ }
910
+ }
911
+
912
+ for (const windowId of candidateWindowIds) {
913
+ const window = await this.waitForWindow(windowId, 300);
914
+ if (window) {
915
+ const windowTabs = await this.waitForWindowTabs(window.id, 300);
916
+ const ownedTabs = windowTabs.filter(
917
+ (tab) => peerTabIds.has(tab.id) || (tab.groupId !== null && peerGroupIds.has(tab.groupId))
918
+ );
919
+ if (ownedTabs.length === 0) {
920
+ continue;
921
+ }
922
+ const foreignTabs = windowTabs.filter(
923
+ (tab) => !peerTabIds.has(tab.id) && (tab.groupId === null || !peerGroupIds.has(tab.groupId))
924
+ );
925
+ if (foreignTabs.length > 0) {
926
+ continue;
927
+ }
928
+ return window;
929
+ }
930
+ }
931
+
932
+ return null;
933
+ }
787
934
 
788
- return {
789
- window,
790
- tabs: await this.readTrackedTabs(state.tabIds, state.windowId)
791
- };
792
- }
793
-
794
- private orderSessionBindingTabsForMigration(state: SessionBindingRecord, tabs: SessionBindingTab[]): SessionBindingTab[] {
795
- const ordered: SessionBindingTab[] = [];
796
- const seen = new Set<number>();
797
- const pushById = (tabId: number | null): void => {
798
- if (typeof tabId !== 'number') {
799
- return;
800
- }
801
- const tab = tabs.find((candidate) => candidate.id === tabId);
802
- if (!tab || seen.has(tab.id)) {
803
- return;
804
- }
805
- ordered.push(tab);
806
- seen.add(tab.id);
807
- };
808
-
809
- pushById(state.primaryTabId);
810
- pushById(state.activeTabId);
811
- for (const tab of tabs) {
812
- if (seen.has(tab.id)) {
813
- continue;
814
- }
815
- ordered.push(tab);
816
- seen.add(tab.id);
817
- }
818
- return ordered;
819
- }
820
-
821
- private async recoverBindingTabs(state: SessionBindingRecord, existingTabs: SessionBindingTab[]): Promise<SessionBindingTab[]> {
935
+ private async recoverBindingTabs(state: SessionBindingRecord, existingTabs: SessionBindingTab[]): Promise<SessionBindingTab[]> {
822
936
  if (state.windowId === null) {
823
937
  return existingTabs;
824
938
  }
@@ -847,8 +961,22 @@ class SessionBindingManager {
847
961
  return preferredTabs;
848
962
  }
849
963
 
850
- return existingTabs;
851
- }
964
+ return existingTabs;
965
+ }
966
+
967
+ private async collectBindingTabsForClose(state: SessionBindingRecord): Promise<SessionBindingTab[]> {
968
+ const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
969
+ if (state.windowId === null || state.groupId === null) {
970
+ return trackedTabs;
971
+ }
972
+ const windowTabs = await this.waitForWindowTabs(state.windowId, 300);
973
+ const groupedTabs = windowTabs.filter((tab) => tab.groupId === state.groupId);
974
+ const merged = new Map<number, SessionBindingTab>();
975
+ for (const tab of [...trackedTabs, ...groupedTabs]) {
976
+ merged.set(tab.id, tab);
977
+ }
978
+ return [...merged.values()];
979
+ }
852
980
 
853
981
  private async createBindingTab(options: { windowId: number | null; url: string; active: boolean }): Promise<SessionBindingTab> {
854
982
  if (options.windowId === null) {
@@ -877,17 +1005,18 @@ class SessionBindingManager {
877
1005
  throw lastError ?? new Error(`No window with id: ${options.windowId}.`);
878
1006
  }
879
1007
 
880
- private async inspectBinding(bindingId: string): Promise<SessionBindingInfo | null> {
881
- const state = await this.loadBindingRecord(bindingId);
882
- if (!state) {
883
- return null;
884
- }
885
-
886
- let tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
887
- const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId, 300) : null;
888
- if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
889
- tabs = [...tabs, activeTab];
890
- }
1008
+ private async inspectBinding(bindingId: string): Promise<SessionBindingInfo | null> {
1009
+ const state = await this.loadBindingRecord(bindingId);
1010
+ if (!state) {
1011
+ return null;
1012
+ }
1013
+
1014
+ let tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
1015
+ tabs = await this.recoverBindingTabs(state, tabs);
1016
+ const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId, 300) : null;
1017
+ if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
1018
+ tabs = [...tabs, activeTab];
1019
+ }
891
1020
  if (tabs.length === 0 && state.primaryTabId !== null) {
892
1021
  const primaryTab = await this.waitForTrackedTab(state.primaryTabId, state.windowId, 300);
893
1022
  if (primaryTab) {