@flrande/bak-extension 0.6.13 → 0.6.15

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-14T11:06:39.278Z
1
+ 2026-03-14T15:19:38.952Z
@@ -556,7 +556,7 @@
556
556
  // package.json
557
557
  var package_default = {
558
558
  name: "@flrande/bak-extension",
559
- version: "0.6.13",
559
+ version: "0.6.15",
560
560
  type: "module",
561
561
  scripts: {
562
562
  build: "tsup src/background.ts src/content.ts src/popup.ts --format iife --out-dir dist --clean && node scripts/copy-assets.mjs",
@@ -990,10 +990,16 @@
990
990
  return typeof candidate.id === "string" && Array.isArray(candidate.tabIds) && (typeof candidate.windowId === "number" || candidate.windowId === null) && (typeof candidate.groupId === "number" || candidate.groupId === null) && (typeof candidate.activeTabId === "number" || candidate.activeTabId === null) && (typeof candidate.primaryTabId === "number" || candidate.primaryTabId === null);
991
991
  }
992
992
  function cloneSessionBindingRecord(state) {
993
- return {
993
+ const cloned = {
994
994
  ...state,
995
995
  tabIds: [...state.tabIds]
996
996
  };
997
+ if (cloned.tabIds.length === 0) {
998
+ cloned.groupId = null;
999
+ cloned.activeTabId = null;
1000
+ cloned.primaryTabId = null;
1001
+ }
1002
+ return cloned;
997
1003
  }
998
1004
  function normalizeSessionBindingRecordMap(value) {
999
1005
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -1376,6 +1382,34 @@
1376
1382
  };
1377
1383
  }
1378
1384
  const tabs = await this.readLooseTrackedTabs(remainingTabIds);
1385
+ if (tabs.length === 0) {
1386
+ const liveWindow = binding.windowId !== null ? await this.waitForWindow(binding.windowId, 300) : null;
1387
+ if (!liveWindow) {
1388
+ await this.storage.delete(binding.id);
1389
+ return {
1390
+ binding: null,
1391
+ closedTabId: resolvedTabId
1392
+ };
1393
+ }
1394
+ const nextState2 = {
1395
+ id: binding.id,
1396
+ label: binding.label,
1397
+ color: binding.color,
1398
+ windowId: liveWindow.id,
1399
+ groupId: null,
1400
+ tabIds: [],
1401
+ activeTabId: null,
1402
+ primaryTabId: null
1403
+ };
1404
+ await this.storage.save(nextState2);
1405
+ return {
1406
+ binding: {
1407
+ ...nextState2,
1408
+ tabs: []
1409
+ },
1410
+ closedTabId: resolvedTabId
1411
+ };
1412
+ }
1379
1413
  const nextPrimaryTabId = binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
1380
1414
  const nextActiveTabId = binding.activeTabId === resolvedTabId ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null : binding.activeTabId;
1381
1415
  const nextState = {
@@ -2041,38 +2075,82 @@
2041
2075
  async function listSessionBindingStates() {
2042
2076
  return Object.values(await loadSessionBindingStateMap());
2043
2077
  }
2044
- async function summarizeSessionBindings(states) {
2045
- const items = await Promise.all(
2046
- states.map(async (state) => {
2047
- const detached = state.windowId === null || state.tabIds.length === 0;
2048
- const activeTab = typeof state.activeTabId === "number" ? await sessionBindingBrowser.getTab(state.activeTabId) : null;
2049
- const bindingUpdate = bindingUpdateMetadata.get(state.id);
2050
- return {
2051
- id: state.id,
2052
- label: state.label,
2053
- tabCount: state.tabIds.length,
2054
- activeTabId: state.activeTabId,
2055
- activeTabTitle: activeTab?.title ?? null,
2056
- activeTabUrl: activeTab?.url ?? null,
2057
- windowId: state.windowId,
2058
- groupId: state.groupId,
2059
- detached,
2060
- lastBindingUpdateAt: bindingUpdate?.at ?? null,
2061
- lastBindingUpdateReason: bindingUpdate?.reason ?? null
2062
- };
2078
+ function collectPopupSessionBindingTabIds(state) {
2079
+ return [
2080
+ ...new Set(state.tabIds.concat([state.activeTabId, state.primaryTabId].filter((value) => typeof value === "number")))
2081
+ ];
2082
+ }
2083
+ async function inspectPopupSessionBinding(state) {
2084
+ const trackedTabs = (await Promise.all(
2085
+ collectPopupSessionBindingTabIds(state).map(async (tabId) => {
2086
+ return await sessionBindingBrowser.getTab(tabId);
2063
2087
  })
2064
- );
2088
+ )).filter((tab) => tab !== null);
2089
+ const liveWindow = typeof state.windowId === "number" ? await sessionBindingBrowser.getWindow(state.windowId) : null;
2090
+ const activeTab = trackedTabs.find((tab) => tab.id === state.activeTabId) ?? trackedTabs.find((tab) => tab.active) ?? trackedTabs[0] ?? null;
2091
+ const status = trackedTabs.length > 0 ? "attached" : liveWindow ? "window-only" : "detached";
2092
+ const bindingUpdate = bindingUpdateMetadata.get(state.id);
2093
+ return {
2094
+ summary: {
2095
+ id: state.id,
2096
+ label: state.label,
2097
+ tabCount: trackedTabs.length,
2098
+ activeTabId: activeTab?.id ?? null,
2099
+ activeTabTitle: activeTab?.title ?? null,
2100
+ activeTabUrl: activeTab?.url ?? null,
2101
+ windowId: activeTab?.windowId ?? trackedTabs[0]?.windowId ?? liveWindow?.id ?? state.windowId,
2102
+ groupId: trackedTabs.length > 0 ? activeTab?.groupId ?? trackedTabs.find((tab) => tab.groupId !== null)?.groupId ?? state.groupId : null,
2103
+ status,
2104
+ lastBindingUpdateAt: bindingUpdate?.at ?? null,
2105
+ lastBindingUpdateReason: bindingUpdate?.reason ?? null
2106
+ },
2107
+ prune: status === "detached"
2108
+ };
2109
+ }
2110
+ async function summarizeSessionBindings() {
2111
+ const statusRank = {
2112
+ attached: 0,
2113
+ "window-only": 1,
2114
+ detached: 2
2115
+ };
2116
+ const items = await mutateSessionBindingStateMap(async (stateMap) => {
2117
+ const inspected = await Promise.all(
2118
+ Object.entries(stateMap).map(async ([bindingId, state]) => {
2119
+ return {
2120
+ bindingId,
2121
+ inspected: await inspectPopupSessionBinding(state)
2122
+ };
2123
+ })
2124
+ );
2125
+ for (const entry of inspected) {
2126
+ if (entry.inspected.prune) {
2127
+ delete stateMap[entry.bindingId];
2128
+ }
2129
+ }
2130
+ return inspected.filter((entry) => !entry.inspected.prune).map((entry) => entry.inspected.summary).sort((left, right) => {
2131
+ const byStatus = statusRank[left.status] - statusRank[right.status];
2132
+ if (byStatus !== 0) {
2133
+ return byStatus;
2134
+ }
2135
+ const byUpdate = (right.lastBindingUpdateAt ?? 0) - (left.lastBindingUpdateAt ?? 0);
2136
+ if (byUpdate !== 0) {
2137
+ return byUpdate;
2138
+ }
2139
+ return left.label.localeCompare(right.label);
2140
+ });
2141
+ });
2065
2142
  return {
2066
2143
  count: items.length,
2067
- attachedCount: items.filter((item) => !item.detached).length,
2068
- detachedCount: items.filter((item) => item.detached).length,
2144
+ attachedCount: items.filter((item) => item.status === "attached").length,
2145
+ windowOnlyCount: items.filter((item) => item.status === "window-only").length,
2146
+ detachedCount: items.filter((item) => item.status === "detached").length,
2069
2147
  tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
2070
2148
  items
2071
2149
  };
2072
2150
  }
2073
2151
  async function buildPopupState() {
2074
2152
  const config = await getConfig();
2075
- const sessionBindings = await summarizeSessionBindings(await listSessionBindingStates());
2153
+ const sessionBindings = await summarizeSessionBindings();
2076
2154
  const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
2077
2155
  let connectionState;
2078
2156
  if (!config.token) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Browser Agent Kit",
4
- "version": "0.6.13",
4
+ "version": "0.6.15",
5
5
  "action": {
6
6
  "default_popup": "popup.html"
7
7
  },
@@ -101,7 +101,7 @@
101
101
  const lastError = `${state.lastErrorContext ?? ""} ${state.lastError ?? ""}`.toLowerCase();
102
102
  const runtimeOffline = lastError.includes("cannot connect to bak cli");
103
103
  if (state.connected) {
104
- const body = state.sessionBindings.detachedCount > 0 ? `${pluralize(state.sessionBindings.detachedCount, "remembered session")} are detached. Check the cards below before you continue browser work.` : "The extension bridge is healthy and ready for CLI-driven browser work.";
104
+ const body = state.sessionBindings.detachedCount > 0 ? `${pluralize(state.sessionBindings.detachedCount, "remembered session")} are detached. Check the cards below before you continue browser work.` : state.sessionBindings.windowOnlyCount > 0 ? `${pluralize(state.sessionBindings.windowOnlyCount, "remembered session")} still point at a live window, but do not own tabs yet.` : "The extension bridge is healthy and ready for CLI-driven browser work.";
105
105
  return {
106
106
  badge: "Ready",
107
107
  title: "Connected to the local bak runtime",
@@ -110,6 +110,9 @@
110
110
  recoverySteps: state.sessionBindings.detachedCount > 0 ? [
111
111
  "Resume or recreate detached work from the bak CLI before sending new page commands.",
112
112
  "Use the Sessions panel below to confirm which remembered session lost its owned tabs."
113
+ ] : state.sessionBindings.windowOnlyCount > 0 ? [
114
+ "New bak page commands can reuse those remembered windows without disturbing your human tabs.",
115
+ "Use the Sessions panel below to confirm which binding is only waiting for a new owned tab."
113
116
  ] : [
114
117
  "Start browser work from the bak CLI.",
115
118
  "Use Reconnect bridge only when you intentionally changed token or port settings."
@@ -234,7 +237,7 @@
234
237
  if (state.count === 0) {
235
238
  sessionSummaryEl.textContent = "No remembered sessions";
236
239
  } else {
237
- sessionSummaryEl.textContent = `${pluralize(state.count, "session")}, ${pluralize(state.attachedCount, "attached binding")}, ${pluralize(state.detachedCount, "detached binding")}, ${pluralize(state.tabCount, "tracked tab")}`;
240
+ sessionSummaryEl.textContent = `${pluralize(state.count, "session")}, ${pluralize(state.attachedCount, "attached binding")}, ${pluralize(state.windowOnlyCount, "window-only binding")}, ${pluralize(state.detachedCount, "detached binding")}, ${pluralize(state.tabCount, "tracked tab")}`;
238
241
  }
239
242
  sessionCardsEl.replaceChildren();
240
243
  if (state.items.length === 0) {
@@ -247,7 +250,7 @@
247
250
  for (const item of state.items) {
248
251
  const card = document.createElement("section");
249
252
  card.className = "session-card";
250
- card.dataset.detached = item.detached ? "true" : "false";
253
+ card.dataset.status = item.status;
251
254
  const header = document.createElement("div");
252
255
  header.className = "session-card-header";
253
256
  const titleWrap = document.createElement("div");
@@ -261,16 +264,16 @@
261
264
  titleWrap.append(title, subtitle);
262
265
  const badge = document.createElement("span");
263
266
  badge.className = "session-badge";
264
- badge.dataset.detached = item.detached ? "true" : "false";
265
- badge.textContent = item.detached ? "Detached" : "Attached";
267
+ badge.dataset.status = item.status;
268
+ badge.textContent = item.status === "attached" ? "Attached" : item.status === "window-only" ? "Window only" : "Detached";
266
269
  header.append(titleWrap, badge);
267
270
  const activeTitle = document.createElement("div");
268
271
  activeTitle.className = "session-active-title";
269
- activeTitle.textContent = item.activeTabTitle ? truncate(item.activeTabTitle, 72) : "No active tab title";
272
+ activeTitle.textContent = item.activeTabTitle ? truncate(item.activeTabTitle, 72) : item.status === "window-only" ? "No owned tab yet" : "No active tab title";
270
273
  activeTitle.title = item.activeTabTitle ?? "";
271
274
  const activeUrl = document.createElement("div");
272
275
  activeUrl.className = "session-active-url";
273
- activeUrl.textContent = formatUrl(item.activeTabUrl);
276
+ activeUrl.textContent = item.status === "window-only" && !item.activeTabUrl ? "Next bak page command can reuse this window" : formatUrl(item.activeTabUrl);
274
277
  activeUrl.title = item.activeTabUrl ?? "";
275
278
  const meta = document.createElement("div");
276
279
  meta.className = "session-meta-grid";
@@ -286,7 +289,7 @@
286
289
  );
287
290
  const footer = document.createElement("div");
288
291
  footer.className = "session-card-footer";
289
- footer.textContent = item.detached ? "bak still remembers this session, but its owned tabs or window are missing." : "The saved binding still points at live browser tabs.";
292
+ footer.textContent = item.status === "attached" ? "The saved binding still points at live browser tabs." : item.status === "window-only" ? "bak still remembers the target window for this session, but it does not own any live tabs right now." : "bak still remembers this session, but its owned tabs or window are missing.";
290
293
  card.append(header, activeTitle, activeUrl, meta, footer);
291
294
  sessionCardsEl.appendChild(card);
292
295
  }
package/dist/popup.html CHANGED
@@ -245,7 +245,12 @@
245
245
  background: rgba(255, 255, 255, 0.92);
246
246
  }
247
247
 
248
- .session-card[data-detached="true"] {
248
+ .session-card[data-status="window-only"] {
249
+ border-color: #38bdf8;
250
+ background: rgba(240, 249, 255, 0.95);
251
+ }
252
+
253
+ .session-card[data-status="detached"] {
249
254
  border-color: #f59e0b;
250
255
  background: rgba(255, 251, 235, 0.95);
251
256
  }
@@ -283,12 +288,17 @@
283
288
  white-space: nowrap;
284
289
  }
285
290
 
286
- .session-badge[data-detached="true"] {
291
+ .session-badge[data-status="window-only"] {
292
+ background: #e0f2fe;
293
+ color: #0369a1;
294
+ }
295
+
296
+ .session-badge[data-status="detached"] {
287
297
  background: #fef3c7;
288
298
  color: #b45309;
289
299
  }
290
300
 
291
- .session-badge[data-detached="false"] {
301
+ .session-badge[data-status="attached"] {
292
302
  background: #dbeafe;
293
303
  color: #1d4ed8;
294
304
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@flrande/bak-extension",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@flrande/bak-protocol": "0.6.13"
6
+ "@flrande/bak-protocol": "0.6.15"
7
7
  },
8
8
  "devDependencies": {
9
9
  "@types/chrome": "^0.1.14",
package/public/popup.html CHANGED
@@ -245,7 +245,12 @@
245
245
  background: rgba(255, 255, 255, 0.92);
246
246
  }
247
247
 
248
- .session-card[data-detached="true"] {
248
+ .session-card[data-status="window-only"] {
249
+ border-color: #38bdf8;
250
+ background: rgba(240, 249, 255, 0.95);
251
+ }
252
+
253
+ .session-card[data-status="detached"] {
249
254
  border-color: #f59e0b;
250
255
  background: rgba(255, 251, 235, 0.95);
251
256
  }
@@ -283,12 +288,17 @@
283
288
  white-space: nowrap;
284
289
  }
285
290
 
286
- .session-badge[data-detached="true"] {
291
+ .session-badge[data-status="window-only"] {
292
+ background: #e0f2fe;
293
+ color: #0369a1;
294
+ }
295
+
296
+ .session-badge[data-status="detached"] {
287
297
  background: #fef3c7;
288
298
  color: #b45309;
289
299
  }
290
300
 
291
- .session-badge[data-detached="false"] {
301
+ .session-badge[data-status="attached"] {
292
302
  background: #dbeafe;
293
303
  color: #1d4ed8;
294
304
  }
package/src/background.ts CHANGED
@@ -70,12 +70,14 @@ interface ExtensionConfig {
70
70
  debugRichText: boolean;
71
71
  }
72
72
 
73
- interface RuntimeErrorDetails {
74
- message: string;
75
- context: 'config' | 'socket' | 'request' | 'parse';
76
- at: number;
77
- }
78
-
73
+ interface RuntimeErrorDetails {
74
+ message: string;
75
+ context: 'config' | 'socket' | 'request' | 'parse';
76
+ at: number;
77
+ }
78
+
79
+ type PopupSessionBindingStatus = 'attached' | 'window-only' | 'detached';
80
+
79
81
  interface PopupSessionBindingSummary {
80
82
  id: string;
81
83
  label: string;
@@ -85,7 +87,7 @@ interface PopupSessionBindingSummary {
85
87
  activeTabUrl: string | null;
86
88
  windowId: number | null;
87
89
  groupId: number | null;
88
- detached: boolean;
90
+ status: PopupSessionBindingStatus;
89
91
  lastBindingUpdateAt: number | null;
90
92
  lastBindingUpdateReason: string | null;
91
93
  }
@@ -107,14 +109,15 @@ interface PopupState {
107
109
  extensionVersion: string;
108
110
  lastBindingUpdateAt: number | null;
109
111
  lastBindingUpdateReason: string | null;
110
- sessionBindings: {
111
- count: number;
112
- attachedCount: number;
113
- detachedCount: number;
114
- tabCount: number;
115
- items: PopupSessionBindingSummary[];
116
- };
117
- }
112
+ sessionBindings: {
113
+ count: number;
114
+ attachedCount: number;
115
+ windowOnlyCount: number;
116
+ detachedCount: number;
117
+ tabCount: number;
118
+ items: PopupSessionBindingSummary[];
119
+ };
120
+ }
118
121
 
119
122
  const DEFAULT_PORT = 17373;
120
123
  const STORAGE_KEY_TOKEN = 'pairToken';
@@ -344,44 +347,99 @@ async function loadSessionBindingState(bindingId: string): Promise<SessionBindin
344
347
  return stateMap[bindingId] ?? null;
345
348
  }
346
349
 
347
- async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
348
- return Object.values(await loadSessionBindingStateMap());
349
- }
350
-
351
- async function summarizeSessionBindings(states: SessionBindingRecord[]): Promise<PopupState['sessionBindings']> {
352
- const items = await Promise.all(
353
- states.map(async (state) => {
354
- const detached = state.windowId === null || state.tabIds.length === 0;
355
- const activeTab =
356
- typeof state.activeTabId === 'number' ? await sessionBindingBrowser.getTab(state.activeTabId) : null;
357
- const bindingUpdate = bindingUpdateMetadata.get(state.id);
358
- return {
359
- id: state.id,
360
- label: state.label,
361
- tabCount: state.tabIds.length,
362
- activeTabId: state.activeTabId,
363
- activeTabTitle: activeTab?.title ?? null,
364
- activeTabUrl: activeTab?.url ?? null,
365
- windowId: state.windowId,
366
- groupId: state.groupId,
367
- detached,
368
- lastBindingUpdateAt: bindingUpdate?.at ?? null,
369
- lastBindingUpdateReason: bindingUpdate?.reason ?? null
370
- } satisfies PopupSessionBindingSummary;
371
- })
372
- );
350
+ async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
351
+ return Object.values(await loadSessionBindingStateMap());
352
+ }
353
+
354
+ function collectPopupSessionBindingTabIds(state: SessionBindingRecord): number[] {
355
+ return [
356
+ ...new Set(state.tabIds.concat([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number')))
357
+ ];
358
+ }
359
+
360
+ async function inspectPopupSessionBinding(
361
+ state: SessionBindingRecord
362
+ ): Promise<{ summary: PopupSessionBindingSummary; prune: boolean }> {
363
+ const trackedTabs = (
364
+ await Promise.all(
365
+ collectPopupSessionBindingTabIds(state).map(async (tabId) => {
366
+ return await sessionBindingBrowser.getTab(tabId);
367
+ })
368
+ )
369
+ ).filter((tab): tab is SessionBindingTab => tab !== null);
370
+ const liveWindow = typeof state.windowId === 'number' ? await sessionBindingBrowser.getWindow(state.windowId) : null;
371
+ const activeTab = trackedTabs.find((tab) => tab.id === state.activeTabId) ?? trackedTabs.find((tab) => tab.active) ?? trackedTabs[0] ?? null;
372
+ const status: PopupSessionBindingStatus = trackedTabs.length > 0 ? 'attached' : liveWindow ? 'window-only' : 'detached';
373
+ const bindingUpdate = bindingUpdateMetadata.get(state.id);
374
+ return {
375
+ summary: {
376
+ id: state.id,
377
+ label: state.label,
378
+ tabCount: trackedTabs.length,
379
+ activeTabId: activeTab?.id ?? null,
380
+ activeTabTitle: activeTab?.title ?? null,
381
+ activeTabUrl: activeTab?.url ?? null,
382
+ windowId: activeTab?.windowId ?? trackedTabs[0]?.windowId ?? liveWindow?.id ?? state.windowId,
383
+ groupId:
384
+ trackedTabs.length > 0
385
+ ? activeTab?.groupId ?? trackedTabs.find((tab) => tab.groupId !== null)?.groupId ?? state.groupId
386
+ : null,
387
+ status,
388
+ lastBindingUpdateAt: bindingUpdate?.at ?? null,
389
+ lastBindingUpdateReason: bindingUpdate?.reason ?? null
390
+ },
391
+ prune: status === 'detached'
392
+ };
393
+ }
394
+
395
+ async function summarizeSessionBindings(): Promise<PopupState['sessionBindings']> {
396
+ const statusRank: Record<PopupSessionBindingStatus, number> = {
397
+ attached: 0,
398
+ 'window-only': 1,
399
+ detached: 2
400
+ };
401
+ const items = await mutateSessionBindingStateMap(async (stateMap) => {
402
+ const inspected = await Promise.all(
403
+ Object.entries(stateMap).map(async ([bindingId, state]) => {
404
+ return {
405
+ bindingId,
406
+ inspected: await inspectPopupSessionBinding(state)
407
+ };
408
+ })
409
+ );
410
+ for (const entry of inspected) {
411
+ if (entry.inspected.prune) {
412
+ delete stateMap[entry.bindingId];
413
+ }
414
+ }
415
+ return inspected
416
+ .filter((entry) => !entry.inspected.prune)
417
+ .map((entry) => entry.inspected.summary)
418
+ .sort((left, right) => {
419
+ const byStatus = statusRank[left.status] - statusRank[right.status];
420
+ if (byStatus !== 0) {
421
+ return byStatus;
422
+ }
423
+ const byUpdate = (right.lastBindingUpdateAt ?? 0) - (left.lastBindingUpdateAt ?? 0);
424
+ if (byUpdate !== 0) {
425
+ return byUpdate;
426
+ }
427
+ return left.label.localeCompare(right.label);
428
+ });
429
+ });
373
430
  return {
374
431
  count: items.length,
375
- attachedCount: items.filter((item) => !item.detached).length,
376
- detachedCount: items.filter((item) => item.detached).length,
377
- tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
378
- items
379
- };
380
- }
381
-
432
+ attachedCount: items.filter((item) => item.status === 'attached').length,
433
+ windowOnlyCount: items.filter((item) => item.status === 'window-only').length,
434
+ detachedCount: items.filter((item) => item.status === 'detached').length,
435
+ tabCount: items.reduce((sum, item) => sum + item.tabCount, 0),
436
+ items
437
+ };
438
+ }
439
+
382
440
  async function buildPopupState(): Promise<PopupState> {
383
441
  const config = await getConfig();
384
- const sessionBindings = await summarizeSessionBindings(await listSessionBindingStates());
442
+ const sessionBindings = await summarizeSessionBindings();
385
443
  const reconnectRemainingMs = nextReconnectAt === null ? null : Math.max(0, nextReconnectAt - Date.now());
386
444
  let connectionState: PopupState['connectionState'];
387
445
  if (!config.token) {
package/src/popup.ts CHANGED
@@ -21,6 +21,7 @@ const sessionSummaryEl = document.getElementById('sessionSummary') as HTMLDivEle
21
21
  const sessionCardsEl = document.getElementById('sessionCards') as HTMLDivElement;
22
22
 
23
23
  type PopupTone = 'neutral' | 'success' | 'warning' | 'error';
24
+ type PopupSessionBindingStatus = 'attached' | 'window-only' | 'detached';
24
25
 
25
26
  interface PopupState {
26
27
  ok: boolean;
@@ -42,6 +43,7 @@ interface PopupState {
42
43
  sessionBindings: {
43
44
  count: number;
44
45
  attachedCount: number;
46
+ windowOnlyCount: number;
45
47
  detachedCount: number;
46
48
  tabCount: number;
47
49
  items: Array<{
@@ -53,7 +55,7 @@ interface PopupState {
53
55
  activeTabUrl: string | null;
54
56
  windowId: number | null;
55
57
  groupId: number | null;
56
- detached: boolean;
58
+ status: PopupSessionBindingStatus;
57
59
  lastBindingUpdateAt: number | null;
58
60
  lastBindingUpdateReason: string | null;
59
61
  }>;
@@ -158,6 +160,8 @@ function describeStatus(state: PopupState): StatusDescriptor {
158
160
  const body =
159
161
  state.sessionBindings.detachedCount > 0
160
162
  ? `${pluralize(state.sessionBindings.detachedCount, 'remembered session')} are detached. Check the cards below before you continue browser work.`
163
+ : state.sessionBindings.windowOnlyCount > 0
164
+ ? `${pluralize(state.sessionBindings.windowOnlyCount, 'remembered session')} still point at a live window, but do not own tabs yet.`
161
165
  : 'The extension bridge is healthy and ready for CLI-driven browser work.';
162
166
  return {
163
167
  badge: 'Ready',
@@ -169,6 +173,11 @@ function describeStatus(state: PopupState): StatusDescriptor {
169
173
  'Resume or recreate detached work from the bak CLI before sending new page commands.',
170
174
  'Use the Sessions panel below to confirm which remembered session lost its owned tabs.'
171
175
  ]
176
+ : state.sessionBindings.windowOnlyCount > 0
177
+ ? [
178
+ 'New bak page commands can reuse those remembered windows without disturbing your human tabs.',
179
+ 'Use the Sessions panel below to confirm which binding is only waiting for a new owned tab.'
180
+ ]
172
181
  : [
173
182
  'Start browser work from the bak CLI.',
174
183
  'Use Reconnect bridge only when you intentionally changed token or port settings.'
@@ -311,6 +320,7 @@ function renderSessionBindings(state: PopupState['sessionBindings']): void {
311
320
  sessionSummaryEl.textContent =
312
321
  `${pluralize(state.count, 'session')}, ` +
313
322
  `${pluralize(state.attachedCount, 'attached binding')}, ` +
323
+ `${pluralize(state.windowOnlyCount, 'window-only binding')}, ` +
314
324
  `${pluralize(state.detachedCount, 'detached binding')}, ` +
315
325
  `${pluralize(state.tabCount, 'tracked tab')}`;
316
326
  }
@@ -328,7 +338,7 @@ function renderSessionBindings(state: PopupState['sessionBindings']): void {
328
338
  for (const item of state.items) {
329
339
  const card = document.createElement('section');
330
340
  card.className = 'session-card';
331
- card.dataset.detached = item.detached ? 'true' : 'false';
341
+ card.dataset.status = item.status;
332
342
 
333
343
  const header = document.createElement('div');
334
344
  header.className = 'session-card-header';
@@ -348,19 +358,24 @@ function renderSessionBindings(state: PopupState['sessionBindings']): void {
348
358
 
349
359
  const badge = document.createElement('span');
350
360
  badge.className = 'session-badge';
351
- badge.dataset.detached = item.detached ? 'true' : 'false';
352
- badge.textContent = item.detached ? 'Detached' : 'Attached';
361
+ badge.dataset.status = item.status;
362
+ badge.textContent = item.status === 'attached' ? 'Attached' : item.status === 'window-only' ? 'Window only' : 'Detached';
353
363
 
354
364
  header.append(titleWrap, badge);
355
365
 
356
366
  const activeTitle = document.createElement('div');
357
367
  activeTitle.className = 'session-active-title';
358
- activeTitle.textContent = item.activeTabTitle ? truncate(item.activeTabTitle, 72) : 'No active tab title';
368
+ activeTitle.textContent =
369
+ item.activeTabTitle
370
+ ? truncate(item.activeTabTitle, 72)
371
+ : item.status === 'window-only'
372
+ ? 'No owned tab yet'
373
+ : 'No active tab title';
359
374
  activeTitle.title = item.activeTabTitle ?? '';
360
375
 
361
376
  const activeUrl = document.createElement('div');
362
377
  activeUrl.className = 'session-active-url';
363
- activeUrl.textContent = formatUrl(item.activeTabUrl);
378
+ activeUrl.textContent = item.status === 'window-only' && !item.activeTabUrl ? 'Next bak page command can reuse this window' : formatUrl(item.activeTabUrl);
364
379
  activeUrl.title = item.activeTabUrl ?? '';
365
380
 
366
381
  const meta = document.createElement('div');
@@ -380,9 +395,12 @@ function renderSessionBindings(state: PopupState['sessionBindings']): void {
380
395
 
381
396
  const footer = document.createElement('div');
382
397
  footer.className = 'session-card-footer';
383
- footer.textContent = item.detached
384
- ? 'bak still remembers this session, but its owned tabs or window are missing.'
385
- : 'The saved binding still points at live browser tabs.';
398
+ footer.textContent =
399
+ item.status === 'attached'
400
+ ? 'The saved binding still points at live browser tabs.'
401
+ : item.status === 'window-only'
402
+ ? 'bak still remembers the target window for this session, but it does not own any live tabs right now.'
403
+ : 'bak still remembers this session, but its owned tabs or window are missing.';
386
404
 
387
405
  card.append(header, activeTitle, activeUrl, meta, footer);
388
406
  sessionCardsEl.appendChild(card);
@@ -18,10 +18,16 @@ function isSessionBindingRecord(value: unknown): value is SessionBindingRecord {
18
18
  }
19
19
 
20
20
  function cloneSessionBindingRecord(state: SessionBindingRecord): SessionBindingRecord {
21
- return {
21
+ const cloned: SessionBindingRecord = {
22
22
  ...state,
23
23
  tabIds: [...state.tabIds]
24
24
  };
25
+ if (cloned.tabIds.length === 0) {
26
+ cloned.groupId = null;
27
+ cloned.activeTabId = null;
28
+ cloned.primaryTabId = null;
29
+ }
30
+ return cloned;
25
31
  }
26
32
 
27
33
  function normalizeSessionBindingRecordMap(value: unknown): { found: boolean; map: Record<string, SessionBindingRecord> } {
@@ -482,10 +482,10 @@ class SessionBindingManager {
482
482
  };
483
483
  }
484
484
 
485
- async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
486
- const binding = await this.inspectBinding(bindingId);
487
- if (!binding) {
488
- throw new Error(`Binding ${bindingId} does not exist`);
485
+ async closeTab(bindingId: string, tabId?: number): Promise<{ binding: SessionBindingInfo | null; closedTabId: number }> {
486
+ const binding = await this.inspectBinding(bindingId);
487
+ if (!binding) {
488
+ throw new Error(`Binding ${bindingId} does not exist`);
489
489
  }
490
490
  const resolvedTabId =
491
491
  typeof tabId === 'number'
@@ -505,13 +505,43 @@ class SessionBindingManager {
505
505
  closedTabId: resolvedTabId
506
506
  };
507
507
  }
508
-
509
- const tabs = await this.readLooseTrackedTabs(remainingTabIds);
510
- const nextPrimaryTabId =
511
- binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
512
- const nextActiveTabId =
513
- binding.activeTabId === resolvedTabId
514
- ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
508
+
509
+ const tabs = await this.readLooseTrackedTabs(remainingTabIds);
510
+ if (tabs.length === 0) {
511
+ const liveWindow = binding.windowId !== null ? await this.waitForWindow(binding.windowId, 300) : null;
512
+ if (!liveWindow) {
513
+ await this.storage.delete(binding.id);
514
+ return {
515
+ binding: null,
516
+ closedTabId: resolvedTabId
517
+ };
518
+ }
519
+
520
+ const nextState: SessionBindingRecord = {
521
+ id: binding.id,
522
+ label: binding.label,
523
+ color: binding.color,
524
+ windowId: liveWindow.id,
525
+ groupId: null,
526
+ tabIds: [],
527
+ activeTabId: null,
528
+ primaryTabId: null
529
+ };
530
+ await this.storage.save(nextState);
531
+ return {
532
+ binding: {
533
+ ...nextState,
534
+ tabs: []
535
+ },
536
+ closedTabId: resolvedTabId
537
+ };
538
+ }
539
+
540
+ const nextPrimaryTabId =
541
+ binding.primaryTabId === resolvedTabId ? tabs[0]?.id ?? null : binding.primaryTabId;
542
+ const nextActiveTabId =
543
+ binding.activeTabId === resolvedTabId
544
+ ? tabs.find((candidate) => candidate.active)?.id ?? nextPrimaryTabId ?? tabs[0]?.id ?? null
515
545
  : binding.activeTabId;
516
546
  const nextState: SessionBindingRecord = {
517
547
  id: binding.id,