@flrande/bak-extension 0.3.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workspace.ts CHANGED
@@ -1,917 +1,912 @@
1
- export const DEFAULT_WORKSPACE_ID = 'default';
2
- export const DEFAULT_WORKSPACE_LABEL = 'bak agent';
3
- export const DEFAULT_WORKSPACE_COLOR = 'blue';
4
- export const DEFAULT_WORKSPACE_URL = 'about:blank';
5
-
6
- export type WorkspaceColor = 'grey' | 'blue' | 'red' | 'yellow' | 'green' | 'pink' | 'purple' | 'cyan' | 'orange';
7
-
8
- export interface WorkspaceTab {
9
- id: number;
10
- title: string;
11
- url: string;
12
- active: boolean;
13
- windowId: number;
14
- groupId: number | null;
15
- }
16
-
17
- export interface WorkspaceWindow {
18
- id: number;
19
- focused: boolean;
20
- }
21
-
22
- export interface WorkspaceGroup {
23
- id: number;
24
- windowId: number;
25
- title: string;
26
- color: WorkspaceColor;
27
- collapsed: boolean;
28
- }
29
-
30
- export interface WorkspaceRecord {
31
- id: string;
32
- label: string;
33
- color: WorkspaceColor;
34
- windowId: number | null;
35
- groupId: number | null;
36
- tabIds: number[];
37
- activeTabId: number | null;
38
- primaryTabId: number | null;
39
- }
40
-
41
- export interface WorkspaceInfo extends WorkspaceRecord {
42
- tabs: WorkspaceTab[];
43
- }
44
-
45
- export interface WorkspaceEnsureResult {
46
- workspace: WorkspaceInfo;
47
- created: boolean;
48
- repaired: boolean;
49
- repairActions: string[];
50
- }
51
-
52
- export interface WorkspaceTargetResolution {
53
- tab: WorkspaceTab;
54
- workspace: WorkspaceInfo | null;
55
- resolution: 'explicit-tab' | 'explicit-workspace' | 'default-workspace' | 'browser-active';
56
- createdWorkspace: boolean;
57
- repaired: boolean;
58
- repairActions: string[];
59
- }
60
-
61
- export interface WorkspaceStorage {
62
- load(): Promise<WorkspaceRecord | null>;
63
- save(state: WorkspaceRecord | null): Promise<void>;
64
- }
65
-
66
- export interface WorkspaceBrowser {
67
- getTab(tabId: number): Promise<WorkspaceTab | null>;
68
- getActiveTab(): Promise<WorkspaceTab | null>;
69
- listTabs(filter?: { windowId?: number }): Promise<WorkspaceTab[]>;
70
- createTab(options: { windowId?: number; url?: string; active?: boolean }): Promise<WorkspaceTab>;
71
- updateTab(tabId: number, options: { active?: boolean; url?: string }): Promise<WorkspaceTab>;
72
- closeTab(tabId: number): Promise<void>;
73
- getWindow(windowId: number): Promise<WorkspaceWindow | null>;
74
- createWindow(options: { url?: string; focused?: boolean }): Promise<WorkspaceWindow>;
75
- updateWindow(windowId: number, options: { focused?: boolean }): Promise<WorkspaceWindow>;
76
- closeWindow(windowId: number): Promise<void>;
77
- getGroup(groupId: number): Promise<WorkspaceGroup | null>;
78
- groupTabs(tabIds: number[], groupId?: number): Promise<number>;
79
- updateGroup(groupId: number, options: { title?: string; color?: WorkspaceColor; collapsed?: boolean }): Promise<WorkspaceGroup>;
80
- }
81
-
82
- interface WorkspaceWindowOwnership {
83
- workspaceTabs: WorkspaceTab[];
84
- foreignTabs: WorkspaceTab[];
85
- }
86
-
87
- export interface WorkspaceEnsureOptions {
88
- workspaceId?: string;
89
- focus?: boolean;
90
- initialUrl?: string;
91
- }
92
-
93
- export interface WorkspaceOpenTabOptions {
94
- workspaceId?: string;
95
- url?: string;
96
- active?: boolean;
97
- focus?: boolean;
98
- }
99
-
100
- export interface WorkspaceResolveTargetOptions {
101
- tabId?: number;
102
- workspaceId?: string;
103
- createIfMissing?: boolean;
104
- }
105
-
106
- export class WorkspaceManager {
107
- private readonly storage: WorkspaceStorage;
108
- private readonly browser: WorkspaceBrowser;
109
-
110
- constructor(storage: WorkspaceStorage, browser: WorkspaceBrowser) {
111
- this.storage = storage;
112
- this.browser = browser;
113
- }
114
-
115
- async getWorkspaceInfo(workspaceId = DEFAULT_WORKSPACE_ID): Promise<WorkspaceInfo | null> {
116
- return this.inspectWorkspace(workspaceId);
117
- }
118
-
119
- async ensureWorkspace(options: WorkspaceEnsureOptions = {}): Promise<WorkspaceEnsureResult> {
120
- const workspaceId = this.normalizeWorkspaceId(options.workspaceId);
121
- const repairActions: string[] = [];
122
- const initialUrl = options.initialUrl ?? DEFAULT_WORKSPACE_URL;
123
- const persisted = await this.storage.load();
124
- const created = !persisted;
125
- let state = this.normalizeState(persisted, workspaceId);
126
-
127
- const originalWindowId = state.windowId;
128
- let window = state.windowId !== null ? await this.waitForWindow(state.windowId) : null;
129
- let tabs: WorkspaceTab[] = [];
130
- if (!window) {
131
- const rebound = await this.rebindWorkspaceWindow(state);
132
- if (rebound) {
133
- window = rebound.window;
134
- tabs = rebound.tabs;
135
- if (originalWindowId !== rebound.window.id) {
136
- repairActions.push('rebound-window');
137
- }
138
- }
139
- }
140
- if (!window) {
141
- const createdWindow = await this.browser.createWindow({
142
- url: initialUrl,
143
- focused: options.focus === true
144
- });
145
- state.windowId = createdWindow.id;
146
- state.groupId = null;
147
- state.tabIds = [];
148
- state.activeTabId = null;
149
- state.primaryTabId = null;
150
- window = createdWindow;
151
- tabs = await this.waitForWindowTabs(createdWindow.id);
152
- state.tabIds = tabs.map((tab) => tab.id);
153
- if (state.primaryTabId === null) {
154
- state.primaryTabId = tabs[0]?.id ?? null;
155
- }
156
- if (state.activeTabId === null) {
157
- state.activeTabId = tabs.find((tab) => tab.active)?.id ?? tabs[0]?.id ?? null;
158
- }
159
- repairActions.push(created ? 'created-window' : 'recreated-window');
160
- }
161
-
162
- tabs = tabs.length > 0 ? tabs : await this.readTrackedTabs(state.tabIds, state.windowId);
163
- const recoveredTabs = await this.recoverWorkspaceTabs(state, tabs);
164
- if (recoveredTabs.length > tabs.length) {
165
- tabs = recoveredTabs;
166
- repairActions.push('recovered-tracked-tabs');
167
- }
168
- if (tabs.length !== state.tabIds.length) {
169
- repairActions.push('pruned-missing-tabs');
170
- }
171
- state.tabIds = tabs.map((tab) => tab.id);
172
-
173
- if (state.windowId !== null) {
174
- const ownership = await this.inspectWorkspaceWindowOwnership(state, state.windowId);
175
- if (ownership.foreignTabs.length > 0) {
176
- const migrated = await this.moveWorkspaceIntoDedicatedWindow(state, ownership, initialUrl);
177
- window = migrated.window;
178
- tabs = migrated.tabs;
179
- state.tabIds = tabs.map((tab) => tab.id);
180
- repairActions.push('migrated-dirty-window');
181
- }
182
- }
183
-
184
- if (tabs.length === 0) {
185
- const primary = await this.createWorkspaceTab({
186
- windowId: state.windowId,
187
- url: initialUrl,
188
- active: true
189
- });
190
- tabs = [primary];
191
- state.tabIds = [primary.id];
192
- state.primaryTabId = primary.id;
193
- state.activeTabId = primary.id;
194
- repairActions.push('created-primary-tab');
195
- }
196
-
197
- if (state.primaryTabId === null || !tabs.some((tab) => tab.id === state.primaryTabId)) {
198
- state.primaryTabId = tabs[0]?.id ?? null;
199
- repairActions.push('reassigned-primary-tab');
200
- }
201
-
202
- if (state.activeTabId === null || !tabs.some((tab) => tab.id === state.activeTabId)) {
203
- state.activeTabId = state.primaryTabId ?? tabs[0]?.id ?? null;
204
- repairActions.push('reassigned-active-tab');
205
- }
206
-
207
- let group = state.groupId !== null ? await this.browser.getGroup(state.groupId) : null;
208
- if (!group || group.windowId !== state.windowId) {
209
- const groupId = await this.browser.groupTabs(tabs.map((tab) => tab.id));
210
- group = await this.browser.updateGroup(groupId, {
211
- title: state.label,
212
- color: state.color,
213
- collapsed: false
214
- });
215
- state.groupId = group.id;
216
- repairActions.push('recreated-group');
217
- } else {
218
- await this.browser.updateGroup(group.id, {
219
- title: state.label,
220
- color: state.color,
221
- collapsed: false
222
- });
223
- }
224
-
225
- const ungroupedIds = tabs.filter((tab) => tab.groupId !== state.groupId).map((tab) => tab.id);
226
- if (ungroupedIds.length > 0) {
227
- await this.browser.groupTabs(ungroupedIds, state.groupId ?? undefined);
228
- repairActions.push('regrouped-tabs');
229
- }
230
-
231
- tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
232
- tabs = await this.recoverWorkspaceTabs(state, tabs);
233
- const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId) : null;
234
- if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
235
- tabs = [...tabs, activeTab];
236
- }
237
- if (tabs.length === 0 && state.primaryTabId !== null) {
238
- const primaryTab = await this.waitForTrackedTab(state.primaryTabId, state.windowId);
239
- if (primaryTab) {
240
- tabs = [primaryTab];
241
- }
242
- }
243
- state.tabIds = [...new Set(tabs.map((tab) => tab.id))];
244
-
245
- if (options.focus === true && state.activeTabId !== null) {
246
- await this.browser.updateTab(state.activeTabId, { active: true });
247
- window = await this.browser.updateWindow(state.windowId!, { focused: true });
248
- void window;
249
- repairActions.push('focused-window');
250
- }
251
-
252
- await this.storage.save(state);
253
-
254
- return {
255
- workspace: {
256
- ...state,
257
- tabs
258
- },
259
- created,
260
- repaired: repairActions.length > 0,
261
- repairActions
262
- };
263
- }
264
-
265
- async openTab(options: WorkspaceOpenTabOptions = {}): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab }> {
266
- const workspaceId = this.normalizeWorkspaceId(options.workspaceId);
267
- const hadWorkspace = (await this.loadWorkspaceRecord(workspaceId)) !== null;
268
- const ensured = await this.ensureWorkspace({
269
- workspaceId,
270
- focus: false,
271
- initialUrl: hadWorkspace ? options.url ?? DEFAULT_WORKSPACE_URL : DEFAULT_WORKSPACE_URL
272
- });
273
- let state = { ...ensured.workspace, tabIds: [...ensured.workspace.tabIds], tabs: [...ensured.workspace.tabs] };
274
- if (state.windowId !== null && state.tabs.length === 0) {
275
- const rebound = await this.rebindWorkspaceWindow(state);
276
- if (rebound) {
277
- state.windowId = rebound.window.id;
278
- state.tabs = rebound.tabs;
279
- state.tabIds = [...new Set(rebound.tabs.map((tab) => tab.id))];
280
- }
281
- }
282
- const active = options.active === true;
283
- const desiredUrl = options.url ?? DEFAULT_WORKSPACE_URL;
284
- let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
285
- state,
286
- ensured.created ||
287
- ensured.repairActions.includes('recreated-window') ||
288
- ensured.repairActions.includes('created-primary-tab') ||
289
- ensured.repairActions.includes('migrated-dirty-window')
290
- );
291
-
292
- let createdTab: WorkspaceTab;
293
- try {
294
- createdTab = reusablePrimaryTab
295
- ? await this.browser.updateTab(reusablePrimaryTab.id, {
296
- url: desiredUrl,
297
- active
298
- })
299
- : await this.createWorkspaceTab({
300
- windowId: state.windowId,
301
- url: desiredUrl,
302
- active
303
- });
304
- } catch (error) {
305
- if (!this.isMissingWindowError(error)) {
306
- throw error;
307
- }
308
- const repaired = await this.ensureWorkspace({
309
- workspaceId,
310
- focus: false,
311
- initialUrl: desiredUrl
312
- });
313
- state = { ...repaired.workspace };
314
- reusablePrimaryTab = await this.resolveReusablePrimaryTab(state, true);
315
- createdTab = reusablePrimaryTab
316
- ? await this.browser.updateTab(reusablePrimaryTab.id, {
317
- url: desiredUrl,
318
- active
319
- })
320
- : await this.createWorkspaceTab({
321
- windowId: state.windowId,
322
- url: desiredUrl,
323
- active
324
- });
325
- }
326
- const nextTabIds = [...new Set([...state.tabIds, createdTab.id])];
327
- const groupId = await this.browser.groupTabs([createdTab.id], state.groupId ?? undefined);
328
- await this.browser.updateGroup(groupId, {
329
- title: state.label,
330
- color: state.color,
331
- collapsed: false
332
- });
333
- const nextState: WorkspaceRecord = {
334
- id: state.id,
335
- label: state.label,
336
- color: state.color,
337
- windowId: state.windowId,
338
- groupId,
339
- tabIds: nextTabIds,
340
- activeTabId: createdTab.id,
341
- primaryTabId: state.primaryTabId ?? createdTab.id
342
- };
343
-
344
- if (options.focus === true) {
345
- await this.browser.updateTab(createdTab.id, { active: true });
346
- await this.browser.updateWindow(state.windowId!, { focused: true });
347
- }
348
-
349
- await this.storage.save(nextState);
350
- const tabs = await this.readTrackedTabs(nextState.tabIds, nextState.windowId);
351
- const tab = tabs.find((item) => item.id === createdTab.id) ?? createdTab;
352
- return {
353
- workspace: {
354
- ...nextState,
355
- tabs
356
- },
357
- tab
358
- };
359
- }
360
-
361
- async listTabs(workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ workspace: WorkspaceInfo; tabs: WorkspaceTab[] }> {
362
- const ensured = await this.inspectWorkspace(workspaceId);
363
- if (!ensured) {
364
- throw new Error(`Workspace ${workspaceId} does not exist`);
365
- }
366
- return {
367
- workspace: ensured,
368
- tabs: ensured.tabs
369
- };
370
- }
371
-
372
- async getActiveTab(workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab | null }> {
373
- const ensured = await this.inspectWorkspace(workspaceId);
374
- if (!ensured) {
375
- const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
376
- return {
377
- workspace: {
378
- ...this.normalizeState(null, normalizedWorkspaceId),
379
- tabs: []
380
- },
381
- tab: null
382
- };
383
- }
384
- return {
385
- workspace: ensured,
386
- tab: ensured.tabs.find((tab) => tab.id === ensured.activeTabId) ?? null
387
- };
388
- }
389
-
390
- async setActiveTab(tabId: number, workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab }> {
391
- const ensured = await this.ensureWorkspace({ workspaceId });
392
- if (!ensured.workspace.tabIds.includes(tabId)) {
393
- throw new Error(`Tab ${tabId} does not belong to workspace ${workspaceId}`);
394
- }
395
- const nextState: WorkspaceRecord = {
396
- id: ensured.workspace.id,
397
- label: ensured.workspace.label,
398
- color: ensured.workspace.color,
399
- windowId: ensured.workspace.windowId,
400
- groupId: ensured.workspace.groupId,
401
- tabIds: [...ensured.workspace.tabIds],
402
- activeTabId: tabId,
403
- primaryTabId: ensured.workspace.primaryTabId ?? tabId
404
- };
405
- await this.storage.save(nextState);
406
- const tabs = await this.readTrackedTabs(nextState.tabIds, nextState.windowId);
407
- const tab = tabs.find((item) => item.id === tabId);
408
- if (!tab) {
409
- throw new Error(`Tab ${tabId} is missing from workspace ${workspaceId}`);
410
- }
411
- return {
412
- workspace: {
413
- ...nextState,
414
- tabs
415
- },
416
- tab
417
- };
418
- }
419
-
420
- async focus(workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ ok: true; workspace: WorkspaceInfo }> {
421
- const ensured = await this.ensureWorkspace({ workspaceId, focus: false });
422
- if (ensured.workspace.activeTabId !== null) {
423
- await this.browser.updateTab(ensured.workspace.activeTabId, { active: true });
424
- }
425
- if (ensured.workspace.windowId !== null) {
426
- await this.browser.updateWindow(ensured.workspace.windowId, { focused: true });
427
- }
428
- const refreshed = await this.ensureWorkspace({ workspaceId, focus: false });
429
- return { ok: true, workspace: refreshed.workspace };
430
- }
431
-
432
- async reset(options: WorkspaceEnsureOptions = {}): Promise<WorkspaceEnsureResult> {
433
- await this.close(options.workspaceId);
434
- return this.ensureWorkspace(options);
435
- }
436
-
437
- async close(workspaceId = DEFAULT_WORKSPACE_ID): Promise<{ ok: true }> {
438
- const state = await this.storage.load();
439
- if (!state || state.id !== workspaceId) {
440
- await this.storage.save(null);
441
- return { ok: true };
442
- }
443
- // Clear persisted state before closing the window so tab/window removal
444
- // listeners cannot race and resurrect an empty workspace record.
445
- await this.storage.save(null);
446
- if (state.windowId !== null) {
447
- const existingWindow = await this.browser.getWindow(state.windowId);
448
- if (existingWindow) {
449
- await this.browser.closeWindow(state.windowId);
450
- }
451
- }
452
- return { ok: true };
453
- }
454
-
455
- async resolveTarget(options: WorkspaceResolveTargetOptions = {}): Promise<WorkspaceTargetResolution> {
456
- if (typeof options.tabId === 'number') {
457
- const explicitTab = await this.browser.getTab(options.tabId);
458
- if (!explicitTab) {
459
- throw new Error(`No tab with id ${options.tabId}`);
460
- }
461
- return {
462
- tab: explicitTab,
463
- workspace: null,
464
- resolution: 'explicit-tab',
465
- createdWorkspace: false,
466
- repaired: false,
467
- repairActions: []
468
- };
469
- }
470
-
471
- const explicitWorkspaceId = typeof options.workspaceId === 'string' ? this.normalizeWorkspaceId(options.workspaceId) : undefined;
472
- if (explicitWorkspaceId) {
473
- const ensured = await this.ensureWorkspace({
474
- workspaceId: explicitWorkspaceId,
475
- focus: false
476
- });
477
- return this.buildWorkspaceResolution(ensured, 'explicit-workspace');
478
- }
479
-
480
- const existingWorkspace = await this.loadWorkspaceRecord(DEFAULT_WORKSPACE_ID);
481
- if (existingWorkspace) {
482
- const ensured = await this.ensureWorkspace({
483
- workspaceId: existingWorkspace.id,
484
- focus: false
485
- });
486
- return this.buildWorkspaceResolution(ensured, 'default-workspace');
487
- }
488
-
489
- if (options.createIfMissing !== true) {
490
- const activeTab = await this.browser.getActiveTab();
491
- if (!activeTab) {
492
- throw new Error('No active tab');
493
- }
494
- return {
495
- tab: activeTab,
496
- workspace: null,
497
- resolution: 'browser-active',
498
- createdWorkspace: false,
499
- repaired: false,
500
- repairActions: []
501
- };
502
- }
503
-
504
- const ensured = await this.ensureWorkspace({
505
- workspaceId: DEFAULT_WORKSPACE_ID,
506
- focus: false
507
- });
508
- return this.buildWorkspaceResolution(ensured, 'default-workspace');
509
- }
510
-
511
- private normalizeWorkspaceId(workspaceId?: string): string {
512
- const candidate = workspaceId?.trim();
513
- if (!candidate) {
514
- return DEFAULT_WORKSPACE_ID;
515
- }
516
- if (candidate !== DEFAULT_WORKSPACE_ID) {
517
- throw new Error(`Unsupported workspace id: ${candidate}`);
518
- }
519
- return candidate;
520
- }
521
-
522
- private normalizeState(state: WorkspaceRecord | null, workspaceId: string): WorkspaceRecord {
523
- return {
524
- id: workspaceId,
525
- label: state?.label ?? DEFAULT_WORKSPACE_LABEL,
526
- color: state?.color ?? DEFAULT_WORKSPACE_COLOR,
527
- windowId: state?.windowId ?? null,
528
- groupId: state?.groupId ?? null,
529
- tabIds: state?.tabIds ?? [],
530
- activeTabId: state?.activeTabId ?? null,
531
- primaryTabId: state?.primaryTabId ?? null
532
- };
533
- }
534
-
535
- private async loadWorkspaceRecord(workspaceId = DEFAULT_WORKSPACE_ID): Promise<WorkspaceRecord | null> {
536
- const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
537
- const state = await this.storage.load();
538
- if (!state || state.id !== normalizedWorkspaceId) {
539
- return null;
540
- }
541
- return this.normalizeState(state, normalizedWorkspaceId);
542
- }
543
-
544
- private async buildWorkspaceResolution(
545
- ensured: WorkspaceEnsureResult,
546
- resolution: 'explicit-workspace' | 'default-workspace'
547
- ): Promise<WorkspaceTargetResolution> {
548
- const tab = ensured.workspace.tabs.find((item) => item.id === ensured.workspace.activeTabId) ?? ensured.workspace.tabs[0] ?? null;
549
- if (tab) {
550
- return {
551
- tab,
552
- workspace: ensured.workspace,
553
- resolution,
554
- createdWorkspace: ensured.created,
555
- repaired: ensured.repaired,
556
- repairActions: ensured.repairActions
557
- };
558
- }
559
-
560
- if (ensured.workspace.activeTabId !== null) {
561
- const activeWorkspaceTab = await this.waitForTrackedTab(ensured.workspace.activeTabId, ensured.workspace.windowId);
562
- if (activeWorkspaceTab) {
563
- return {
564
- tab: activeWorkspaceTab,
565
- workspace: ensured.workspace,
566
- resolution,
567
- createdWorkspace: ensured.created,
568
- repaired: ensured.repaired,
569
- repairActions: ensured.repairActions
570
- };
571
- }
572
- }
573
-
574
- const activeTab = await this.browser.getActiveTab();
575
- if (!activeTab) {
576
- throw new Error('No active tab');
577
- }
578
- return {
579
- tab: activeTab,
580
- workspace: null,
581
- resolution: 'browser-active',
582
- createdWorkspace: ensured.created,
583
- repaired: ensured.repaired,
584
- repairActions: ensured.repairActions
585
- };
586
- }
587
-
588
- private async readTrackedTabs(tabIds: number[], windowId: number | null): Promise<WorkspaceTab[]> {
589
- const tabs = (
590
- await Promise.all(
591
- tabIds.map(async (tabId) => {
592
- const tab = await this.browser.getTab(tabId);
593
- if (!tab) {
594
- return null;
595
- }
596
- if (windowId !== null && tab.windowId !== windowId) {
597
- return null;
598
- }
599
- return tab;
600
- })
601
- )
602
- ).filter((tab): tab is WorkspaceTab => tab !== null);
603
- return tabs;
604
- }
605
-
606
- private async readLooseTrackedTabs(tabIds: number[]): Promise<WorkspaceTab[]> {
607
- const tabs = (
608
- await Promise.all(
609
- tabIds.map(async (tabId) => {
610
- return await this.browser.getTab(tabId);
611
- })
612
- )
613
- ).filter((tab): tab is WorkspaceTab => tab !== null);
614
- return tabs;
615
- }
616
-
617
- private collectCandidateTabIds(state: WorkspaceRecord): number[] {
618
- return [...new Set(state.tabIds.concat([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number')))];
619
- }
620
-
621
- private async rebindWorkspaceWindow(state: WorkspaceRecord): Promise<{ window: WorkspaceWindow; tabs: WorkspaceTab[] } | null> {
622
- const candidateWindowIds: number[] = [];
623
- const pushWindowId = (windowId: number | null | undefined): void => {
624
- if (typeof windowId !== 'number') {
625
- return;
626
- }
627
- if (!candidateWindowIds.includes(windowId)) {
628
- candidateWindowIds.push(windowId);
629
- }
630
- };
631
-
632
- const group = state.groupId !== null ? await this.browser.getGroup(state.groupId) : null;
633
- pushWindowId(group?.windowId);
634
-
635
- const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
636
- for (const tab of trackedTabs) {
637
- pushWindowId(tab.windowId);
638
- }
639
-
640
- for (const candidateWindowId of candidateWindowIds) {
641
- const window = await this.waitForWindow(candidateWindowId);
642
- if (!window) {
643
- continue;
644
- }
645
- let tabs = await this.readTrackedTabs(this.collectCandidateTabIds(state), candidateWindowId);
646
- if (tabs.length === 0 && group?.id !== null && group?.windowId === candidateWindowId) {
647
- const windowTabs = await this.waitForWindowTabs(candidateWindowId, 750);
648
- tabs = windowTabs.filter((tab) => tab.groupId === group.id);
649
- }
650
- if (tabs.length === 0) {
651
- tabs = trackedTabs.filter((tab) => tab.windowId === candidateWindowId);
652
- }
653
- state.windowId = candidateWindowId;
654
- if (tabs.length > 0) {
655
- state.tabIds = [...new Set(tabs.map((tab) => tab.id))];
656
- if (state.primaryTabId === null || !state.tabIds.includes(state.primaryTabId)) {
657
- state.primaryTabId = tabs[0]?.id ?? null;
658
- }
659
- if (state.activeTabId === null || !state.tabIds.includes(state.activeTabId)) {
660
- state.activeTabId = tabs.find((tab) => tab.active)?.id ?? state.primaryTabId;
661
- }
662
- }
663
- return { window, tabs };
664
- }
665
-
666
- return null;
667
- }
668
-
669
- private async inspectWorkspaceWindowOwnership(state: WorkspaceRecord, windowId: number): Promise<WorkspaceWindowOwnership> {
670
- const windowTabs = await this.waitForWindowTabs(windowId, 500);
671
- const trackedIds = new Set(this.collectCandidateTabIds(state));
672
- return {
673
- workspaceTabs: windowTabs.filter((tab) => trackedIds.has(tab.id) || (state.groupId !== null && tab.groupId === state.groupId)),
674
- foreignTabs: windowTabs.filter((tab) => !trackedIds.has(tab.id) && (state.groupId === null || tab.groupId !== state.groupId))
675
- };
676
- }
677
-
678
- private async moveWorkspaceIntoDedicatedWindow(
679
- state: WorkspaceRecord,
680
- ownership: WorkspaceWindowOwnership,
681
- initialUrl: string
682
- ): Promise<{ window: WorkspaceWindow; tabs: WorkspaceTab[] }> {
683
- const sourceTabs = this.orderWorkspaceTabsForMigration(state, ownership.workspaceTabs);
684
- const seedUrl = sourceTabs[0]?.url ?? initialUrl;
685
- const window = await this.browser.createWindow({
686
- url: seedUrl || DEFAULT_WORKSPACE_URL,
687
- focused: false
688
- });
689
- const recreatedTabs = await this.waitForWindowTabs(window.id);
690
- const firstTab = recreatedTabs[0] ?? null;
691
- const tabIdMap = new Map<number, number>();
692
- if (sourceTabs[0] && firstTab) {
693
- tabIdMap.set(sourceTabs[0].id, firstTab.id);
694
- }
695
-
696
- for (const sourceTab of sourceTabs.slice(1)) {
697
- const recreated = await this.createWorkspaceTab({
698
- windowId: window.id,
699
- url: sourceTab.url,
700
- active: false
701
- });
702
- recreatedTabs.push(recreated);
703
- tabIdMap.set(sourceTab.id, recreated.id);
704
- }
705
-
706
- const nextPrimaryTabId =
707
- (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : undefined) ??
708
- firstTab?.id ??
709
- recreatedTabs[0]?.id ??
710
- null;
711
- const nextActiveTabId =
712
- (state.activeTabId !== null ? tabIdMap.get(state.activeTabId) : undefined) ?? nextPrimaryTabId ?? recreatedTabs[0]?.id ?? null;
713
- if (nextActiveTabId !== null) {
714
- await this.browser.updateTab(nextActiveTabId, { active: true });
715
- }
716
-
717
- state.windowId = window.id;
718
- state.groupId = null;
719
- state.tabIds = recreatedTabs.map((tab) => tab.id);
720
- state.primaryTabId = nextPrimaryTabId;
721
- state.activeTabId = nextActiveTabId;
722
-
723
- for (const workspaceTab of ownership.workspaceTabs) {
724
- await this.browser.closeTab(workspaceTab.id);
725
- }
726
-
727
- return {
728
- window,
729
- tabs: await this.readTrackedTabs(state.tabIds, state.windowId)
730
- };
731
- }
732
-
733
- private orderWorkspaceTabsForMigration(state: WorkspaceRecord, tabs: WorkspaceTab[]): WorkspaceTab[] {
734
- const ordered: WorkspaceTab[] = [];
735
- const seen = new Set<number>();
736
- const pushById = (tabId: number | null): void => {
737
- if (typeof tabId !== 'number') {
738
- return;
739
- }
740
- const tab = tabs.find((candidate) => candidate.id === tabId);
741
- if (!tab || seen.has(tab.id)) {
742
- return;
743
- }
744
- ordered.push(tab);
745
- seen.add(tab.id);
746
- };
747
-
748
- pushById(state.primaryTabId);
749
- pushById(state.activeTabId);
750
- for (const tab of tabs) {
751
- if (seen.has(tab.id)) {
752
- continue;
753
- }
754
- ordered.push(tab);
755
- seen.add(tab.id);
756
- }
757
- return ordered;
758
- }
759
-
760
- private async recoverWorkspaceTabs(state: WorkspaceRecord, existingTabs: WorkspaceTab[]): Promise<WorkspaceTab[]> {
761
- if (state.windowId === null) {
762
- return existingTabs;
763
- }
764
-
765
- const candidates = await this.waitForWindowTabs(state.windowId, 500);
766
- if (candidates.length === 0) {
767
- return existingTabs;
768
- }
769
-
770
- const trackedIds = new Set(state.tabIds);
771
- const trackedTabs = candidates.filter((tab) => trackedIds.has(tab.id));
772
- if (trackedTabs.length > existingTabs.length) {
773
- return trackedTabs;
774
- }
775
-
776
- if (state.groupId !== null) {
777
- const groupedTabs = candidates.filter((tab) => tab.groupId === state.groupId);
778
- if (groupedTabs.length > 0) {
779
- return groupedTabs;
780
- }
781
- }
782
-
783
- const preferredIds = new Set([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number'));
784
- const preferredTabs = candidates.filter((tab) => preferredIds.has(tab.id));
785
- if (preferredTabs.length > existingTabs.length) {
786
- return preferredTabs;
787
- }
788
-
789
- return existingTabs;
790
- }
791
-
792
- private async createWorkspaceTab(options: { windowId: number | null; url: string; active: boolean }): Promise<WorkspaceTab> {
793
- if (options.windowId === null) {
794
- throw new Error('Workspace window is unavailable');
795
- }
796
-
797
- const deadline = Date.now() + 1_500;
798
- let lastError: Error | null = null;
799
-
800
- while (Date.now() < deadline) {
801
- try {
802
- return await this.browser.createTab({
803
- windowId: options.windowId,
804
- url: options.url,
805
- active: options.active
806
- });
807
- } catch (error) {
808
- if (!this.isMissingWindowError(error)) {
809
- throw error;
810
- }
811
- lastError = error instanceof Error ? error : new Error(String(error));
812
- await this.delay(50);
813
- }
814
- }
815
-
816
- throw lastError ?? new Error(`No window with id: ${options.windowId}.`);
817
- }
818
-
819
- private async inspectWorkspace(workspaceId = DEFAULT_WORKSPACE_ID): Promise<WorkspaceInfo | null> {
820
- const state = await this.loadWorkspaceRecord(workspaceId);
821
- if (!state) {
822
- return null;
823
- }
824
-
825
- let tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
826
- const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId, 300) : null;
827
- if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
828
- tabs = [...tabs, activeTab];
829
- }
830
- if (tabs.length === 0 && state.primaryTabId !== null) {
831
- const primaryTab = await this.waitForTrackedTab(state.primaryTabId, state.windowId, 300);
832
- if (primaryTab) {
833
- tabs = [primaryTab];
834
- }
835
- }
836
-
837
- return {
838
- ...state,
839
- tabIds: [...new Set(state.tabIds.concat(tabs.map((tab) => tab.id)))],
840
- tabs
841
- };
842
- }
843
-
844
- private async resolveReusablePrimaryTab(workspace: WorkspaceInfo, allowReuse: boolean): Promise<WorkspaceTab | null> {
845
- if (workspace.windowId === null) {
846
- return null;
847
- }
848
- if (workspace.primaryTabId !== null) {
849
- const trackedPrimary = workspace.tabs.find((tab) => tab.id === workspace.primaryTabId) ?? (await this.waitForTrackedTab(workspace.primaryTabId, workspace.windowId));
850
- if (trackedPrimary && (allowReuse || this.isReusableBlankWorkspaceTab(trackedPrimary, workspace))) {
851
- return trackedPrimary;
852
- }
853
- }
854
- const windowTabs = await this.waitForWindowTabs(workspace.windowId, 750);
855
- if (windowTabs.length !== 1) {
856
- return null;
857
- }
858
- const candidate = windowTabs[0]!;
859
- if (allowReuse || this.isReusableBlankWorkspaceTab(candidate, workspace)) {
860
- return candidate;
861
- }
862
- return null;
863
- }
864
-
865
- private isReusableBlankWorkspaceTab(tab: WorkspaceTab, workspace: WorkspaceInfo): boolean {
866
- if (workspace.tabIds.length > 1) {
867
- return false;
868
- }
869
- const normalizedUrl = tab.url.trim().toLowerCase();
870
- return normalizedUrl === '' || normalizedUrl === DEFAULT_WORKSPACE_URL;
871
- }
872
-
873
- private async waitForWindow(windowId: number, timeoutMs = 750): Promise<WorkspaceWindow | null> {
874
- const deadline = Date.now() + timeoutMs;
875
- while (Date.now() < deadline) {
876
- const window = await this.browser.getWindow(windowId);
877
- if (window) {
878
- return window;
879
- }
880
- await this.delay(50);
881
- }
882
- return null;
883
- }
884
-
885
- private async waitForTrackedTab(tabId: number, windowId: number | null, timeoutMs = 1_000): Promise<WorkspaceTab | null> {
886
- const deadline = Date.now() + timeoutMs;
887
- while (Date.now() < deadline) {
888
- const tab = await this.browser.getTab(tabId);
889
- if (tab && (windowId === null || tab.windowId === windowId)) {
890
- return tab;
891
- }
892
- await this.delay(50);
893
- }
894
- return null;
895
- }
896
-
897
- private async waitForWindowTabs(windowId: number, timeoutMs = 1_000): Promise<WorkspaceTab[]> {
898
- const deadline = Date.now() + timeoutMs;
899
- while (Date.now() < deadline) {
900
- const tabs = await this.browser.listTabs({ windowId });
901
- if (tabs.length > 0) {
902
- return tabs;
903
- }
904
- await this.delay(50);
905
- }
906
- return [];
907
- }
908
-
909
- private async delay(ms: number): Promise<void> {
910
- await new Promise((resolve) => setTimeout(resolve, ms));
911
- }
912
-
913
- private isMissingWindowError(error: unknown): boolean {
914
- const message = error instanceof Error ? error.message : String(error);
915
- return message.toLowerCase().includes('no window with id');
916
- }
917
- }
1
+ export const DEFAULT_WORKSPACE_LABEL = 'bak agent';
2
+ export const DEFAULT_WORKSPACE_COLOR = 'blue';
3
+ export const DEFAULT_WORKSPACE_URL = 'about:blank';
4
+
5
+ export type WorkspaceColor = 'grey' | 'blue' | 'red' | 'yellow' | 'green' | 'pink' | 'purple' | 'cyan' | 'orange';
6
+
7
+ export interface WorkspaceTab {
8
+ id: number;
9
+ title: string;
10
+ url: string;
11
+ active: boolean;
12
+ windowId: number;
13
+ groupId: number | null;
14
+ }
15
+
16
+ export interface WorkspaceWindow {
17
+ id: number;
18
+ focused: boolean;
19
+ }
20
+
21
+ export interface WorkspaceGroup {
22
+ id: number;
23
+ windowId: number;
24
+ title: string;
25
+ color: WorkspaceColor;
26
+ collapsed: boolean;
27
+ }
28
+
29
+ export interface WorkspaceRecord {
30
+ id: string;
31
+ label: string;
32
+ color: WorkspaceColor;
33
+ windowId: number | null;
34
+ groupId: number | null;
35
+ tabIds: number[];
36
+ activeTabId: number | null;
37
+ primaryTabId: number | null;
38
+ }
39
+
40
+ export interface WorkspaceInfo extends WorkspaceRecord {
41
+ tabs: WorkspaceTab[];
42
+ }
43
+
44
+ export interface WorkspaceEnsureResult {
45
+ workspace: WorkspaceInfo;
46
+ created: boolean;
47
+ repaired: boolean;
48
+ repairActions: string[];
49
+ }
50
+
51
+ export interface WorkspaceTargetResolution {
52
+ tab: WorkspaceTab;
53
+ workspace: WorkspaceInfo | null;
54
+ resolution: 'explicit-tab' | 'explicit-workspace' | 'default-workspace' | 'browser-active';
55
+ createdWorkspace: boolean;
56
+ repaired: boolean;
57
+ repairActions: string[];
58
+ }
59
+
60
+ export interface WorkspaceStorage {
61
+ load(workspaceId: string): Promise<WorkspaceRecord | null>;
62
+ save(state: WorkspaceRecord): Promise<void>;
63
+ delete(workspaceId: string): Promise<void>;
64
+ list(): Promise<WorkspaceRecord[]>;
65
+ }
66
+
67
+ export interface WorkspaceBrowser {
68
+ getTab(tabId: number): Promise<WorkspaceTab | null>;
69
+ getActiveTab(): Promise<WorkspaceTab | null>;
70
+ listTabs(filter?: { windowId?: number }): Promise<WorkspaceTab[]>;
71
+ createTab(options: { windowId?: number; url?: string; active?: boolean }): Promise<WorkspaceTab>;
72
+ updateTab(tabId: number, options: { active?: boolean; url?: string }): Promise<WorkspaceTab>;
73
+ closeTab(tabId: number): Promise<void>;
74
+ getWindow(windowId: number): Promise<WorkspaceWindow | null>;
75
+ createWindow(options: { url?: string; focused?: boolean }): Promise<WorkspaceWindow>;
76
+ updateWindow(windowId: number, options: { focused?: boolean }): Promise<WorkspaceWindow>;
77
+ closeWindow(windowId: number): Promise<void>;
78
+ getGroup(groupId: number): Promise<WorkspaceGroup | null>;
79
+ groupTabs(tabIds: number[], groupId?: number): Promise<number>;
80
+ updateGroup(groupId: number, options: { title?: string; color?: WorkspaceColor; collapsed?: boolean }): Promise<WorkspaceGroup>;
81
+ }
82
+
83
+ interface WorkspaceWindowOwnership {
84
+ workspaceTabs: WorkspaceTab[];
85
+ foreignTabs: WorkspaceTab[];
86
+ }
87
+
88
+ export interface WorkspaceEnsureOptions {
89
+ workspaceId?: string;
90
+ focus?: boolean;
91
+ initialUrl?: string;
92
+ }
93
+
94
+ export interface WorkspaceOpenTabOptions {
95
+ workspaceId?: string;
96
+ url?: string;
97
+ active?: boolean;
98
+ focus?: boolean;
99
+ }
100
+
101
+ export interface WorkspaceResolveTargetOptions {
102
+ tabId?: number;
103
+ workspaceId?: string;
104
+ createIfMissing?: boolean;
105
+ }
106
+
107
+ class SessionBindingManager {
108
+ private readonly storage: WorkspaceStorage;
109
+ private readonly browser: WorkspaceBrowser;
110
+
111
+ constructor(storage: WorkspaceStorage, browser: WorkspaceBrowser) {
112
+ this.storage = storage;
113
+ this.browser = browser;
114
+ }
115
+
116
+ async getWorkspaceInfo(workspaceId: string): Promise<WorkspaceInfo | null> {
117
+ return this.inspectWorkspace(workspaceId);
118
+ }
119
+
120
+ async ensureWorkspace(options: WorkspaceEnsureOptions = {}): Promise<WorkspaceEnsureResult> {
121
+ const workspaceId = this.normalizeWorkspaceId(options.workspaceId);
122
+ const repairActions: string[] = [];
123
+ const initialUrl = options.initialUrl ?? DEFAULT_WORKSPACE_URL;
124
+ const persisted = await this.storage.load(workspaceId);
125
+ const created = !persisted;
126
+ let state = this.normalizeState(persisted, workspaceId);
127
+
128
+ const originalWindowId = state.windowId;
129
+ let window = state.windowId !== null ? await this.waitForWindow(state.windowId) : null;
130
+ let tabs: WorkspaceTab[] = [];
131
+ if (!window) {
132
+ const rebound = await this.rebindWorkspaceWindow(state);
133
+ if (rebound) {
134
+ window = rebound.window;
135
+ tabs = rebound.tabs;
136
+ if (originalWindowId !== rebound.window.id) {
137
+ repairActions.push('rebound-window');
138
+ }
139
+ }
140
+ }
141
+ if (!window) {
142
+ const createdWindow = await this.browser.createWindow({
143
+ url: initialUrl,
144
+ focused: options.focus === true
145
+ });
146
+ state.windowId = createdWindow.id;
147
+ state.groupId = null;
148
+ state.tabIds = [];
149
+ state.activeTabId = null;
150
+ state.primaryTabId = null;
151
+ window = createdWindow;
152
+ tabs = await this.waitForWindowTabs(createdWindow.id);
153
+ state.tabIds = tabs.map((tab) => tab.id);
154
+ if (state.primaryTabId === null) {
155
+ state.primaryTabId = tabs[0]?.id ?? null;
156
+ }
157
+ if (state.activeTabId === null) {
158
+ state.activeTabId = tabs.find((tab) => tab.active)?.id ?? tabs[0]?.id ?? null;
159
+ }
160
+ repairActions.push(created ? 'created-window' : 'recreated-window');
161
+ }
162
+
163
+ tabs = tabs.length > 0 ? tabs : await this.readTrackedTabs(state.tabIds, state.windowId);
164
+ const recoveredTabs = await this.recoverWorkspaceTabs(state, tabs);
165
+ if (recoveredTabs.length > tabs.length) {
166
+ tabs = recoveredTabs;
167
+ repairActions.push('recovered-tracked-tabs');
168
+ }
169
+ if (tabs.length !== state.tabIds.length) {
170
+ repairActions.push('pruned-missing-tabs');
171
+ }
172
+ state.tabIds = tabs.map((tab) => tab.id);
173
+
174
+ if (state.windowId !== null) {
175
+ const ownership = await this.inspectWorkspaceWindowOwnership(state, state.windowId);
176
+ if (ownership.foreignTabs.length > 0) {
177
+ const migrated = await this.moveWorkspaceIntoDedicatedWindow(state, ownership, initialUrl);
178
+ window = migrated.window;
179
+ tabs = migrated.tabs;
180
+ state.tabIds = tabs.map((tab) => tab.id);
181
+ repairActions.push('migrated-dirty-window');
182
+ }
183
+ }
184
+
185
+ if (tabs.length === 0) {
186
+ const primary = await this.createWorkspaceTab({
187
+ windowId: state.windowId,
188
+ url: initialUrl,
189
+ active: true
190
+ });
191
+ tabs = [primary];
192
+ state.tabIds = [primary.id];
193
+ state.primaryTabId = primary.id;
194
+ state.activeTabId = primary.id;
195
+ repairActions.push('created-primary-tab');
196
+ }
197
+
198
+ if (state.primaryTabId === null || !tabs.some((tab) => tab.id === state.primaryTabId)) {
199
+ state.primaryTabId = tabs[0]?.id ?? null;
200
+ repairActions.push('reassigned-primary-tab');
201
+ }
202
+
203
+ if (state.activeTabId === null || !tabs.some((tab) => tab.id === state.activeTabId)) {
204
+ state.activeTabId = state.primaryTabId ?? tabs[0]?.id ?? null;
205
+ repairActions.push('reassigned-active-tab');
206
+ }
207
+
208
+ let group = state.groupId !== null ? await this.browser.getGroup(state.groupId) : null;
209
+ if (!group || group.windowId !== state.windowId) {
210
+ const groupId = await this.browser.groupTabs(tabs.map((tab) => tab.id));
211
+ group = await this.browser.updateGroup(groupId, {
212
+ title: state.label,
213
+ color: state.color,
214
+ collapsed: false
215
+ });
216
+ state.groupId = group.id;
217
+ repairActions.push('recreated-group');
218
+ } else {
219
+ await this.browser.updateGroup(group.id, {
220
+ title: state.label,
221
+ color: state.color,
222
+ collapsed: false
223
+ });
224
+ }
225
+
226
+ const ungroupedIds = tabs.filter((tab) => tab.groupId !== state.groupId).map((tab) => tab.id);
227
+ if (ungroupedIds.length > 0) {
228
+ await this.browser.groupTabs(ungroupedIds, state.groupId ?? undefined);
229
+ repairActions.push('regrouped-tabs');
230
+ }
231
+
232
+ tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
233
+ tabs = await this.recoverWorkspaceTabs(state, tabs);
234
+ const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId) : null;
235
+ if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
236
+ tabs = [...tabs, activeTab];
237
+ }
238
+ if (tabs.length === 0 && state.primaryTabId !== null) {
239
+ const primaryTab = await this.waitForTrackedTab(state.primaryTabId, state.windowId);
240
+ if (primaryTab) {
241
+ tabs = [primaryTab];
242
+ }
243
+ }
244
+ state.tabIds = [...new Set(tabs.map((tab) => tab.id))];
245
+
246
+ if (options.focus === true && state.activeTabId !== null) {
247
+ await this.browser.updateTab(state.activeTabId, { active: true });
248
+ window = await this.browser.updateWindow(state.windowId!, { focused: true });
249
+ void window;
250
+ repairActions.push('focused-window');
251
+ }
252
+
253
+ await this.storage.save(state);
254
+
255
+ return {
256
+ workspace: {
257
+ ...state,
258
+ tabs
259
+ },
260
+ created,
261
+ repaired: repairActions.length > 0,
262
+ repairActions
263
+ };
264
+ }
265
+
266
+ async openTab(options: WorkspaceOpenTabOptions = {}): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab }> {
267
+ const workspaceId = this.normalizeWorkspaceId(options.workspaceId);
268
+ const hadWorkspace = (await this.loadWorkspaceRecord(workspaceId)) !== null;
269
+ const ensured = await this.ensureWorkspace({
270
+ workspaceId,
271
+ focus: false,
272
+ initialUrl: hadWorkspace ? options.url ?? DEFAULT_WORKSPACE_URL : DEFAULT_WORKSPACE_URL
273
+ });
274
+ let state = { ...ensured.workspace, tabIds: [...ensured.workspace.tabIds], tabs: [...ensured.workspace.tabs] };
275
+ if (state.windowId !== null && state.tabs.length === 0) {
276
+ const rebound = await this.rebindWorkspaceWindow(state);
277
+ if (rebound) {
278
+ state.windowId = rebound.window.id;
279
+ state.tabs = rebound.tabs;
280
+ state.tabIds = [...new Set(rebound.tabs.map((tab) => tab.id))];
281
+ }
282
+ }
283
+ const active = options.active === true;
284
+ const desiredUrl = options.url ?? DEFAULT_WORKSPACE_URL;
285
+ let reusablePrimaryTab = await this.resolveReusablePrimaryTab(
286
+ state,
287
+ ensured.created ||
288
+ ensured.repairActions.includes('recreated-window') ||
289
+ ensured.repairActions.includes('created-primary-tab') ||
290
+ ensured.repairActions.includes('migrated-dirty-window')
291
+ );
292
+
293
+ let createdTab: WorkspaceTab;
294
+ try {
295
+ createdTab = reusablePrimaryTab
296
+ ? await this.browser.updateTab(reusablePrimaryTab.id, {
297
+ url: desiredUrl,
298
+ active
299
+ })
300
+ : await this.createWorkspaceTab({
301
+ windowId: state.windowId,
302
+ url: desiredUrl,
303
+ active
304
+ });
305
+ } catch (error) {
306
+ if (!this.isMissingWindowError(error)) {
307
+ throw error;
308
+ }
309
+ const repaired = await this.ensureWorkspace({
310
+ workspaceId,
311
+ focus: false,
312
+ initialUrl: desiredUrl
313
+ });
314
+ state = { ...repaired.workspace };
315
+ reusablePrimaryTab = await this.resolveReusablePrimaryTab(state, true);
316
+ createdTab = reusablePrimaryTab
317
+ ? await this.browser.updateTab(reusablePrimaryTab.id, {
318
+ url: desiredUrl,
319
+ active
320
+ })
321
+ : await this.createWorkspaceTab({
322
+ windowId: state.windowId,
323
+ url: desiredUrl,
324
+ active
325
+ });
326
+ }
327
+ const nextTabIds = [...new Set([...state.tabIds, createdTab.id])];
328
+ const groupId = await this.browser.groupTabs([createdTab.id], state.groupId ?? undefined);
329
+ await this.browser.updateGroup(groupId, {
330
+ title: state.label,
331
+ color: state.color,
332
+ collapsed: false
333
+ });
334
+ const nextState: WorkspaceRecord = {
335
+ id: state.id,
336
+ label: state.label,
337
+ color: state.color,
338
+ windowId: state.windowId,
339
+ groupId,
340
+ tabIds: nextTabIds,
341
+ activeTabId: createdTab.id,
342
+ primaryTabId: state.primaryTabId ?? createdTab.id
343
+ };
344
+
345
+ if (options.focus === true) {
346
+ await this.browser.updateTab(createdTab.id, { active: true });
347
+ await this.browser.updateWindow(state.windowId!, { focused: true });
348
+ }
349
+
350
+ await this.storage.save(nextState);
351
+ const tabs = await this.readTrackedTabs(nextState.tabIds, nextState.windowId);
352
+ const tab = tabs.find((item) => item.id === createdTab.id) ?? createdTab;
353
+ return {
354
+ workspace: {
355
+ ...nextState,
356
+ tabs
357
+ },
358
+ tab
359
+ };
360
+ }
361
+
362
+ async listTabs(workspaceId: string): Promise<{ workspace: WorkspaceInfo; tabs: WorkspaceTab[] }> {
363
+ const ensured = await this.inspectWorkspace(workspaceId);
364
+ if (!ensured) {
365
+ throw new Error(`Workspace ${workspaceId} does not exist`);
366
+ }
367
+ return {
368
+ workspace: ensured,
369
+ tabs: ensured.tabs
370
+ };
371
+ }
372
+
373
+ async getActiveTab(workspaceId: string): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab | null }> {
374
+ const ensured = await this.inspectWorkspace(workspaceId);
375
+ if (!ensured) {
376
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
377
+ return {
378
+ workspace: {
379
+ ...this.normalizeState(null, normalizedWorkspaceId),
380
+ tabs: []
381
+ },
382
+ tab: null
383
+ };
384
+ }
385
+ return {
386
+ workspace: ensured,
387
+ tab: ensured.tabs.find((tab) => tab.id === ensured.activeTabId) ?? null
388
+ };
389
+ }
390
+
391
+ async setActiveTab(tabId: number, workspaceId: string): Promise<{ workspace: WorkspaceInfo; tab: WorkspaceTab }> {
392
+ const ensured = await this.ensureWorkspace({ workspaceId });
393
+ if (!ensured.workspace.tabIds.includes(tabId)) {
394
+ throw new Error(`Tab ${tabId} does not belong to workspace ${workspaceId}`);
395
+ }
396
+ const nextState: WorkspaceRecord = {
397
+ id: ensured.workspace.id,
398
+ label: ensured.workspace.label,
399
+ color: ensured.workspace.color,
400
+ windowId: ensured.workspace.windowId,
401
+ groupId: ensured.workspace.groupId,
402
+ tabIds: [...ensured.workspace.tabIds],
403
+ activeTabId: tabId,
404
+ primaryTabId: ensured.workspace.primaryTabId ?? tabId
405
+ };
406
+ await this.storage.save(nextState);
407
+ const tabs = await this.readTrackedTabs(nextState.tabIds, nextState.windowId);
408
+ const tab = tabs.find((item) => item.id === tabId);
409
+ if (!tab) {
410
+ throw new Error(`Tab ${tabId} is missing from workspace ${workspaceId}`);
411
+ }
412
+ return {
413
+ workspace: {
414
+ ...nextState,
415
+ tabs
416
+ },
417
+ tab
418
+ };
419
+ }
420
+
421
+ async focus(workspaceId: string): Promise<{ ok: true; workspace: WorkspaceInfo }> {
422
+ const ensured = await this.ensureWorkspace({ workspaceId, focus: false });
423
+ if (ensured.workspace.activeTabId !== null) {
424
+ await this.browser.updateTab(ensured.workspace.activeTabId, { active: true });
425
+ }
426
+ if (ensured.workspace.windowId !== null) {
427
+ await this.browser.updateWindow(ensured.workspace.windowId, { focused: true });
428
+ }
429
+ const refreshed = await this.ensureWorkspace({ workspaceId, focus: false });
430
+ return { ok: true, workspace: refreshed.workspace };
431
+ }
432
+
433
+ async reset(options: WorkspaceEnsureOptions = {}): Promise<WorkspaceEnsureResult> {
434
+ const workspaceId = this.normalizeWorkspaceId(options.workspaceId);
435
+ await this.close(workspaceId);
436
+ return this.ensureWorkspace({
437
+ ...options,
438
+ workspaceId
439
+ });
440
+ }
441
+
442
+ async close(workspaceId: string): Promise<{ ok: true }> {
443
+ const state = await this.loadWorkspaceRecord(workspaceId);
444
+ if (!state) {
445
+ await this.storage.delete(workspaceId);
446
+ return { ok: true };
447
+ }
448
+ // Clear persisted state before closing the window so tab/window removal
449
+ // listeners cannot race and resurrect an empty workspace record.
450
+ await this.storage.delete(workspaceId);
451
+ if (state.windowId !== null) {
452
+ const existingWindow = await this.browser.getWindow(state.windowId);
453
+ if (existingWindow) {
454
+ await this.browser.closeWindow(state.windowId);
455
+ }
456
+ }
457
+ return { ok: true };
458
+ }
459
+
460
+ async resolveTarget(options: WorkspaceResolveTargetOptions = {}): Promise<WorkspaceTargetResolution> {
461
+ if (typeof options.tabId === 'number') {
462
+ const explicitTab = await this.browser.getTab(options.tabId);
463
+ if (!explicitTab) {
464
+ throw new Error(`No tab with id ${options.tabId}`);
465
+ }
466
+ return {
467
+ tab: explicitTab,
468
+ workspace: null,
469
+ resolution: 'explicit-tab',
470
+ createdWorkspace: false,
471
+ repaired: false,
472
+ repairActions: []
473
+ };
474
+ }
475
+
476
+ const explicitWorkspaceId = typeof options.workspaceId === 'string' ? this.normalizeWorkspaceId(options.workspaceId) : undefined;
477
+ if (explicitWorkspaceId) {
478
+ const ensured = await this.ensureWorkspace({
479
+ workspaceId: explicitWorkspaceId,
480
+ focus: false
481
+ });
482
+ return this.buildWorkspaceResolution(ensured, 'explicit-workspace');
483
+ }
484
+
485
+ if (options.createIfMissing !== true) {
486
+ const activeTab = await this.browser.getActiveTab();
487
+ if (!activeTab) {
488
+ throw new Error('No active tab');
489
+ }
490
+ return {
491
+ tab: activeTab,
492
+ workspace: null,
493
+ resolution: 'browser-active',
494
+ createdWorkspace: false,
495
+ repaired: false,
496
+ repairActions: []
497
+ };
498
+ }
499
+
500
+ throw new Error('workspaceId is required when createIfMissing is true');
501
+ }
502
+
503
+ private normalizeWorkspaceId(workspaceId?: string): string {
504
+ const candidate = workspaceId?.trim();
505
+ if (!candidate) {
506
+ throw new Error('workspaceId is required');
507
+ }
508
+ return candidate;
509
+ }
510
+
511
+ private normalizeState(state: WorkspaceRecord | null, workspaceId: string): WorkspaceRecord {
512
+ return {
513
+ id: workspaceId,
514
+ label: state?.label ?? DEFAULT_WORKSPACE_LABEL,
515
+ color: state?.color ?? DEFAULT_WORKSPACE_COLOR,
516
+ windowId: state?.windowId ?? null,
517
+ groupId: state?.groupId ?? null,
518
+ tabIds: state?.tabIds ?? [],
519
+ activeTabId: state?.activeTabId ?? null,
520
+ primaryTabId: state?.primaryTabId ?? null
521
+ };
522
+ }
523
+
524
+ async listWorkspaceRecords(): Promise<WorkspaceRecord[]> {
525
+ return await this.storage.list();
526
+ }
527
+
528
+ private async loadWorkspaceRecord(workspaceId: string): Promise<WorkspaceRecord | null> {
529
+ const normalizedWorkspaceId = this.normalizeWorkspaceId(workspaceId);
530
+ const state = await this.storage.load(normalizedWorkspaceId);
531
+ if (!state || state.id !== normalizedWorkspaceId) {
532
+ return null;
533
+ }
534
+ return this.normalizeState(state, normalizedWorkspaceId);
535
+ }
536
+
537
+ private async buildWorkspaceResolution(
538
+ ensured: WorkspaceEnsureResult,
539
+ resolution: 'explicit-workspace' | 'default-workspace'
540
+ ): Promise<WorkspaceTargetResolution> {
541
+ const tab = ensured.workspace.tabs.find((item) => item.id === ensured.workspace.activeTabId) ?? ensured.workspace.tabs[0] ?? null;
542
+ if (tab) {
543
+ return {
544
+ tab,
545
+ workspace: ensured.workspace,
546
+ resolution,
547
+ createdWorkspace: ensured.created,
548
+ repaired: ensured.repaired,
549
+ repairActions: ensured.repairActions
550
+ };
551
+ }
552
+
553
+ if (ensured.workspace.activeTabId !== null) {
554
+ const activeWorkspaceTab = await this.waitForTrackedTab(ensured.workspace.activeTabId, ensured.workspace.windowId);
555
+ if (activeWorkspaceTab) {
556
+ return {
557
+ tab: activeWorkspaceTab,
558
+ workspace: ensured.workspace,
559
+ resolution,
560
+ createdWorkspace: ensured.created,
561
+ repaired: ensured.repaired,
562
+ repairActions: ensured.repairActions
563
+ };
564
+ }
565
+ }
566
+
567
+ const activeTab = await this.browser.getActiveTab();
568
+ if (!activeTab) {
569
+ throw new Error('No active tab');
570
+ }
571
+ return {
572
+ tab: activeTab,
573
+ workspace: null,
574
+ resolution: 'browser-active',
575
+ createdWorkspace: ensured.created,
576
+ repaired: ensured.repaired,
577
+ repairActions: ensured.repairActions
578
+ };
579
+ }
580
+
581
+ private async readTrackedTabs(tabIds: number[], windowId: number | null): Promise<WorkspaceTab[]> {
582
+ const tabs = (
583
+ await Promise.all(
584
+ tabIds.map(async (tabId) => {
585
+ const tab = await this.browser.getTab(tabId);
586
+ if (!tab) {
587
+ return null;
588
+ }
589
+ if (windowId !== null && tab.windowId !== windowId) {
590
+ return null;
591
+ }
592
+ return tab;
593
+ })
594
+ )
595
+ ).filter((tab): tab is WorkspaceTab => tab !== null);
596
+ return tabs;
597
+ }
598
+
599
+ private async readLooseTrackedTabs(tabIds: number[]): Promise<WorkspaceTab[]> {
600
+ const tabs = (
601
+ await Promise.all(
602
+ tabIds.map(async (tabId) => {
603
+ return await this.browser.getTab(tabId);
604
+ })
605
+ )
606
+ ).filter((tab): tab is WorkspaceTab => tab !== null);
607
+ return tabs;
608
+ }
609
+
610
+ private collectCandidateTabIds(state: WorkspaceRecord): number[] {
611
+ return [...new Set(state.tabIds.concat([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number')))];
612
+ }
613
+
614
+ private async rebindWorkspaceWindow(state: WorkspaceRecord): Promise<{ window: WorkspaceWindow; tabs: WorkspaceTab[] } | null> {
615
+ const candidateWindowIds: number[] = [];
616
+ const pushWindowId = (windowId: number | null | undefined): void => {
617
+ if (typeof windowId !== 'number') {
618
+ return;
619
+ }
620
+ if (!candidateWindowIds.includes(windowId)) {
621
+ candidateWindowIds.push(windowId);
622
+ }
623
+ };
624
+
625
+ const group = state.groupId !== null ? await this.browser.getGroup(state.groupId) : null;
626
+ pushWindowId(group?.windowId);
627
+
628
+ const trackedTabs = await this.readLooseTrackedTabs(this.collectCandidateTabIds(state));
629
+ for (const tab of trackedTabs) {
630
+ pushWindowId(tab.windowId);
631
+ }
632
+
633
+ for (const candidateWindowId of candidateWindowIds) {
634
+ const window = await this.waitForWindow(candidateWindowId);
635
+ if (!window) {
636
+ continue;
637
+ }
638
+ let tabs = await this.readTrackedTabs(this.collectCandidateTabIds(state), candidateWindowId);
639
+ if (tabs.length === 0 && group?.id !== null && group?.windowId === candidateWindowId) {
640
+ const windowTabs = await this.waitForWindowTabs(candidateWindowId, 750);
641
+ tabs = windowTabs.filter((tab) => tab.groupId === group.id);
642
+ }
643
+ if (tabs.length === 0) {
644
+ tabs = trackedTabs.filter((tab) => tab.windowId === candidateWindowId);
645
+ }
646
+ state.windowId = candidateWindowId;
647
+ if (tabs.length > 0) {
648
+ state.tabIds = [...new Set(tabs.map((tab) => tab.id))];
649
+ if (state.primaryTabId === null || !state.tabIds.includes(state.primaryTabId)) {
650
+ state.primaryTabId = tabs[0]?.id ?? null;
651
+ }
652
+ if (state.activeTabId === null || !state.tabIds.includes(state.activeTabId)) {
653
+ state.activeTabId = tabs.find((tab) => tab.active)?.id ?? state.primaryTabId;
654
+ }
655
+ }
656
+ return { window, tabs };
657
+ }
658
+
659
+ return null;
660
+ }
661
+
662
+ private async inspectWorkspaceWindowOwnership(state: WorkspaceRecord, windowId: number): Promise<WorkspaceWindowOwnership> {
663
+ const windowTabs = await this.waitForWindowTabs(windowId, 500);
664
+ const trackedIds = new Set(this.collectCandidateTabIds(state));
665
+ return {
666
+ workspaceTabs: windowTabs.filter((tab) => trackedIds.has(tab.id) || (state.groupId !== null && tab.groupId === state.groupId)),
667
+ foreignTabs: windowTabs.filter((tab) => !trackedIds.has(tab.id) && (state.groupId === null || tab.groupId !== state.groupId))
668
+ };
669
+ }
670
+
671
+ private async moveWorkspaceIntoDedicatedWindow(
672
+ state: WorkspaceRecord,
673
+ ownership: WorkspaceWindowOwnership,
674
+ initialUrl: string
675
+ ): Promise<{ window: WorkspaceWindow; tabs: WorkspaceTab[] }> {
676
+ const sourceTabs = this.orderWorkspaceTabsForMigration(state, ownership.workspaceTabs);
677
+ const seedUrl = sourceTabs[0]?.url ?? initialUrl;
678
+ const window = await this.browser.createWindow({
679
+ url: seedUrl || DEFAULT_WORKSPACE_URL,
680
+ focused: false
681
+ });
682
+ const recreatedTabs = await this.waitForWindowTabs(window.id);
683
+ const firstTab = recreatedTabs[0] ?? null;
684
+ const tabIdMap = new Map<number, number>();
685
+ if (sourceTabs[0] && firstTab) {
686
+ tabIdMap.set(sourceTabs[0].id, firstTab.id);
687
+ }
688
+
689
+ for (const sourceTab of sourceTabs.slice(1)) {
690
+ const recreated = await this.createWorkspaceTab({
691
+ windowId: window.id,
692
+ url: sourceTab.url,
693
+ active: false
694
+ });
695
+ recreatedTabs.push(recreated);
696
+ tabIdMap.set(sourceTab.id, recreated.id);
697
+ }
698
+
699
+ const nextPrimaryTabId =
700
+ (state.primaryTabId !== null ? tabIdMap.get(state.primaryTabId) : undefined) ??
701
+ firstTab?.id ??
702
+ recreatedTabs[0]?.id ??
703
+ null;
704
+ const nextActiveTabId =
705
+ (state.activeTabId !== null ? tabIdMap.get(state.activeTabId) : undefined) ?? nextPrimaryTabId ?? recreatedTabs[0]?.id ?? null;
706
+ if (nextActiveTabId !== null) {
707
+ await this.browser.updateTab(nextActiveTabId, { active: true });
708
+ }
709
+
710
+ state.windowId = window.id;
711
+ state.groupId = null;
712
+ state.tabIds = recreatedTabs.map((tab) => tab.id);
713
+ state.primaryTabId = nextPrimaryTabId;
714
+ state.activeTabId = nextActiveTabId;
715
+
716
+ for (const workspaceTab of ownership.workspaceTabs) {
717
+ await this.browser.closeTab(workspaceTab.id);
718
+ }
719
+
720
+ return {
721
+ window,
722
+ tabs: await this.readTrackedTabs(state.tabIds, state.windowId)
723
+ };
724
+ }
725
+
726
+ private orderWorkspaceTabsForMigration(state: WorkspaceRecord, tabs: WorkspaceTab[]): WorkspaceTab[] {
727
+ const ordered: WorkspaceTab[] = [];
728
+ const seen = new Set<number>();
729
+ const pushById = (tabId: number | null): void => {
730
+ if (typeof tabId !== 'number') {
731
+ return;
732
+ }
733
+ const tab = tabs.find((candidate) => candidate.id === tabId);
734
+ if (!tab || seen.has(tab.id)) {
735
+ return;
736
+ }
737
+ ordered.push(tab);
738
+ seen.add(tab.id);
739
+ };
740
+
741
+ pushById(state.primaryTabId);
742
+ pushById(state.activeTabId);
743
+ for (const tab of tabs) {
744
+ if (seen.has(tab.id)) {
745
+ continue;
746
+ }
747
+ ordered.push(tab);
748
+ seen.add(tab.id);
749
+ }
750
+ return ordered;
751
+ }
752
+
753
+ private async recoverWorkspaceTabs(state: WorkspaceRecord, existingTabs: WorkspaceTab[]): Promise<WorkspaceTab[]> {
754
+ if (state.windowId === null) {
755
+ return existingTabs;
756
+ }
757
+
758
+ const candidates = await this.waitForWindowTabs(state.windowId, 500);
759
+ if (candidates.length === 0) {
760
+ return existingTabs;
761
+ }
762
+
763
+ const trackedIds = new Set(state.tabIds);
764
+ const trackedTabs = candidates.filter((tab) => trackedIds.has(tab.id));
765
+ if (trackedTabs.length > existingTabs.length) {
766
+ return trackedTabs;
767
+ }
768
+
769
+ if (state.groupId !== null) {
770
+ const groupedTabs = candidates.filter((tab) => tab.groupId === state.groupId);
771
+ if (groupedTabs.length > 0) {
772
+ return groupedTabs;
773
+ }
774
+ }
775
+
776
+ const preferredIds = new Set([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number'));
777
+ const preferredTabs = candidates.filter((tab) => preferredIds.has(tab.id));
778
+ if (preferredTabs.length > existingTabs.length) {
779
+ return preferredTabs;
780
+ }
781
+
782
+ return existingTabs;
783
+ }
784
+
785
+ private async createWorkspaceTab(options: { windowId: number | null; url: string; active: boolean }): Promise<WorkspaceTab> {
786
+ if (options.windowId === null) {
787
+ throw new Error('Workspace window is unavailable');
788
+ }
789
+
790
+ const deadline = Date.now() + 1_500;
791
+ let lastError: Error | null = null;
792
+
793
+ while (Date.now() < deadline) {
794
+ try {
795
+ return await this.browser.createTab({
796
+ windowId: options.windowId,
797
+ url: options.url,
798
+ active: options.active
799
+ });
800
+ } catch (error) {
801
+ if (!this.isMissingWindowError(error)) {
802
+ throw error;
803
+ }
804
+ lastError = error instanceof Error ? error : new Error(String(error));
805
+ await this.delay(50);
806
+ }
807
+ }
808
+
809
+ throw lastError ?? new Error(`No window with id: ${options.windowId}.`);
810
+ }
811
+
812
+ private async inspectWorkspace(workspaceId: string): Promise<WorkspaceInfo | null> {
813
+ const state = await this.loadWorkspaceRecord(workspaceId);
814
+ if (!state) {
815
+ return null;
816
+ }
817
+
818
+ let tabs = await this.readTrackedTabs(state.tabIds, state.windowId);
819
+ const activeTab = state.activeTabId !== null ? await this.waitForTrackedTab(state.activeTabId, state.windowId, 300) : null;
820
+ if (activeTab && !tabs.some((tab) => tab.id === activeTab.id)) {
821
+ tabs = [...tabs, activeTab];
822
+ }
823
+ if (tabs.length === 0 && state.primaryTabId !== null) {
824
+ const primaryTab = await this.waitForTrackedTab(state.primaryTabId, state.windowId, 300);
825
+ if (primaryTab) {
826
+ tabs = [primaryTab];
827
+ }
828
+ }
829
+
830
+ return {
831
+ ...state,
832
+ tabIds: [...new Set(state.tabIds.concat(tabs.map((tab) => tab.id)))],
833
+ tabs
834
+ };
835
+ }
836
+
837
+ private async resolveReusablePrimaryTab(workspace: WorkspaceInfo, allowReuse: boolean): Promise<WorkspaceTab | null> {
838
+ if (workspace.windowId === null) {
839
+ return null;
840
+ }
841
+ if (workspace.primaryTabId !== null) {
842
+ const trackedPrimary = workspace.tabs.find((tab) => tab.id === workspace.primaryTabId) ?? (await this.waitForTrackedTab(workspace.primaryTabId, workspace.windowId));
843
+ if (trackedPrimary && (allowReuse || this.isReusableBlankWorkspaceTab(trackedPrimary, workspace))) {
844
+ return trackedPrimary;
845
+ }
846
+ }
847
+ const windowTabs = await this.waitForWindowTabs(workspace.windowId, 750);
848
+ if (windowTabs.length !== 1) {
849
+ return null;
850
+ }
851
+ const candidate = windowTabs[0]!;
852
+ if (allowReuse || this.isReusableBlankWorkspaceTab(candidate, workspace)) {
853
+ return candidate;
854
+ }
855
+ return null;
856
+ }
857
+
858
+ private isReusableBlankWorkspaceTab(tab: WorkspaceTab, workspace: WorkspaceInfo): boolean {
859
+ if (workspace.tabIds.length > 1) {
860
+ return false;
861
+ }
862
+ const normalizedUrl = tab.url.trim().toLowerCase();
863
+ return normalizedUrl === '' || normalizedUrl === DEFAULT_WORKSPACE_URL;
864
+ }
865
+
866
+ private async waitForWindow(windowId: number, timeoutMs = 750): Promise<WorkspaceWindow | null> {
867
+ const deadline = Date.now() + timeoutMs;
868
+ while (Date.now() < deadline) {
869
+ const window = await this.browser.getWindow(windowId);
870
+ if (window) {
871
+ return window;
872
+ }
873
+ await this.delay(50);
874
+ }
875
+ return null;
876
+ }
877
+
878
+ private async waitForTrackedTab(tabId: number, windowId: number | null, timeoutMs = 1_000): Promise<WorkspaceTab | null> {
879
+ const deadline = Date.now() + timeoutMs;
880
+ while (Date.now() < deadline) {
881
+ const tab = await this.browser.getTab(tabId);
882
+ if (tab && (windowId === null || tab.windowId === windowId)) {
883
+ return tab;
884
+ }
885
+ await this.delay(50);
886
+ }
887
+ return null;
888
+ }
889
+
890
+ private async waitForWindowTabs(windowId: number, timeoutMs = 1_000): Promise<WorkspaceTab[]> {
891
+ const deadline = Date.now() + timeoutMs;
892
+ while (Date.now() < deadline) {
893
+ const tabs = await this.browser.listTabs({ windowId });
894
+ if (tabs.length > 0) {
895
+ return tabs;
896
+ }
897
+ await this.delay(50);
898
+ }
899
+ return [];
900
+ }
901
+
902
+ private async delay(ms: number): Promise<void> {
903
+ await new Promise((resolve) => setTimeout(resolve, ms));
904
+ }
905
+
906
+ private isMissingWindowError(error: unknown): boolean {
907
+ const message = error instanceof Error ? error.message : String(error);
908
+ return message.toLowerCase().includes('no window with id');
909
+ }
910
+ }
911
+
912
+ export { SessionBindingManager, SessionBindingManager as WorkspaceManager };