browser-workspace 0.4.1 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +16 -2
  2. package/SKILL.md +60 -37
  3. package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
  4. package/bin/browser-workspace +174 -13
  5. package/package.json +1 -1
  6. package/platform_runner.py +23 -6
  7. package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
  8. package/platforms/chatgpt/tests/test_contract.py +7 -0
  9. package/platforms/wechat-channels/actions/_common.py +1 -1
  10. package/platforms/wechat-channels/actions/_post.py +37 -38
  11. package/platforms/wechat-channels/manifest.yaml +19 -13
  12. package/platforms/xhs/actions/_post.py +109 -20
  13. package/platforms/xhs/manifest.yaml +14 -5
  14. package/platforms/xhs/tests/test_contract.py +17 -0
  15. package/platforms/youtube/actions/_post.py +7 -4
  16. package/pyproject.toml +6 -2
  17. package/session_client.py +21 -11
  18. package/session_daemon.py +115 -9
  19. package/src/browser_workspace.egg-info/PKG-INFO +2 -1
  20. package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
  21. package/src/browser_workspace.egg-info/entry_points.txt +2 -0
  22. package/src/browser_workspace.egg-info/requires.txt +1 -0
  23. package/src/browser_workspace.egg-info/top_level.txt +1 -1
  24. package/src/browser_workspace_app/__init__.py +1 -0
  25. package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
  26. package/src/browser_workspace_app/cli.py +236 -0
  27. package/src/browser_workspace_app/extension/manifest.json +26 -0
  28. package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
  29. package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
  30. package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
  31. package/src/browser_workspace_app/extension/workspace.html +8 -0
  32. package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
  33. package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
  34. package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
  35. package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
  36. package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
  37. package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
  38. package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
  39. package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
  40. package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
  41. package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
  42. package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
  43. package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
  44. package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
  45. package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
  46. package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
  47. package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
  48. package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
  49. package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
  50. package/src/browser_workspace_app/managed_chrome.py +244 -0
  51. package/src/browser_workspace_app/node/admin.mjs +44 -0
  52. package/src/browser_workspace_app/package.json +36 -0
  53. package/src/browser_workspace_app/platform_runner.py +204 -0
  54. package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
  55. package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
  56. package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
  57. package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
  58. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
  59. package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
  60. package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
  61. package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
  62. package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
  63. package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
  64. package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
  65. package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
  66. package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
  67. package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
  68. package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
  69. package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
  70. package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
  71. package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
  72. package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
  73. package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
  74. package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
  75. package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
  76. package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
  77. package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
  78. package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
  79. package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
  80. package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
  81. package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
  82. package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
  83. package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
  84. package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
  85. package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
  86. package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
  87. package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
  88. package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
  89. package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
  90. package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
  91. package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
  92. package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
  93. package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
  94. package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
  95. package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
  96. package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
  97. package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
  98. package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
  99. package/src/browser_workspace_app/pyproject.toml +33 -0
  100. package/src/browser_workspace_app/session_client.py +107 -0
  101. package/src/browser_workspace_app/session_daemon.py +431 -0
  102. package/src/browser_harness/__init__.py +0 -2
  103. package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
  104. package/src/browser_harness/_ipc.py +0 -201
  105. package/src/browser_harness/admin.py +0 -1588
  106. package/src/browser_harness/auth.py +0 -546
  107. package/src/browser_harness/daemon.py +0 -894
  108. package/src/browser_harness/helpers.py +0 -668
  109. package/src/browser_harness/macos.py +0 -131
  110. package/src/browser_harness/mcp_cli.py +0 -16
  111. package/src/browser_harness/paths.py +0 -49
  112. package/src/browser_harness/recorder.py +0 -343
  113. package/src/browser_harness/run.py +0 -413
  114. package/src/browser_harness/telemetry.py +0 -308
  115. package/src/browser_harness/video-template.html +0 -970
  116. package/src/browser_harness/video.py +0 -749
  117. package/src/browser_harness/video_render.py +0 -524
@@ -0,0 +1,680 @@
1
+ const STORAGE_KEY = "browserWorkspaceManager.v1";
2
+ const MIN_POOL_SIZE = 1;
3
+ const MAX_POOL_SIZE = 64;
4
+ const EXTENSION_MANAGEMENT_RECENCY_MS = 15_000;
5
+ const STALE_LEASE_MS = 5 * 60 * 1000;
6
+
7
+ export function normalizeWorkspaceName(value) {
8
+ const name = String(value || "").trim();
9
+ if (!name) throw new Error("Workspace name is required");
10
+ if (name.length > 80) throw new Error("Workspace name must be at most 80 characters");
11
+ return name;
12
+ }
13
+
14
+ export function normalizePoolSize(value, fallback = 8) {
15
+ const number = Number(value ?? fallback);
16
+ if (!Number.isInteger(number) || number < MIN_POOL_SIZE || number > MAX_POOL_SIZE) {
17
+ throw new Error(`Pool size must be an integer between ${MIN_POOL_SIZE} and ${MAX_POOL_SIZE}`);
18
+ }
19
+ return number;
20
+ }
21
+
22
+ function queryString(workspace, slot) {
23
+ return new URLSearchParams({ workspace, role: "idle", slot: String(slot) }).toString();
24
+ }
25
+
26
+ export class WorkspaceManager {
27
+ constructor(chromeApi) {
28
+ this.chrome = chromeApi;
29
+ this.recentExtensionManagementByWindow = new Map();
30
+ this.ownedTabIds = new Set();
31
+ }
32
+
33
+ isOwnedTab(tab) {
34
+ const tabId = Number(tab?.id);
35
+ if (Number.isInteger(tabId) && this.ownedTabIds.has(tabId)) return true;
36
+ const meta = this.parseManagedUrl(this.tabUrl(tab));
37
+ return Boolean(meta?.workspace);
38
+ }
39
+
40
+ markOwned(tabId) {
41
+ const id = Number(tabId);
42
+ if (Number.isInteger(id)) this.ownedTabIds.add(id);
43
+ }
44
+
45
+ idleUrl(workspace, slot) {
46
+ return this.chrome.runtime.getURL(`workspace.html?${queryString(workspace, slot)}`);
47
+ }
48
+
49
+ identityTitle(tabId) {
50
+ return `__BW_TAB_${tabId}__`;
51
+ }
52
+
53
+ identityUrl(workspace, tabId) {
54
+ const query = new URLSearchParams({
55
+ workspace,
56
+ role: "lease",
57
+ slot: String(tabId),
58
+ }).toString();
59
+ return this.chrome.runtime.getURL(`workspace.html?${query}`);
60
+ }
61
+
62
+ parseManagedUrl(url) {
63
+ try {
64
+ const parsed = new URL(url);
65
+ const own = new URL(this.chrome.runtime.getURL("workspace.html"));
66
+ if (parsed.origin !== own.origin || parsed.pathname !== own.pathname) return null;
67
+ return {
68
+ workspace: parsed.searchParams.get("workspace") || "",
69
+ role: parsed.searchParams.get("role") || "",
70
+ slot: parsed.searchParams.get("slot") || "",
71
+ };
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ async loadConfig() {
78
+ const stored = (await this.chrome.storage.local.get(STORAGE_KEY))[STORAGE_KEY];
79
+ return stored && typeof stored === "object" ? stored : { workspaces: {} };
80
+ }
81
+
82
+ async saveConfig(config) {
83
+ await this.chrome.storage.local.set({ [STORAGE_KEY]: config });
84
+ }
85
+
86
+ async configuredWorkspace(name) {
87
+ const config = await this.loadConfig();
88
+ return { config, entry: config.workspaces[name] || null };
89
+ }
90
+
91
+ async setPoolSize(name, poolSize) {
92
+ const { config, entry } = await this.configuredWorkspace(name);
93
+ config.workspaces[name] = { ...(entry || {}), poolSize };
94
+ await this.saveConfig(config);
95
+ }
96
+
97
+ async removeConfig(name) {
98
+ const config = await this.loadConfig();
99
+ delete config.workspaces[name];
100
+ await this.saveConfig(config);
101
+ }
102
+
103
+ async removeDeletedGroup(group) {
104
+ const name = String(group?.title || "").trim();
105
+ if (!name) return null;
106
+
107
+ const { entry } = await this.configuredWorkspace(name);
108
+ if (!entry) return null;
109
+
110
+ const remaining = await this.workspaceGroup(name);
111
+ if (remaining) return null;
112
+
113
+ await this.removeConfig(name);
114
+ return { name, deleted: true };
115
+ }
116
+
117
+ async workspaceGroup(name) {
118
+ const groups = await this.chrome.tabGroups.query({});
119
+ const matches = groups.filter((group) => group.title === name);
120
+ if (matches.length > 1) {
121
+ throw new Error(`Workspace ${name} is ambiguous: ${matches.length} groups have that title`);
122
+ }
123
+ return matches[0] || null;
124
+ }
125
+
126
+ async groupTabs(groupId) {
127
+ if (!Number.isInteger(groupId) || groupId < 0) return [];
128
+ return await this.chrome.tabs.query({ groupId });
129
+ }
130
+
131
+ tabUrl(tab) {
132
+ return String(tab?.pendingUrl || tab?.url || "");
133
+ }
134
+
135
+ isExtensionManagementUrl(url) {
136
+ return /^chrome:\/\/extensions(?:\/|\?|#|$)/.test(String(url || ""));
137
+ }
138
+
139
+ isExtensionPageUrl(url) {
140
+ return /^chrome-extension:\/\//.test(String(url || ""));
141
+ }
142
+
143
+ async noteActivatedTab(activeInfo, now = Date.now()) {
144
+ const tabId = Number(activeInfo?.tabId);
145
+ const windowId = Number(activeInfo?.windowId);
146
+ if (!Number.isInteger(tabId) || !Number.isInteger(windowId)) return null;
147
+ if (typeof this.chrome.tabs.get !== "function") return null;
148
+
149
+ const tab = await this.chrome.tabs.get(tabId);
150
+ if (!this.isExtensionManagementUrl(this.tabUrl(tab))) return null;
151
+
152
+ this.recentExtensionManagementByWindow.set(windowId, now);
153
+ return { windowId, tabId, recordedAt: now };
154
+ }
155
+
156
+ async uniqueConfiguredWorkspace() {
157
+ const config = await this.loadConfig();
158
+ const names = Object.keys(config.workspaces || {});
159
+ if (!names.length) return null;
160
+
161
+ const groups = await this.chrome.tabGroups.query({});
162
+ const candidates = [];
163
+ for (const name of names) {
164
+ const matches = groups.filter((group) => group.title === name);
165
+ if (matches.length > 1) {
166
+ throw new Error(`Workspace ${name} is ambiguous: ${matches.length} groups have that title`);
167
+ }
168
+ if (matches[0]) {
169
+ candidates.push({
170
+ group: matches[0],
171
+ entry: config.workspaces[name],
172
+ });
173
+ }
174
+ }
175
+ return candidates.length === 1 ? candidates[0] : null;
176
+ }
177
+
178
+ async inheritNavigationTarget(details) {
179
+ const sourceTabId = Number(details?.sourceTabId);
180
+ const tabId = Number(details?.tabId);
181
+ if (!Number.isInteger(sourceTabId) || !Number.isInteger(tabId)) return null;
182
+
183
+ const [source, target] = await Promise.all([
184
+ this.chrome.tabs.get(sourceTabId),
185
+ this.chrome.tabs.get(tabId),
186
+ ]);
187
+ if (!this.isOwnedTab(source)) return null;
188
+
189
+ const groupId = Number(source.groupId);
190
+ if (!Number.isInteger(groupId) || groupId < 0) return null;
191
+
192
+ const groups = await this.chrome.tabGroups.query({});
193
+ const group = groups.find((candidate) => candidate.id === groupId);
194
+ if (!group?.title) return null;
195
+
196
+ const { entry } = await this.configuredWorkspace(group.title);
197
+ if (!entry) return null;
198
+
199
+ this.markOwned(tabId);
200
+ await this.chrome.tabs.group({ groupId, tabIds: [tabId] });
201
+
202
+ const tabs = await this.groupTabs(groupId);
203
+ if (tabs.length > entry.poolSize) {
204
+ const { idle } = this.classify(group.title, tabs);
205
+ const removable = idle.find((candidate) => candidate.id !== tabId);
206
+ if (removable) await this.chrome.tabs.remove(removable.id);
207
+ }
208
+
209
+ await this.chrome.tabGroups.update(groupId, {
210
+ title: group.title,
211
+ collapsed: true,
212
+ });
213
+
214
+ return {
215
+ workspace: group.title,
216
+ groupId,
217
+ tabId,
218
+ sourceTabId,
219
+ adoptionReason: "navigation-target",
220
+ };
221
+ }
222
+
223
+ async inheritWorkspaceForCreatedTab(tab) {
224
+ const tabId = Number(tab?.id);
225
+ const openerTabId = Number(tab?.openerTabId);
226
+ if (!Number.isInteger(tabId)) return null;
227
+
228
+ const allTabs = await this.chrome.tabs.query({});
229
+ const tabsById = new Map(allTabs.map((candidate) => [candidate.id, candidate]));
230
+ const groups = await this.chrome.tabGroups.query({});
231
+ const groupsById = new Map(groups.map((candidate) => [candidate.id, candidate]));
232
+
233
+ let ancestor = Number.isInteger(openerTabId) ? tabsById.get(openerTabId) : null;
234
+ const visited = new Set();
235
+ let inherited = null;
236
+ let extensionManagementAncestor = null;
237
+
238
+ while (ancestor && !visited.has(ancestor.id)) {
239
+ visited.add(ancestor.id);
240
+
241
+ if (this.isExtensionManagementUrl(this.tabUrl(ancestor))) {
242
+ extensionManagementAncestor = ancestor;
243
+ }
244
+
245
+ if (this.isOwnedTab(ancestor) && Number.isInteger(ancestor.groupId) && ancestor.groupId >= 0) {
246
+ const group = groupsById.get(ancestor.groupId);
247
+ if (group?.title) {
248
+ const { entry } = await this.configuredWorkspace(group.title);
249
+ if (entry) {
250
+ inherited = { ancestor, group, entry };
251
+ break;
252
+ }
253
+ }
254
+ }
255
+
256
+ const parentId = Number(ancestor.openerTabId);
257
+ ancestor = Number.isInteger(parentId) ? tabsById.get(parentId) : null;
258
+ }
259
+
260
+ let adoptionReason = "workspace-opener";
261
+ if (!inherited && this.isExtensionPageUrl(this.tabUrl(tab))) {
262
+ const windowId = Number(tab?.windowId);
263
+ const recentManagementAt = Number.isInteger(windowId)
264
+ ? this.recentExtensionManagementByWindow.get(windowId)
265
+ : null;
266
+ const hasRecentManagementContext =
267
+ Number.isFinite(recentManagementAt) &&
268
+ Date.now() - recentManagementAt <= EXTENSION_MANAGEMENT_RECENCY_MS;
269
+
270
+ if (extensionManagementAncestor || hasRecentManagementContext) {
271
+ const candidate = await this.uniqueConfiguredWorkspace();
272
+ if (candidate) {
273
+ inherited = {
274
+ ancestor: extensionManagementAncestor,
275
+ group: candidate.group,
276
+ entry: candidate.entry,
277
+ };
278
+ adoptionReason = extensionManagementAncestor
279
+ ? "extension-management-opener"
280
+ : "recent-extension-management";
281
+ }
282
+ }
283
+ }
284
+
285
+ if (!inherited) {
286
+ if (this.isOwnedTab(tab)) return {
287
+ tabId,
288
+ owned: true,
289
+ };
290
+
291
+ const groupId = Number(tab?.groupId);
292
+ const group = Number.isInteger(groupId) && groupId >= 0
293
+ ? groupsById.get(groupId)
294
+ : null;
295
+ if (group?.title) {
296
+ const { entry } = await this.configuredWorkspace(group.title);
297
+ const meta = this.parseManagedUrl(this.tabUrl(tab));
298
+ if (entry && meta?.workspace !== group.title) {
299
+ await this.chrome.tabs.ungroup([tabId]);
300
+ return {
301
+ workspace: group.title,
302
+ groupId,
303
+ tabId,
304
+ releasedUnexpected: true,
305
+ };
306
+ }
307
+ }
308
+ return null;
309
+ }
310
+
311
+ const { ancestor: workspaceAncestor, group, entry } = inherited;
312
+ this.markOwned(tabId);
313
+ await this.chrome.tabs.group({ groupId: group.id, tabIds: [tabId] });
314
+
315
+ const tabs = await this.groupTabs(group.id);
316
+ if (tabs.length > entry.poolSize) {
317
+ const { idle } = this.classify(group.title, tabs);
318
+ const removable = idle.find((candidate) => candidate.id !== tabId);
319
+ if (removable) await this.chrome.tabs.remove(removable.id);
320
+ }
321
+
322
+ await this.chrome.tabGroups.update(group.id, {
323
+ title: group.title,
324
+ collapsed: true,
325
+ });
326
+
327
+ const result = {
328
+ workspace: group.title,
329
+ groupId: group.id,
330
+ tabId,
331
+ inheritedFromTabId: Number.isInteger(openerTabId) ? openerTabId : null,
332
+ workspaceAncestorTabId: workspaceAncestor?.id ?? null,
333
+ };
334
+ if (adoptionReason !== "workspace-opener") result.adoptionReason = adoptionReason;
335
+ return result;
336
+ }
337
+
338
+ async reconcileCreatedTab(tabId) {
339
+ const id = Number(tabId);
340
+ if (!Number.isInteger(id)) return null;
341
+ const tab = await this.chrome.tabs.get(id);
342
+ return await this.inheritWorkspaceForCreatedTab(tab);
343
+ }
344
+
345
+ classify(name, tabs) {
346
+ const idle = [];
347
+ const leased = [];
348
+ for (const tab of tabs) {
349
+ const meta = this.parseManagedUrl(tab.url || "");
350
+ if (meta?.workspace === name && ["idle", "marker"].includes(meta.role)) idle.push(tab);
351
+ else leased.push(tab);
352
+ }
353
+ return { idle, leased };
354
+ }
355
+
356
+ async waitForIdleTab(tabId, workspace, timeoutMs = 3000) {
357
+ const deadline = Date.now() + timeoutMs;
358
+ for (;;) {
359
+ const tabs = await this.chrome.tabs.query({});
360
+ const tab = tabs.find((candidate) => candidate.id === tabId);
361
+ const meta = this.parseManagedUrl(tab?.url || "");
362
+ if (tab && meta?.workspace === workspace && meta.role === "idle") return tab;
363
+ if (Date.now() >= deadline) {
364
+ throw new Error(`Timed out waiting for workspace ${workspace} idle tab ${tabId}`);
365
+ }
366
+ await new Promise((resolve) => setTimeout(resolve, 25));
367
+ }
368
+ }
369
+
370
+ async createGroup(name, poolSize) {
371
+ // Chrome groups are created by grouping a tab. Keep one seed tab so the
372
+ // workspace has an identity, then grow the physical pool on demand.
373
+ const created = [await this.chrome.tabs.create({
374
+ url: this.idleUrl(name, 0),
375
+ active: false,
376
+ })];
377
+ for (const tab of created) this.markOwned(tab.id);
378
+ const groupId = await this.chrome.tabs.group({ tabIds: created.map((tab) => tab.id) });
379
+ await this.chrome.tabGroups.update(groupId, { title: name, collapsed: true });
380
+ for (const tab of created) await this.waitForIdleTab(tab.id, name);
381
+ return await this.status(name);
382
+ }
383
+
384
+ async ensureWorkspace(name, poolSize = null) {
385
+ const normalizedName = normalizeWorkspaceName(name);
386
+ const { entry } = await this.configuredWorkspace(normalizedName);
387
+ const wanted = normalizePoolSize(poolSize, entry?.poolSize ?? 8);
388
+ if (!entry || entry.poolSize !== wanted) await this.setPoolSize(normalizedName, wanted);
389
+
390
+ const group = await this.workspaceGroup(normalizedName);
391
+ if (!group) return await this.createGroup(normalizedName, wanted);
392
+
393
+ await this.chrome.tabGroups.update(group.id, { title: normalizedName, collapsed: true });
394
+ await this.reconcilePool(normalizedName, group.id, wanted);
395
+ return await this.status(normalizedName);
396
+ }
397
+
398
+ async reconcilePool(name, groupId, poolSize) {
399
+ const tabs = await this.groupTabs(groupId);
400
+ const { idle } = this.classify(name, tabs);
401
+
402
+ // poolSize is a maximum concurrent capacity, not a target tab count.
403
+ // Only remove surplus idle tabs when shrinking; never refill a lazy pool.
404
+ if (tabs.length > poolSize && idle.length) {
405
+ const removable = Math.min(idle.length, tabs.length - poolSize);
406
+ await this.chrome.tabs.remove(idle.slice(0, removable).map((tab) => tab.id));
407
+ }
408
+
409
+ await this.chrome.tabGroups.update(groupId, { title: name, collapsed: true });
410
+ }
411
+
412
+ async reclaimStaleTabs(now = Date.now()) {
413
+ const config = await this.loadConfig();
414
+ const reclaimed = [];
415
+ for (const name of Object.keys(config.workspaces || {})) {
416
+ const group = await this.workspaceGroup(name);
417
+ if (!group) continue;
418
+ const tabs = await this.groupTabs(group.id);
419
+ const { leased } = this.classify(name, tabs);
420
+ for (const tab of leased) {
421
+ const lastAccessed = Number(tab.lastAccessed);
422
+ if (tab.active || !Number.isFinite(lastAccessed)) continue;
423
+ if (now - lastAccessed < STALE_LEASE_MS) continue;
424
+ await this.release(name, tab.id);
425
+ reclaimed.push(tab.id);
426
+ }
427
+ }
428
+ return { reclaimedTabIds: reclaimed };
429
+ }
430
+
431
+ async status(name) {
432
+ const normalizedName = normalizeWorkspaceName(name);
433
+ const { entry } = await this.configuredWorkspace(normalizedName);
434
+ if (!entry) return { name: normalizedName, initialized: false };
435
+
436
+ const group = await this.workspaceGroup(normalizedName);
437
+ if (!group) {
438
+ return {
439
+ name: normalizedName,
440
+ initialized: false,
441
+ poolSize: entry.poolSize,
442
+ reason: "group-missing",
443
+ };
444
+ }
445
+
446
+ const tabs = await this.groupTabs(group.id);
447
+ const { idle, leased } = this.classify(normalizedName, tabs);
448
+ return {
449
+ name: normalizedName,
450
+ initialized: true,
451
+ groupId: group.id,
452
+ poolSize: entry.poolSize,
453
+ maxCapacity: entry.poolSize,
454
+ physicalTabs: tabs.length,
455
+ idle: idle.length,
456
+ leased: leased.length,
457
+ tabIds: tabs.map((tab) => tab.id),
458
+ idleTabIds: idle.map((tab) => tab.id),
459
+ leasedTabIds: leased.map((tab) => tab.id),
460
+ tabs: tabs.map((tab) => ({
461
+ tabId: tab.id,
462
+ groupId: tab.groupId,
463
+ title: tab.title || "",
464
+ url: tab.url || "",
465
+ active: Boolean(tab.active),
466
+ })),
467
+ collapsed: Boolean(group.collapsed),
468
+ };
469
+ }
470
+
471
+ async restoreConfiguredWorkspaces() {
472
+ const config = await this.loadConfig();
473
+ const restored = [];
474
+ for (const name of Object.keys(config.workspaces || {}).sort()) {
475
+ restored.push(await this.ensureWorkspace(name));
476
+ }
477
+ return { workspaces: restored };
478
+ }
479
+
480
+ async list() {
481
+ const config = await this.loadConfig();
482
+ const result = [];
483
+ for (const name of Object.keys(config.workspaces).sort()) {
484
+ result.push(await this.status(name));
485
+ }
486
+ return { workspaces: result };
487
+ }
488
+
489
+ async ensure(name, defaultPoolSize = 8) {
490
+ const normalizedName = normalizeWorkspaceName(name);
491
+ const { entry } = await this.configuredWorkspace(normalizedName);
492
+ if (entry) return await this.ensureWorkspace(normalizedName);
493
+ return await this.ensureWorkspace(
494
+ normalizedName,
495
+ normalizePoolSize(defaultPoolSize),
496
+ );
497
+ }
498
+
499
+ async create(name, poolSize = 8) {
500
+ return await this.ensureWorkspace(name, normalizePoolSize(poolSize));
501
+ }
502
+
503
+ async resize(name, poolSize) {
504
+ const normalizedName = normalizeWorkspaceName(name);
505
+ const wanted = normalizePoolSize(poolSize);
506
+ await this.setPoolSize(normalizedName, wanted);
507
+ return await this.ensureWorkspace(normalizedName, wanted);
508
+ }
509
+
510
+ async acquireIdentity(name) {
511
+ const normalizedName = normalizeWorkspaceName(name);
512
+ const ready = await this.ensureWorkspace(normalizedName);
513
+ const tabs = await this.groupTabs(ready.groupId);
514
+ const { idle, leased } = this.classify(normalizedName, tabs);
515
+ if (!idle.length && leased.length >= ready.poolSize) {
516
+ throw new Error(`Workspace ${normalizedName} pool is exhausted at size ${ready.poolSize}`);
517
+ }
518
+
519
+ let tab = idle[0];
520
+ if (!tab) {
521
+ tab = await this.chrome.tabs.create({
522
+ url: this.idleUrl(normalizedName, tabs.length),
523
+ active: false,
524
+ });
525
+ this.markOwned(tab.id);
526
+ await this.chrome.tabs.group({ groupId: ready.groupId, tabIds: [tab.id] });
527
+ tab = await this.waitForIdleTab(tab.id, normalizedName);
528
+ }
529
+
530
+ this.markOwned(tab.id);
531
+ const identityTitle = this.identityTitle(tab.id);
532
+ const identityUrl = this.identityUrl(normalizedName, tab.id);
533
+ const updated = await this.chrome.tabs.update(tab.id, { url: identityUrl, active: false });
534
+ await this.chrome.tabGroups.update(ready.groupId, { title: normalizedName, collapsed: true });
535
+ return {
536
+ workspace: normalizedName,
537
+ groupId: ready.groupId,
538
+ tabId: updated.id,
539
+ identityTitle,
540
+ identityUrl,
541
+ active: Boolean(updated.active),
542
+ };
543
+ }
544
+
545
+ async acquire(name, url) {
546
+ const normalizedName = normalizeWorkspaceName(name);
547
+ const parsed = new URL(String(url || ""));
548
+ if (!["http:", "https:", "chrome-extension:"].includes(parsed.protocol)) {
549
+ throw new Error("Workspace acquire requires an http(s) or chrome-extension URL");
550
+ }
551
+
552
+ const ready = await this.ensureWorkspace(normalizedName);
553
+ const tabs = await this.groupTabs(ready.groupId);
554
+ const { idle, leased } = this.classify(normalizedName, tabs);
555
+ if (!idle.length && leased.length >= ready.poolSize) {
556
+ throw new Error(`Workspace ${normalizedName} pool is exhausted at size ${ready.poolSize}`);
557
+ }
558
+
559
+ let tab = idle[0];
560
+ if (!tab) {
561
+ tab = await this.chrome.tabs.create({
562
+ url: this.idleUrl(normalizedName, tabs.length),
563
+ active: false,
564
+ });
565
+ // Group reconciliation can fire before the extension idle URL is visible.
566
+ // Mark ownership first so a legitimate Browser Harness lease is not ejected.
567
+ this.markOwned(tab.id);
568
+ await this.chrome.tabs.group({ groupId: ready.groupId, tabIds: [tab.id] });
569
+ tab = await this.waitForIdleTab(tab.id, normalizedName);
570
+ }
571
+ this.markOwned(tab.id);
572
+ const updated = await this.chrome.tabs.update(tab.id, { url: parsed.href, active: false });
573
+ await this.chrome.tabGroups.update(ready.groupId, { title: normalizedName, collapsed: true });
574
+ return {
575
+ workspace: normalizedName,
576
+ groupId: ready.groupId,
577
+ tabId: updated.id,
578
+ url: updated.url || parsed.href,
579
+ active: Boolean(updated.active),
580
+ };
581
+ }
582
+
583
+ async release(name, tabId) {
584
+ const normalizedName = normalizeWorkspaceName(name);
585
+ const ready = await this.ensureWorkspace(normalizedName);
586
+ const wanted = Number(tabId);
587
+ const tabs = await this.groupTabs(ready.groupId);
588
+ if (!tabs.some((tab) => tab.id === wanted)) {
589
+ throw new Error(`Tab ${wanted} is not owned by workspace ${normalizedName}`);
590
+ }
591
+
592
+ if (tabs.length > ready.poolSize) {
593
+ await this.chrome.tabs.remove(wanted);
594
+ } else {
595
+ try {
596
+ await this.chrome.tabs.update(wanted, {
597
+ url: this.idleUrl(normalizedName, wanted),
598
+ active: false,
599
+ });
600
+ await this.waitForIdleTab(wanted, normalizedName);
601
+ } catch {
602
+ // Legacy/unowned leases may refuse or fail to finish navigation back to
603
+ // the extension idle page. Preserve pool capacity by creating a clean
604
+ // idle replacement in the same group before removing the stale tab.
605
+ const replacement = await this.chrome.tabs.create({
606
+ url: this.idleUrl(normalizedName, wanted),
607
+ active: false,
608
+ });
609
+ this.markOwned(replacement.id);
610
+ await this.chrome.tabs.group({ groupId: ready.groupId, tabIds: [replacement.id] });
611
+ await this.waitForIdleTab(replacement.id, normalizedName);
612
+ await this.chrome.tabs.remove(wanted);
613
+ }
614
+ }
615
+ await this.chrome.tabGroups.update(ready.groupId, { title: normalizedName, collapsed: true });
616
+ return { workspace: normalizedName, tabId: wanted, released: true };
617
+ }
618
+
619
+ async releaseAll() {
620
+ const config = await this.loadConfig();
621
+ const released = [];
622
+
623
+ for (const name of Object.keys(config.workspaces || {}).sort()) {
624
+ const group = await this.workspaceGroup(name);
625
+ if (!group) continue;
626
+
627
+ // Snapshot first because release() mutates the group as tabs return idle
628
+ // or are removed when the group is above its configured capacity.
629
+ const tabs = await this.groupTabs(group.id);
630
+ const { leased } = this.classify(name, tabs);
631
+ for (const tab of leased) {
632
+ await this.release(name, tab.id);
633
+ released.push({ workspace: name, tabId: tab.id });
634
+ }
635
+
636
+ // Chrome destroys a tab group when it becomes empty. Release-all must
637
+ // preserve workspace identity, so re-ensure the workspace after the
638
+ // release pass and leave at least one idle seed tab if necessary.
639
+ await this.ensureWorkspace(name);
640
+ }
641
+
642
+ return {
643
+ released,
644
+ releasedCount: released.length,
645
+ };
646
+ }
647
+
648
+ async delete(name, force = false) {
649
+ const normalizedName = normalizeWorkspaceName(name);
650
+ const state = await this.status(normalizedName);
651
+ if (!state.initialized) {
652
+ await this.removeConfig(normalizedName);
653
+ return { name: normalizedName, deleted: true };
654
+ }
655
+ if (state.leasedTabIds.length && !force) {
656
+ throw new Error(`Workspace ${normalizedName} has ${state.leasedTabIds.length} leased tabs`);
657
+ }
658
+
659
+ const tabs = await this.groupTabs(state.groupId);
660
+ if (tabs.length) await this.chrome.tabs.remove(tabs.map((tab) => tab.id));
661
+ await this.removeConfig(normalizedName);
662
+ return { name: normalizedName, deleted: true };
663
+ }
664
+
665
+ async rpc(request) {
666
+ const method = String(request?.method || "");
667
+ const args = request?.args || {};
668
+ if (method === "workspace.create") return await this.create(args.name, args.poolSize);
669
+ if (method === "workspace.ensure") return await this.ensure(args.name, args.poolSize);
670
+ if (method === "workspace.status") return await this.status(args.name);
671
+ if (method === "workspace.list") return await this.list();
672
+ if (method === "workspace.acquire") return await this.acquire(args.name, args.url);
673
+ if (method === "workspace.acquireIdentity") return await this.acquireIdentity(args.name);
674
+ if (method === "workspace.release") return await this.release(args.name, args.tabId);
675
+ if (method === "workspace.releaseAll") return await this.releaseAll();
676
+ if (method === "workspace.resize") return await this.resize(args.name, args.poolSize);
677
+ if (method === "workspace.delete") return await this.delete(args.name, Boolean(args.force));
678
+ throw new Error(`Unknown workspace method: ${method}`);
679
+ }
680
+ }
@@ -0,0 +1,7 @@
1
+ const params = new URLSearchParams(location.search);
2
+ const role = params.get("role") || "";
3
+ const slot = params.get("slot") || "";
4
+
5
+ document.title = role === "lease" && /^\d+$/.test(slot)
6
+ ? `__BW_TAB_${slot}__`
7
+ : "Browser Workspace";
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <title>Browser Workspace</title>
4
+ <script type="module" src="workspace-page.mjs"></script>
5
+ <style>
6
+ body { font-family: system-ui, sans-serif; margin: 24px; }
7
+ </style>
8
+ <p>This tab belongs to a managed Browser Workspace pool.</p>