webhelm 1.0.6 → 1.0.7

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/README.md CHANGED
@@ -29,6 +29,16 @@ npx --yes webhelm@latest session stop SESSION_ID
29
29
 
30
30
  Use `npx skills add lalalic/webhelm --skill webhelm` to install agent guidance. The Extension is bundled with the npm Runtime and staged by WebHelm Daemon at `~/.local/webhelm/extension`. The Skill lives **in this repo** at `skills/webhelm/SKILL.md`. No Chrome Web Store publication or manual Extension installation is required.
31
31
 
32
+ ## WebHelm Chrome Extension control panel
33
+
34
+ The daemon-managed Chrome Extension is installed from the npm Runtime and does not require the Chrome Web Store. Click the WebHelm toolbar icon to see daemon connectivity, each configured workspace's available capacity and current Session count. The badge shows total available slots across configured workspaces, including unallocated lazy slots; `!` means the daemon heartbeat is stale and `?` means capacity cannot be determined.
35
+
36
+ The popup has a confirmation step to stop one Session, clear Sessions in one workspace, or clear Sessions across all workspaces. These actions are executed by the same Node daemon as the CLI, over the existing CDP-backed Extension RPC. No shell access or HTTP listener is granted to the Extension. The persistent ChatGPT queue is not deleted, and queued work may create new Sessions afterward.
37
+
38
+ New installations use the default Chrome tab group `WebHelm` and the idle page `helm.html`. Legacy `Harness` default groups migrate only when their tabs are idle. Custom workspace names are unchanged; the old `workspace.html` path and lease titles remain readable until their Sessions finish. Closing an allocated Session returns its managed tabs to the idle page, retaining physical Chrome targets, while unleased popup children are closed. Do not manually remove active managed tabs.
39
+
40
+ For a real browser integration check on macOS, run `node test/e2e/extension-control.e2e.mjs`; it creates a temporary isolated Chrome profile and daemon and does not touch the current production Sessions.
41
+
32
42
  The Actions checkout is `~/.local/webhelm`, with site modules at `~/.local/webhelm/platforms`. Setup clones if absent, then reuses that checkout without discarding local modifications. Run `platform sync` for new commits. For a verified local Action fix, `platform publish --message "..."` tests, commits and pushes only the action changes to the separate repo.
33
43
 
34
44
  ## Architecture and compatibility
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,8 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "test": "node --test test/node/*.test.mjs test/workspace-manager.test.mjs skills/webhelm/tests/*.test.mjs",
20
- "test:package": "npm pack --dry-run --json"
20
+ "test:package": "npm pack --dry-run --json",
21
+ "test:e2e:extension": "node test/e2e/extension-control.e2e.mjs"
21
22
  },
22
23
  "license": "MIT",
23
24
  "repository": {
package/runtime/cli.mjs CHANGED
@@ -25,9 +25,9 @@ Usage:
25
25
  webhelm migrate # after stopping Chrome and the old WebHelm daemon
26
26
  webhelm setup
27
27
  webhelm status
28
- webhelm ensure # verify/recover managed Chrome and Harness, not just daemon ping
28
+ webhelm ensure # verify/recover managed Chrome and WebHelm group, not just daemon ping
29
29
  webhelm capacity [--workspace NAME]
30
- webhelm session start [--url URL]
30
+ webhelm session start [--workspace NAME] [--url URL]
31
31
  webhelm session list
32
32
  webhelm session heartbeat SESSION_ID # keep a working Session alive
33
33
  webhelm session exec SESSION_ID [--code-file FILE] # JavaScript
@@ -0,0 +1,120 @@
1
+ // The daemon polls this short-lived mailbox through the existing CDP Extension
2
+ // RPC. Never expose a local HTTP service or execute shell commands in Chrome.
3
+ const KEY = 'webhelm.control.v1';
4
+ const FRESH_MS = 8_000;
5
+ const EXPIRY_MS = 20_000;
6
+ const ABANDONED_MS = 10 * 60_000;
7
+
8
+ export class ControlChannel {
9
+ constructor(chromeApi, now = () => Date.now(), makeId = () => crypto.randomUUID()) {
10
+ this.chrome = chromeApi;
11
+ this.now = now;
12
+ this.makeId = makeId;
13
+ this.queue = Promise.resolve();
14
+ }
15
+
16
+ serialize(fn) {
17
+ const task = this.queue.then(fn);
18
+ this.queue = task.catch(() => {});
19
+ return task;
20
+ }
21
+
22
+ async read() {
23
+ return (await this.chrome.storage.session.get(KEY))[KEY] || {};
24
+ }
25
+
26
+ async save(state) {
27
+ await this.chrome.storage.session.set({[KEY]: state});
28
+ }
29
+
30
+ normalize(state) {
31
+ const result = {...state};
32
+ if (result.request) {
33
+ const age = this.now() - result.request.createdAt;
34
+ if (result.request.state === 'pending' && age > EXPIRY_MS) {
35
+ result.lastResult = {id: result.request.id, ok: false, error: 'The request expired. Try again.'};
36
+ result.request = null;
37
+ } else if (result.request.state === 'processing' && age > ABANDONED_MS) {
38
+ result.lastResult = {id: result.request.id, ok: false,
39
+ error: 'The daemon did not confirm completion. Check current sessions before retrying.'};
40
+ result.request = null;
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
46
+ async status() {
47
+ const state = this.normalize(await this.read());
48
+ const connected = Number.isFinite(state.daemon?.seenAt) &&
49
+ this.now() - state.daemon.seenAt < FRESH_MS;
50
+ return {
51
+ connected,
52
+ sessionCount: connected ? state.daemon.sessions?.length ?? 0 : null,
53
+ sessions: connected ? state.daemon.sessions || [] : [],
54
+ request: state.request || null,
55
+ lastResult: state.lastResult || null,
56
+ };
57
+ }
58
+
59
+ async requestStop(action, sessionId, workspace) {
60
+ return this.serialize(async () => {
61
+ if (!['stop-all', 'stop', 'stop-workspace'].includes(action)) throw new Error('Unsupported action');
62
+ const state = this.normalize(await this.read());
63
+ if (state.request) throw new Error('Another WebHelm operation is already running');
64
+ if (!Number.isFinite(state.daemon?.seenAt) || this.now() - state.daemon.seenAt >= FRESH_MS)
65
+ throw new Error('WebHelm daemon is not responding');
66
+ const sessions = state.daemon.sessions || [];
67
+ if (action === 'stop-all' && !sessions.length) throw new Error('No active sessions to stop');
68
+ if (action === 'stop' && !sessions.some(s => s.session_id === sessionId))
69
+ throw new Error('Session is no longer active');
70
+ if (action === 'stop-workspace' && (!workspace || !sessions.some(s => s.workspace === workspace)))
71
+ throw new Error('No active sessions in this workspace');
72
+ const request = {id: this.makeId(), action, ...(action === 'stop' ? {sessionId} : {}),
73
+ ...(action === 'stop-workspace' ? {workspace} : {}),
74
+ state: 'pending', createdAt: this.now()};
75
+ await this.save({...state, request, lastResult: null});
76
+ return {id: request.id, state: 'pending'};
77
+ });
78
+ }
79
+
80
+ async heartbeat(sessions) {
81
+ return this.serialize(async()=>{
82
+ if (!Array.isArray(sessions)) throw new Error('Invalid sessions');
83
+ const state=this.normalize(await this.read());
84
+ await this.save({...state,daemon:{seenAt:this.now(),sessions}});
85
+ return {updated:true};
86
+ });
87
+ }
88
+
89
+ async poll(sessions) {
90
+ return this.serialize(async () => {
91
+ if (!Array.isArray(sessions) || sessions.some(s =>
92
+ typeof s?.session_id !== 'string' || typeof s?.workspace !== 'string'))
93
+ throw new Error('Invalid daemon session listing');
94
+ const state = this.normalize(await this.read());
95
+ const request = state.request?.state === 'pending'
96
+ ? {...state.request, state: 'processing'}
97
+ : state.request || null;
98
+ await this.save({...state, request, daemon: {seenAt: this.now(), sessions}});
99
+ return request?.state === 'processing' && state.request?.state === 'pending'
100
+ ? {id: request.id, action: request.action, ...(request.sessionId ? {sessionId:request.sessionId} : {}),
101
+ ...(request.workspace ? {workspace:request.workspace} : {})}
102
+ : null;
103
+ });
104
+ }
105
+
106
+ async complete(id, response) {
107
+ return this.serialize(async () => {
108
+ const state = this.normalize(await this.read());
109
+ if (!state.request || state.request.id !== id) return {ignored: true};
110
+ const stoppedCount = response?.stopped_count ?? (response?.stopped ? 1 : 0);
111
+ const errors = response?.errors || [];
112
+ const errorText = response?.error || errors.map(e => e.error || String(e)).join('; ');
113
+ const lastResult = response?.ok
114
+ ? {id, ok: errors.length === 0, stoppedCount, errors, ...(errors.length ? {error: errorText} : {})}
115
+ : {id, ok: false, stoppedCount, errors, error: String(errorText || 'Unable to stop sessions')};
116
+ await this.save({...state, request: null, lastResult});
117
+ return lastResult;
118
+ });
119
+ }
120
+ }
@@ -0,0 +1,125 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>WebHelm</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --page: #f5f7fb;
11
+ --card: #fff;
12
+ --ink: #182234;
13
+ --subtle: #5f6d81;
14
+ --border: #e2e8f0;
15
+ --accent: #335bd1;
16
+ --accent-bg: #edf2ff;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ body {
20
+ min-height: 100vh;
21
+ margin: 0;
22
+ padding: 32px 20px;
23
+ display: grid;
24
+ place-items: center;
25
+ font: 15px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
26
+ color: var(--ink);
27
+ background: var(--page);
28
+ }
29
+ main {
30
+ width: min(100%, 460px);
31
+ padding: clamp(24px, 6vw, 36px);
32
+ border: 1px solid var(--border);
33
+ border-radius: 20px;
34
+ background: var(--card);
35
+ box-shadow: 0 12px 35px rgb(25 42 76 / 5%);
36
+ }
37
+ header { display: flex; align-items: center; gap: 14px; }
38
+ .mark {
39
+ display: grid;
40
+ place-items: center;
41
+ width: 48px;
42
+ height: 48px;
43
+ border-radius: 14px;
44
+ background: var(--accent);
45
+ color: white;
46
+ font-size: 24px;
47
+ font-weight: 750;
48
+ letter-spacing: -2px;
49
+ }
50
+ .eyebrow {
51
+ margin: 0;
52
+ color: var(--subtle);
53
+ font-size: 11px;
54
+ font-weight: 650;
55
+ letter-spacing: 0.12em;
56
+ text-transform: uppercase;
57
+ }
58
+ h1 { margin: 1px 0 0; font-size: 24px; line-height: 1.2; letter-spacing: -0.03em; }
59
+ .status {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 9px;
63
+ margin-top: 32px;
64
+ padding: 7px 12px;
65
+ border-radius: 999px;
66
+ background: var(--accent-bg);
67
+ color: var(--accent);
68
+ font-weight: 650;
69
+ font-size: 13px;
70
+ }
71
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
72
+ .description { margin: 16px 0 24px; color: var(--subtle); }
73
+ dl {
74
+ display: grid;
75
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
76
+ gap: 16px;
77
+ margin: 0;
78
+ padding: 18px 0;
79
+ border-block: 1px solid var(--border);
80
+ }
81
+ dt { margin-bottom: 3px; color: var(--subtle); font-size: 12px; }
82
+ dd { margin: 0; font-weight: 650; overflow-wrap: anywhere; }
83
+ footer { margin-top: 20px; color: var(--subtle); font-size: 12px; }
84
+ @media (prefers-color-scheme: dark) {
85
+ :root {
86
+ --page: #10151e;
87
+ --card: #192232;
88
+ --ink: #edf2f9;
89
+ --subtle: #a8b3c5;
90
+ --border: #354256;
91
+ --accent: #a5baff;
92
+ --accent-bg: #293656;
93
+ }
94
+ .mark { background: #4269e0; }
95
+ main { box-shadow: none; }
96
+ }
97
+ @media (max-width: 380px) {
98
+ body { padding: 16px; }
99
+ main { border-radius: 14px; }
100
+ }
101
+ </style>
102
+ <script type="module" src="workspace-page.mjs"></script>
103
+ </head>
104
+ <body>
105
+ <main aria-labelledby="page-title">
106
+ <header>
107
+ <div class="mark" aria-hidden="true">W</div>
108
+ <div>
109
+ <p class="eyebrow">Managed browser tab</p>
110
+ <h1 id="page-title">WebHelm</h1>
111
+ </div>
112
+ </header>
113
+ <div class="status" role="status">
114
+ <span class="dot" aria-hidden="true"></span>
115
+ <span id="status-label">Managed workspace</span>
116
+ </div>
117
+ <p class="description" id="description">This page belongs to WebHelm's managed browser workspace.</p>
118
+ <dl>
119
+ <div><dt>Workspace</dt><dd id="workspace-name">—</dd></div>
120
+ <div><dt>Tab slot</dt><dd id="slot-number">—</dd></div>
121
+ </dl>
122
+ <footer id="help-text">Tab status does not indicate whether the daemon is connected.</footer>
123
+ </main>
124
+ </body>
125
+ </html>
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "manifest_version": 3,
3
- "name": "Browser Workspace",
4
- "version": "0.3.3",
3
+ "name": "WebHelm",
4
+ "version": "0.4.0",
5
5
  "description": "Own named Chrome tab-group workspaces and lazy reusable tab pools for browser agents.",
6
6
  "permissions": [
7
7
  "storage",
8
8
  "tabs",
9
9
  "tabGroups",
10
10
  "webNavigation",
11
- "contextMenus"
11
+ "contextMenus",
12
+ "alarms"
12
13
  ],
13
14
  "host_permissions": [
14
15
  "http://*/*",
@@ -18,8 +19,11 @@
18
19
  "service_worker": "service-worker.mjs",
19
20
  "type": "module"
20
21
  },
22
+ "icons": { "16": "icons/webhelm-16.png", "32": "icons/webhelm-32.png", "48": "icons/webhelm-48.png", "128": "icons/webhelm-128.png" },
21
23
  "action": {
22
- "default_title": "Browser Workspace"
24
+ "default_title": "WebHelm",
25
+ "default_popup": "popup.html",
26
+ "default_icon": { "16": "icons/webhelm-16.png", "32": "icons/webhelm-32.png" }
23
27
  },
24
28
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtovrqHYRxYDBSofmlykO8RDtsePhg8KV1fmq07nnhtbmYiNXAnIKrgRyLHeZv+cwm9DSgP5Ig7FDJ7Q6XrSi+Z1FpU6Y/SJkX5Rozo0oAJcX1ggoGD3vxpfHZThSSG/vRSnD8pH3XFf2nDUD7QP6GXc8dX9lN5pslRUNh1J4UCAOcu2UpPSsvpA+wPPTZr2JYzm3AevFXkQM6y0B+4exgw/Hy21ULST4nLWZihuCSu+gfWnftiTNzUPHtseRJcrpd0zT0R46d+OC+JRwXMVBpYSksDDtOXgERwMBSZf+vvskrEqYoXKN8Z9+Rrr7y9PTOvlwQioVLEDEjr9e7RIegQIDAQAB"
25
29
  }
@@ -0,0 +1,61 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>WebHelm</title>
7
+ <style>
8
+ :root { color-scheme: light dark; --ink:#182234; --muted:#5f6d81; --border:#e1e7f0; --accent:#345fd5; --accent-light:#edf2ff; --warning:#ae3849; }
9
+ * { box-sizing: border-box; }
10
+ body { width:340px; min-height:250px; margin:0; padding:18px; background:light-dark(#fff,#172131); color:light-dark(var(--ink),#edf2f9); font:13px/1.5 system-ui,sans-serif; }
11
+ h1 { margin:0 0 2px; font-size:18px; letter-spacing:-.02em; }
12
+ p { margin:4px 0 12px; color:light-dark(var(--muted),#acb8c9); }
13
+ .stats { display:flex; gap:10px; padding:12px 0; border-block:1px solid light-dark(var(--border),#334155); margin-bottom:12px; }
14
+ .stat { flex:1; }
15
+ .stat strong { display:block; font-size:20px; }
16
+ .stat span { font-size:11px; color:light-dark(var(--muted),#acb8c9); }
17
+ button { width:100%; border:1px solid light-dark(var(--border),#40516a); background:light-dark(#fff,#22314a); color:inherit; border-radius:9px; padding:9px; cursor:pointer; font:inherit; font-weight:600; }
18
+ button:disabled { opacity:.5; cursor:not-allowed; }
19
+ button.danger { background:#ae3849; color:#fff; border-color:#ae3849; }
20
+ button:focus-visible { outline:3px solid var(--accent); outline-offset:2px; }
21
+ #confirmation { margin:8px 0; padding:11px; background:light-dark(#fff1f2,#43222a); border-radius:9px; }
22
+ #confirmation p { color:inherit; }
23
+ .buttons { display:flex; gap:8px; }
24
+ .session-header { margin:12px 0 6px; font-size:13px; font-weight:700; }
25
+ .session-list { max-height:160px; overflow:auto; }
26
+ .workspace-row { padding:12px 0; border-bottom:1px solid light-dark(var(--border),#334155); }
27
+ .workspace-title { display:flex; align-items:center; gap:8px; justify-content:space-between; }
28
+ .workspace-title strong { font-size:13px; overflow-wrap:anywhere; }
29
+ .workspace-title button { width:auto; flex-shrink:0; padding:5px 9px; font-size:11px; }
30
+ .workspace-details { color:light-dark(var(--muted),#acb8c9); font-size:11px; margin:4px 0; }
31
+ .session-row { display:flex; align-items:center; gap:8px; padding:7px 0; border-bottom:1px solid light-dark(var(--border),#334155); }
32
+ .session-info { min-width:0; flex:1; }
33
+ .session-info strong { display:block; font-size:12px; }
34
+ .session-info span { display:block; color:light-dark(var(--muted),#acb8c9); font-size:11px; }
35
+ .session-row button { width:auto; padding:5px 9px; font-size:11px; }
36
+ #message { min-height:20px; margin-top:10px; font-size:12px; overflow-wrap:anywhere; }
37
+ [hidden] { display:none !important; }
38
+ </style>
39
+ <script type="module" src="popup.mjs"></script>
40
+ </head>
41
+ <body>
42
+ <h1>WebHelm</h1>
43
+ <p>Managed Chrome · Workspace control</p>
44
+ <div class="stats" aria-live="polite">
45
+ <div class="stat"><strong id="available">—</strong><span>Total available slots</span></div>
46
+ <div class="stat"><strong id="sessions">—</strong><span>Active sessions</span></div>
47
+ <div class="stat"><strong id="daemon">—</strong><span>Daemon</span></div>
48
+ </div>
49
+ <div class="session-header">Workspaces</div>
50
+ <div id="workspace-list" class="session-list" aria-live="polite">Connecting…</div>
51
+ <button id="clear">Clear all sessions in all workspaces</button>
52
+ <section id="confirmation" hidden aria-label="Confirm clear sessions">
53
+ <p id="confirm-text">Stop the selected WebHelm session(s)? Tasks in progress may be interrupted. Queued jobs remain and may create new sessions.</p>
54
+ <div class="buttons">
55
+ <button id="cancel">Cancel</button>
56
+ <button class="danger" id="confirm">Confirm stop</button>
57
+ </div>
58
+ </section>
59
+ <div id="message" role="status" aria-live="polite"></div>
60
+ </body>
61
+ </html>
@@ -0,0 +1,136 @@
1
+ const el = id => document.getElementById(id);
2
+ let confirming = false;
3
+ let requestId = null;
4
+ let chosen = {action: 'stop-all'};
5
+ let lastSignature = '';
6
+ let refreshRunning = false;
7
+
8
+ async function rpc(method, args = {}) {
9
+ const response = await chrome.runtime.sendMessage({method, args});
10
+ if (!response?.ok) throw new Error(response?.error?.message || 'WebHelm request failed');
11
+ return response.result;
12
+ }
13
+
14
+ function showConfirmation(action, params = {}) {
15
+ chosen = {action, ...params};
16
+ confirming = true;
17
+ el('confirmation').hidden = false;
18
+ el('clear').hidden = true;
19
+ if (action === 'stop-all') {
20
+ el('confirm-text').textContent = 'Stop sessions in ALL workspaces? Active tasks will be interrupted; queued jobs may create new sessions.';
21
+ } else if (action === 'stop-workspace') {
22
+ el('confirm-text').textContent = `Stop all sessions in "${params.workspace}"? Other workspaces are unaffected.`;
23
+ } else {
24
+ el('confirm-text').textContent = `Stop session ${params.sessionId.slice(0,12)}…? Active work may be interrupted.`;
25
+ }
26
+ }
27
+
28
+ function actionButton(label, disabled, onClick) {
29
+ const button = document.createElement('button');
30
+ button.textContent = label;
31
+ button.disabled = disabled;
32
+ button.addEventListener('click', onClick);
33
+ return button;
34
+ }
35
+
36
+ function renderWorkspaces(workspaces, busy, connected) {
37
+ const signature = JSON.stringify(workspaces.map(w => [
38
+ w.name,w.initialized,w.available,w.poolSize,w.idleTabs,w.leasedTabs,w.sessions.map(s => s.session_id),
39
+ ])) + ':' + busy + ':' + connected;
40
+ if (signature === lastSignature) return;
41
+ lastSignature = signature;
42
+ const target = el('workspace-list');
43
+ target.replaceChildren();
44
+ if (!workspaces.length) {
45
+ target.textContent = 'No configured workspaces';
46
+ return;
47
+ }
48
+ for (const workspace of workspaces) {
49
+ const row = document.createElement('section');
50
+ row.className = 'workspace-row';
51
+ const header = document.createElement('div');
52
+ header.className = 'workspace-title';
53
+ const heading = document.createElement('strong');
54
+ heading.textContent = workspace.name;
55
+ header.append(heading, actionButton('Clear group', busy || !connected || !workspace.sessions.length,
56
+ () => showConfirmation('stop-workspace', {workspace:workspace.name})));
57
+ const info = document.createElement('div');
58
+ info.className = 'workspace-details';
59
+ info.textContent = workspace.initialized
60
+ ? `${workspace.available} available / ${workspace.poolSize} slots · ${workspace.idleTabs} idle tabs · ${workspace.leasedTabs} leased · ${workspace.sessions.length} sessions`
61
+ : `Unavailable · ${workspace.sessions.length} sessions · ${workspace.reason || 'workspace missing'}`;
62
+ row.append(header, info);
63
+ for (const session of workspace.sessions) {
64
+ const sessionRow = document.createElement('div');
65
+ sessionRow.className = 'session-row';
66
+ const details = document.createElement('div');
67
+ details.className = 'session-info';
68
+ const identifier = document.createElement('span');
69
+ identifier.textContent = `Session ${session.session_id.slice(0, 12)}…`;
70
+ details.append(identifier);
71
+ sessionRow.append(details, actionButton('Stop', busy || !connected,
72
+ () => showConfirmation('stop', {sessionId:session.session_id})));
73
+ row.append(sessionRow);
74
+ }
75
+ target.append(row);
76
+ }
77
+ }
78
+
79
+ async function refresh() {
80
+ if (refreshRunning) return;
81
+ refreshRunning = true;
82
+ try {
83
+ const state = await rpc('workspace.control.status');
84
+ el('available').textContent = state.connected ? state.totalAvailable ?? '—' : '—';
85
+ el('sessions').textContent = state.connected ? state.totalSessions : '—';
86
+ el('daemon').textContent = state.connected ? 'Online' : 'Offline';
87
+ const busy = Boolean(state.request);
88
+ renderWorkspaces(state.workspaces || [], busy, state.connected);
89
+ el('clear').disabled = !state.connected || !state.sessionCount || busy;
90
+ el('confirm').disabled = !state.connected || busy;
91
+ if (busy) {
92
+ el('message').textContent = 'Stopping sessions…';
93
+ } else if (state.lastResult) {
94
+ el('message').textContent = state.lastResult.ok
95
+ ? `Last action: stopped ${state.lastResult.stoppedCount} session(s).`
96
+ : `Last action failed: ${state.lastResult.error}`;
97
+ requestId = null;
98
+ }
99
+ } catch (error) {
100
+ el('clear').disabled = true;
101
+ el('confirm').disabled = true;
102
+ el('daemon').textContent = 'Offline';
103
+ el('available').textContent = '—';
104
+ el('sessions').textContent = '—';
105
+ el('message').textContent = error.message;
106
+ } finally {
107
+ refreshRunning = false;
108
+ }
109
+ }
110
+
111
+ el('clear').addEventListener('click', () => showConfirmation('stop-all'));
112
+ el('cancel').addEventListener('click', () => {
113
+ confirming = false;
114
+ el('confirmation').hidden = true;
115
+ el('clear').hidden = false;
116
+ refresh();
117
+ });
118
+ el('confirm').addEventListener('click', async () => {
119
+ if (!confirming) return;
120
+ el('confirm').disabled = true;
121
+ try {
122
+ const result = await rpc('workspace.control.requestStop', chosen);
123
+ requestId = result.id;
124
+ el('message').textContent = 'Waiting for WebHelm daemon…';
125
+ } catch (error) {
126
+ el('message').textContent = error.message;
127
+ } finally {
128
+ confirming = false;
129
+ el('confirmation').hidden = true;
130
+ el('clear').hidden = false;
131
+ refresh();
132
+ }
133
+ });
134
+
135
+ refresh();
136
+ setInterval(refresh, 1500);
@@ -1,54 +1,97 @@
1
1
  import { WorkspaceManager } from "./workspace-manager.mjs";
2
+ import { ControlChannel } from "./control-channel.mjs";
3
+ import { summarizeWorkspaces } from "./workspace-summary.mjs";
2
4
 
3
5
  const manager = new WorkspaceManager(chrome);
6
+ const control = new ControlChannel(chrome);
4
7
  const closingWindowIds = new Set();
8
+ let badgeTimer = null;
9
+
10
+ async function refreshBadge() {
11
+ const state = await control.status();
12
+ const summary = summarizeWorkspaces((await manager.list()).workspaces, state.sessions);
13
+ const available = summary.totalAvailable;
14
+ await chrome.action.setBadgeText({
15
+ text: !state.connected ? "!" : available === null ? "?" : available > 99 ? "99+" : String(available),
16
+ });
17
+ await chrome.action.setBadgeBackgroundColor({
18
+ color: !state.connected || available === null ? "#667085"
19
+ : available === 0 ? "#A43145" : "#345FD5",
20
+ });
21
+ await chrome.action.setTitle({title: state.connected
22
+ ? `WebHelm — ${available === null ? "unknown" : available} available slot(s) across ${summary.workspaces.length} workspace(s)`
23
+ : "WebHelm — daemon offline or status stale"});
24
+ }
5
25
 
26
+ function scheduleBadge() {
27
+ if (badgeTimer !== null) clearTimeout(badgeTimer);
28
+ badgeTimer = setTimeout(() => {
29
+ badgeTimer = null;
30
+ refreshBadge().catch(error => console.warn("WebHelm badge update failed", error));
31
+ }, 100);
32
+ }
6
33
 
7
- manager.restoreConfiguredWorkspaces().catch((error) => {
8
- console.warn("Browser Workspace failed to restore configured workspaces", error);
34
+ const BADGE_ALARM = "webhelm.badge-refresh";
35
+ async function scheduleBadgeAlarm() {
36
+ await chrome.alarms.create(BADGE_ALARM, {periodInMinutes: 0.5});
37
+ }
38
+ chrome.alarms.onAlarm.addListener(alarm => {
39
+ if (alarm.name === BADGE_ALARM) scheduleBadge();
9
40
  });
41
+ scheduleBadgeAlarm().catch(error => console.warn("WebHelm badge alarm failed", error));
10
42
 
11
- const RELEASE_ALL_MENU_ID = "browser-workspace.release-all";
12
-
13
- async function ensureContextMenus() {
14
- try {
15
- await chrome.contextMenus.remove(RELEASE_ALL_MENU_ID);
16
- } catch {
17
- // Menu does not exist yet.
18
- }
19
- chrome.contextMenus.create({
20
- id: RELEASE_ALL_MENU_ID,
21
- title: "Release all tabs",
22
- contexts: ["action"],
23
- });
43
+ async function removeLegacyContextMenu() {
44
+ // Legacy release-all operated on extension tabs but did not stop Node Sessions.
45
+ try { await chrome.contextMenus.remove("browser-workspace.release-all"); }
46
+ catch { /* already absent */ }
24
47
  }
25
48
 
49
+ manager.restoreConfiguredWorkspaces()
50
+ .then(scheduleBadge)
51
+ .catch(error => console.warn("WebHelm failed to restore configured workspaces", error));
52
+ removeLegacyContextMenu().catch(() => {});
26
53
  chrome.runtime.onInstalled.addListener(() => {
27
- ensureContextMenus().catch((error) => {
28
- console.warn("Browser Workspace failed to create extension menu", error);
29
- });
54
+ removeLegacyContextMenu().catch(() => {});
55
+ scheduleBadge();
30
56
  });
31
-
32
57
  chrome.runtime.onStartup.addListener(() => {
33
- ensureContextMenus().catch((error) => {
34
- console.warn("Browser Workspace failed to create extension menu", error);
35
- });
36
- });
37
-
38
- ensureContextMenus().catch((error) => {
39
- console.warn("Browser Workspace failed to initialize extension menu", error);
58
+ removeLegacyContextMenu().catch(() => {});
59
+ scheduleBadge();
40
60
  });
41
61
 
42
- chrome.contextMenus.onClicked.addListener((info) => {
43
- if (info.menuItemId !== RELEASE_ALL_MENU_ID) return;
44
- manager.releaseAll().catch((error) => {
45
- console.warn("Browser Workspace failed to release all tabs", error);
46
- });
47
- });
62
+ async function controlRpc(method, args) {
63
+ switch (method) {
64
+ case "workspace.control.status": {
65
+ const state = await control.status();
66
+ const summary = summarizeWorkspaces((await manager.list()).workspaces, state.sessions);
67
+ return {...state,...summary};
68
+ }
69
+ case "workspace.control.requestStop":
70
+ return control.requestStop(args.action, args.sessionId, args.workspace);
71
+ case "workspace.control.heartbeat":
72
+ return control.heartbeat(args.sessions);
73
+ case "workspace.control.poll": {
74
+ const result = await control.poll(args.sessions);
75
+ scheduleBadge();
76
+ return result;
77
+ }
78
+ case "workspace.control.complete":
79
+ return control.complete(args.id, args.response);
80
+ default:
81
+ throw new Error("Unknown workspace control method");
82
+ }
83
+ }
48
84
 
49
85
  globalThis.browserWorkspaceManagerRpc = async (request) => {
50
86
  try {
51
- return { ok: true, result: await manager.rpc(request) };
87
+ const method = String(request?.method || "");
88
+ const result = method.startsWith("workspace.control.")
89
+ ? await controlRpc(method, request.args || {})
90
+ : await manager.rpc(request);
91
+ if (["workspace.acquireIdentity", "workspace.acquire", "workspace.release",
92
+ "workspace.releaseAll", "workspace.resize", "workspace.ensure",
93
+ "workspace.delete", "workspace.create"].includes(method)) scheduleBadge();
94
+ return {ok: true, result};
52
95
  } catch (error) {
53
96
  return {
54
97
  ok: false,
@@ -60,45 +103,53 @@ globalThis.browserWorkspaceManagerRpc = async (request) => {
60
103
  }
61
104
  };
62
105
 
63
- chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
106
+ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
107
+ if (request?.method?.startsWith("workspace.control.") &&
108
+ (sender?.url !== chrome.runtime.getURL("popup.html") ||
109
+ !["workspace.control.requestStop", "workspace.control.status"].includes(request.method))) {
110
+ sendResponse({ok:false,error:{message:"WebHelm control actions require the toolbar popup"}});
111
+ return false;
112
+ }
64
113
  globalThis.browserWorkspaceManagerRpc(request).then(sendResponse);
65
114
  return true;
66
115
  });
67
116
 
68
117
  chrome.tabs.onCreated.addListener((tab) => {
69
- manager.inheritWorkspaceForCreatedTab(tab).catch((error) => {
70
- console.warn("Browser Workspace failed to inherit child tab group", error);
118
+ manager.inheritWorkspaceForCreatedTab(tab).then(scheduleBadge).catch((error) => {
119
+ console.warn("WebHelm failed to inherit child tab group", error);
71
120
  });
72
121
 
73
122
  setTimeout(() => {
74
- manager.reconcileCreatedTab(tab.id).catch((error) => {
123
+ manager.reconcileCreatedTab(tab.id).then(scheduleBadge).catch((error) => {
75
124
  if (!/No tab with id|Invalid tab ID/i.test(String(error?.message || error))) {
76
- console.warn("Browser Workspace failed delayed created-tab reconciliation", error);
125
+ console.warn("WebHelm failed delayed created-tab reconciliation", error);
77
126
  }
78
127
  });
79
128
  }, 150);
80
129
  });
81
130
 
82
131
  chrome.webNavigation.onCreatedNavigationTarget.addListener((details) => {
83
- manager.inheritNavigationTarget(details).catch((error) => {
84
- console.warn("Browser Workspace failed to inherit navigation target", error);
132
+ manager.inheritNavigationTarget(details).then(scheduleBadge).catch((error) => {
133
+ console.warn("WebHelm failed to inherit navigation target", error);
85
134
  });
86
135
  });
87
136
 
88
137
  chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
138
+ if (changeInfo.url || Object.prototype.hasOwnProperty.call(changeInfo, "groupId")) scheduleBadge();
89
139
  if (!Object.prototype.hasOwnProperty.call(changeInfo, "groupId")) return;
90
- manager.inheritWorkspaceForCreatedTab(tab).catch((error) => {
91
- console.warn("Browser Workspace failed to reconcile tab group ownership", error);
140
+ manager.inheritWorkspaceForCreatedTab(tab).then(scheduleBadge).catch((error) => {
141
+ console.warn("WebHelm failed to reconcile tab group ownership", error);
92
142
  });
93
143
  });
94
144
 
95
145
  chrome.tabs.onActivated.addListener((activeInfo) => {
96
146
  manager.noteActivatedTab(activeInfo).catch((error) => {
97
- console.warn("Browser Workspace failed to record active tab context", error);
147
+ console.warn("WebHelm failed to record active tab context", error);
98
148
  });
99
149
  });
100
150
 
101
151
  chrome.tabs.onRemoved.addListener((_tabId, removeInfo) => {
152
+ scheduleBadge();
102
153
  if (!removeInfo?.isWindowClosing) return;
103
154
  closingWindowIds.add(removeInfo.windowId);
104
155
  setTimeout(() => closingWindowIds.delete(removeInfo.windowId), 1000);
@@ -110,8 +161,8 @@ chrome.tabGroups.onRemoved.addListener((group) => {
110
161
  // reappear, and preserve persisted workspaces across normal window closes.
111
162
  setTimeout(() => {
112
163
  if (closingWindowIds.has(group.windowId)) return;
113
- manager.removeDeletedGroup(group).catch((error) => {
114
- console.warn("Browser Workspace failed to remove deleted workspace", error);
164
+ manager.removeDeletedGroup(group).then(scheduleBadge).catch((error) => {
165
+ console.warn("WebHelm failed to remove deleted workspace", error);
115
166
  });
116
167
  }, 100);
117
168
  });
@@ -42,11 +42,11 @@ export class WorkspaceManager {
42
42
  }
43
43
 
44
44
  idleUrl(workspace, slot) {
45
- return this.chrome.runtime.getURL(`workspace.html?${queryString(workspace, slot)}`);
45
+ return this.chrome.runtime.getURL(`helm.html?${queryString(workspace, slot)}`);
46
46
  }
47
47
 
48
48
  identityTitle(tabId) {
49
- return `__BW_TAB_${tabId}__`;
49
+ return `__WEBHELM_TAB_${tabId}__`;
50
50
  }
51
51
 
52
52
  identityUrl(workspace, tabId) {
@@ -55,14 +55,15 @@ export class WorkspaceManager {
55
55
  role: "lease",
56
56
  slot: String(tabId),
57
57
  }).toString();
58
- return this.chrome.runtime.getURL(`workspace.html?${query}`);
58
+ return this.chrome.runtime.getURL(`helm.html?${query}`);
59
59
  }
60
60
 
61
61
  parseManagedUrl(url) {
62
62
  try {
63
63
  const parsed = new URL(url);
64
- const own = new URL(this.chrome.runtime.getURL("workspace.html"));
65
- if (parsed.origin !== own.origin || parsed.pathname !== own.pathname) return null;
64
+ const own = new URL(this.chrome.runtime.getURL("helm.html"));
65
+ if (parsed.origin !== own.origin ||
66
+ !["/helm.html", "/workspace.html"].includes(parsed.pathname)) return null;
66
67
  return {
67
68
  workspace: parsed.searchParams.get("workspace") || "",
68
69
  role: parsed.searchParams.get("role") || "",
@@ -466,6 +467,44 @@ export class WorkspaceManager {
466
467
  return { workspaces: result };
467
468
  }
468
469
 
470
+ // The legacy default group is only renamed after all its tabs return idle.
471
+ // Preserve configured custom workspaces, old leased tabs and Chrome tab IDs.
472
+ async migrateDefaultWorkspace(activeLegacySessions = 0) {
473
+ const oldName = "Harness", newName = "WebHelm";
474
+ const config = await this.loadConfig();
475
+ if (!config.workspaces[oldName]) return {name: newName, migrated: false};
476
+ if (config.workspaces[newName]) return {name: newName, migrated: false, legacyPreserved: true};
477
+ if (activeLegacySessions > 0) return {name: oldName, deferred: "active-sessions"};
478
+ const group = await this.workspaceGroup(oldName);
479
+ const tabs = group ? await this.groupTabs(group.id) : [];
480
+ if (group && this.classify(oldName, tabs).leased.length)
481
+ return {name: oldName, deferred: "leased-tabs"};
482
+
483
+ // URL ownership and group identity change together. If navigation fails,
484
+ // restore both before retrying so no lease is silently adopted.
485
+ const updated = [];
486
+ try {
487
+ // Mark existing legacy tabs as owned before their URLs change. Chrome
488
+ // can emit grouping callbacks mid-migration.
489
+ for (const tab of tabs) this.markOwned(tab.id);
490
+ if (group) await this.chrome.tabGroups.update(group.id, {title: newName});
491
+ for (const tab of tabs) {
492
+ await this.chrome.tabs.update(tab.id, {url: this.idleUrl(newName, tab.id), active: false});
493
+ updated.push(tab);
494
+ }
495
+ config.workspaces[newName] = config.workspaces[oldName];
496
+ delete config.workspaces[oldName];
497
+ await this.saveConfig(config);
498
+ return {name: newName, migrated: true, migratedTabs: updated.length};
499
+ } catch(error) {
500
+ if (group) await this.chrome.tabGroups.update(group.id, {title: oldName}).catch(() => {});
501
+ for (const tab of updated) {
502
+ await this.chrome.tabs.update(tab.id,{url: tab.url,active:false}).catch(() => {});
503
+ }
504
+ throw error;
505
+ }
506
+ }
507
+
469
508
  async ensure(name, defaultPoolSize = 8) {
470
509
  const normalizedName = normalizeWorkspaceName(name);
471
510
  const { entry } = await this.configuredWorkspace(normalizedName);
@@ -544,7 +583,7 @@ export class WorkspaceManager {
544
583
  active: false,
545
584
  });
546
585
  // Group reconciliation can fire before the extension idle URL is visible.
547
- // Mark ownership first so a legitimate Browser Harness lease is not ejected.
586
+ // Mark ownership first so a legitimate WebHelm lease is not ejected.
548
587
  this.markOwned(tab.id);
549
588
  await this.chrome.tabs.group({ groupId: ready.groupId, tabIds: [tab.id] });
550
589
  tab = await this.waitForIdleTab(tab.id, normalizedName);
@@ -650,6 +689,7 @@ export class WorkspaceManager {
650
689
  if (method === "workspace.ensure") return await this.ensure(args.name, args.poolSize);
651
690
  if (method === "workspace.status") return await this.status(args.name);
652
691
  if (method === "workspace.list") return await this.list();
692
+ if (method === "workspace.migrateDefault") return await this.migrateDefaultWorkspace(Number(args.activeLegacySessions || 0));
653
693
  if (method === "workspace.acquire") return await this.acquire(args.name, args.url);
654
694
  if (method === "workspace.acquireIdentity") return await this.acquireIdentity(args.name);
655
695
  if (method === "workspace.release") return await this.release(args.name, args.tabId);
@@ -1,7 +1,29 @@
1
1
  const params = new URLSearchParams(location.search);
2
+ const workspace = params.get("workspace") || "";
2
3
  const role = params.get("role") || "";
3
4
  const slot = params.get("slot") || "";
5
+ const validSlot = /^\d+$/.test(slot);
4
6
 
5
- document.title = role === "lease" && /^\d+$/.test(slot)
6
- ? `__BW_TAB_${slot}__`
7
- : "Browser Workspace";
7
+ // Keep this exact lease title: WebHelm's tab identity protocol depends on it.
8
+ document.title = role === "lease" && validSlot
9
+ ? `${location.pathname.endsWith("/workspace.html") ? "__BW_TAB_" : "__WEBHELM_TAB_"}${slot}__`
10
+ : role === "idle" && workspace
11
+ ? "WebHelm · Idle"
12
+ : "WebHelm";
13
+
14
+ const setText = (id, value) => {
15
+ const element = document.getElementById(id);
16
+ if (element) element.textContent = value;
17
+ };
18
+
19
+ setText("workspace-name", workspace || "Not assigned");
20
+ setText("slot-number", validSlot ? slot : "—");
21
+
22
+ if (role === "idle" && workspace) {
23
+ setText("status-label", "Idle · Awaiting next task");
24
+ setText("description", "This tab is in the WebHelm pool and will be reused automatically when a task needs it.");
25
+ setText("help-text", "No action required. Leave this tab open. This status describes the tab, not daemon connectivity.");
26
+ } else if (role === "lease" && validSlot && workspace) {
27
+ setText("status-label", "Reserved for a task");
28
+ setText("description", "WebHelm has assigned this tab to a browser session.");
29
+ }
@@ -0,0 +1,35 @@
1
+ export function summarizeWorkspaces(workspaces, sessions = []) {
2
+ if (!Array.isArray(workspaces)) throw new Error('Workspace list is invalid');
3
+ const configured = workspaces.map(workspace => ({
4
+ name: workspace.name,
5
+ initialized: Boolean(workspace.initialized),
6
+ poolSize: Number.isInteger(workspace.poolSize) ? workspace.poolSize : null,
7
+ idleTabs: workspace.initialized ? workspace.idle : null,
8
+ leasedTabs: workspace.initialized ? workspace.leased : null,
9
+ available: workspace.initialized
10
+ ? Math.max(0, workspace.poolSize - workspace.leased) : null,
11
+ sessions: sessions.filter(session => session.workspace === workspace.name),
12
+ reason: workspace.reason || null,
13
+ }));
14
+ const configuredNames = new Set(configured.map(workspace => workspace.name));
15
+ // A running Session can outlive a deleted workspace group. Do not hide it.
16
+ for (const session of sessions) {
17
+ if (configuredNames.has(session.workspace)) continue;
18
+ configuredNames.add(session.workspace);
19
+ configured.push({
20
+ name: session.workspace, initialized: false, poolSize: null, idleTabs: null,
21
+ leasedTabs: null, available: null, sessions: sessions.filter(s => s.workspace === session.workspace),
22
+ reason: 'not-configured',
23
+ });
24
+ }
25
+ const known = configured.filter(workspace => workspace.initialized && workspace.available !== null);
26
+ return {
27
+ workspaces: configured,
28
+ totalAvailable: configured.length && known.length === configured.length
29
+ ? known.reduce((total, workspace) => total + workspace.available, 0) : null,
30
+ totalPhysicalIdle: known.length === configured.length
31
+ ? known.reduce((total, workspace) => total + workspace.idleTabs, 0) : null,
32
+ totalSessions: sessions.length,
33
+ partial: known.length !== configured.length,
34
+ };
35
+ }
@@ -1,8 +1,11 @@
1
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
2
4
  <meta charset="utf-8">
3
- <title>Browser Workspace</title>
5
+ <title>WebHelm · Legacy Tab</title>
4
6
  <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>
7
+ </head>
8
+ <body>
9
+ <p>This is a legacy WebHelm tab. It will return to the managed pool when its session ends.</p>
10
+ </body>
11
+ </html>
@@ -8,7 +8,11 @@ import {LOCAL_ROOT} from './config-paths.mjs';
8
8
  export const EXTENSION_ID='kkkpgmhpoeceojalkpfdidffdfnlgdog';
9
9
  export const EXTENSION_HOME=path.resolve(process.env.WEBHELM_EXTENSION_HOME || path.join(LOCAL_ROOT,'extension'));
10
10
  const SOURCE=fileURLToPath(new URL('./extension/',import.meta.url));
11
- const FILES=['manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','workspace-page.mjs'];
11
+ const FILES=[
12
+ 'manifest.json','service-worker.mjs','workspace-manager.mjs','workspace.html','helm.html','workspace-page.mjs',
13
+ 'control-channel.mjs','workspace-summary.mjs','popup.html','popup.mjs',
14
+ ...[16,32,48,128].map(size => `icons/webhelm-${size}.png`),
15
+ ];
12
16
 
13
17
  function manifest(dir){
14
18
  const data=JSON.parse(fs.readFileSync(path.join(dir,'manifest.json'),'utf8'));
@@ -19,9 +23,20 @@ function manifest(dir){
19
23
  return data;
20
24
  }
21
25
  export const extensionVersion=manifest(SOURCE).version;
26
+ function canonicalPath(value) {
27
+ try { return fs.realpathSync(value || ''); }
28
+ catch { return path.resolve(value || ''); }
29
+ }
30
+
31
+ function listRelativeFiles(dir, prefix='') {
32
+ return fs.readdirSync(path.join(dir,prefix),{withFileTypes:true}).flatMap(entry => {
33
+ const relative=path.join(prefix,entry.name);
34
+ return entry.isDirectory() ? listRelativeFiles(dir,relative) : [relative];
35
+ });
36
+ }
22
37
  function identical(dir){
23
38
  return fs.existsSync(path.join(dir,'manifest.json'))&&
24
- JSON.stringify(fs.readdirSync(dir).sort())===JSON.stringify([...FILES].sort())&&
39
+ JSON.stringify(listRelativeFiles(dir).sort())===JSON.stringify([...FILES].sort())&&
25
40
  FILES.every(name=>fs.readFileSync(path.join(dir,name)).equals(fs.readFileSync(path.join(SOURCE,name))));
26
41
  }
27
42
  // Actions checkout contains this self-managed directory, but Git must never publish it.
@@ -44,7 +59,10 @@ export function stageExtension(dir=EXTENSION_HOME){
44
59
  const stage=fs.mkdtempSync(path.join(root,'.webhelm-extension-stage-'));
45
60
  let backup;
46
61
  try {
47
- for(const filename of FILES)fs.copyFileSync(path.join(SOURCE,filename),path.join(stage,filename));
62
+ for(const filename of FILES){
63
+ fs.mkdirSync(path.dirname(path.join(stage,filename)),{recursive:true});
64
+ fs.copyFileSync(path.join(SOURCE,filename),path.join(stage,filename));
65
+ }
48
66
  if(!identical(stage))throw Error('WebHelm extension staged content mismatch');
49
67
  if(fs.existsSync(target)){
50
68
  backup=path.join(root,`.webhelm-extension-backup-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
@@ -67,7 +85,7 @@ export async function ensureExtensionLoaded(browser, {skip=false,extensionDir=EX
67
85
  try{
68
86
  const state=await browser.send('Extensions.getExtensions');
69
87
  modern=true;installed=state.extensions?.find(e=>e.id===EXTENSION_ID);
70
- if(!installed || !installed.enabled || installed.version!==version || path.resolve(installed.path||'')!==dir){
88
+ if(!installed || !installed.enabled || installed.version!==version || canonicalPath(installed.path)!==canonicalPath(dir)){
71
89
  if(!allowUpdate)throw Error('Extension reload deferred: active browser Sessions must finish');
72
90
  const loaded=await browser.send('Extensions.loadUnpacked',{path:dir});
73
91
  if(loaded.id!==EXTENSION_ID)throw Error('Chrome loaded an unexpected extension ID');
@@ -83,7 +101,7 @@ export async function ensureExtensionLoaded(browser, {skip=false,extensionDir=EX
83
101
  let target=targetInfos.find(t=>t.type==='service_worker'&&t.url.startsWith(prefix));
84
102
  let temporary=false;
85
103
  if(!target){
86
- const created=await browser.send('Target.createTarget',{url:`${prefix}workspace.html?webhelm_wakeup=1`,background:true});
104
+ const created=await browser.send('Target.createTarget',{url:`${prefix}helm.html?webhelm_wakeup=1`,background:true});
87
105
  target={targetId:created.targetId};temporary=true;
88
106
  }
89
107
  let attached;
@@ -9,6 +9,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { invokePlatformAction } from './platforms.mjs';
10
10
  import { ChatGPTQueue } from './chatgpt/chatgpt-queue.mjs';
11
11
  import { idleUrlFromIdentity, sameIdleUrl, staleSession } from './session-lifecycle.mjs';
12
+ import { stopMatchingSessions } from './session-control.mjs';
12
13
  import { ensureManagedChrome } from './chrome.mjs';
13
14
  import { ensureExtensionLoaded } from './extension-manager.mjs';
14
15
  import { createManagedChromeGuard } from './managed-chrome-guard.mjs';
@@ -70,8 +71,9 @@ async function acquireIdentityLease(session) {
70
71
  }
71
72
 
72
73
  async function connectBrowser() {
73
- if(browserWs && !browserWs.closed)return browserWs;
74
+ // Never expose a CDP connection before Extension initialization finishes.
74
75
  if(connectingBrowser)return connectingBrowser;
76
+ if(browserWs && !browserWs.closed)return browserWs;
75
77
  connectingBrowser=(async()=>{
76
78
  managedChrome=await ensureManagedChrome();
77
79
  const endpoint=managedChrome.endpoint;
@@ -122,9 +124,18 @@ async function connectBrowser() {
122
124
  finally{connectingBrowser=null;}
123
125
  }
124
126
 
125
- // The daemon owns both the managed Chrome process and the default Harness
127
+ // The daemon owns both the managed Chrome process and the default WebHelm
126
128
  // group. Chrome auto-updates can terminate the browser while the Node daemon
127
129
  // survives; an RPC-only health check cannot recover that loss.
130
+ const defaultWorkspaceMutex=new Mutex();
131
+ async function defaultWorkspaceName() {
132
+ if (process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE === '1') return 'WebHelm';
133
+ return defaultWorkspaceMutex.run(async()=>{
134
+ const activeLegacySessions=[...sessions.values()].filter(s=>s.workspace==='Harness').length;
135
+ const result=await extensionCall(null,'workspace.migrateDefault',{activeLegacySessions});
136
+ return result.name;
137
+ });
138
+ }
128
139
  async function guardManagedChrome(){
129
140
  if(shuttingDown || process.env.WEBHELM_CDP_URL || process.env.WEBHELM_NO_PROFILE_EXTENSION_PROBE==='1')
130
141
  return {skipped:true};
@@ -138,17 +149,18 @@ async function guardManagedChrome(){
138
149
  managedChrome=actual;
139
150
  // Session leasing also creates missing workspaces. Serialize this short
140
151
  // identity transaction with session start so one group is created, not two.
141
- const state=await leaseMutex('Harness').run(async()=>{
152
+ const name=await defaultWorkspaceName();
153
+ const state=await leaseMutex(name).run(async()=>{
142
154
  let current;
143
- try {current=await extensionCall(null,'workspace.status',{name:'Harness'});}
155
+ try {current=await extensionCall(null,'workspace.status',{name});}
144
156
  catch(error){
145
157
  if(sessions.size)throw error; // Never hot reload an Extension used by active Sessions.
146
158
  const browser=await connectBrowser();
147
159
  await ensureExtensionLoaded(browser);
148
- current=await extensionCall(null,'workspace.status',{name:'Harness'});
160
+ current=await extensionCall(null,'workspace.status',{name});
149
161
  }
150
162
  if(!current?.initialized || !Number.isInteger(current.physicalTabs) || current.physicalTabs<1)
151
- current=await extensionCall(null,'workspace.ensure',{name:'Harness'});
163
+ current=await extensionCall(null,'workspace.ensure',{name});
152
164
  return current;
153
165
  });
154
166
  return {managed_chrome_pid:actual.pid,group_id:state.groupId,physical_tabs:state.physicalTabs};
@@ -282,7 +294,13 @@ async function evaluateExtension(browser,sessionId,expression){
282
294
  if(!value?.ok)throw Error(value?.error?.message||'workspace extension RPC failed');
283
295
  return value.result;
284
296
  }
285
- async function extensionCall(_session,method,args={}) {
297
+ // Extension service-worker RPC uses one debugger attachment and must not
298
+ // race concurrent capacity polls, migration and browser Session allocation.
299
+ const extensionRpcMutex=new Mutex();
300
+ async function extensionCall(session,method,args={}) {
301
+ return extensionRpcMutex.run(()=>extensionCallSerialized(session,method,args));
302
+ }
303
+ async function extensionCallSerialized(_session,method,args={}) {
286
304
  const browser=await connectBrowser();
287
305
  const request=JSON.stringify({method,args});
288
306
  const {targetInfos=[]}=await browser.send('Target.getTargets');
@@ -297,7 +315,7 @@ async function extensionCall(_session,method,args={}) {
297
315
  `globalThis.browserWorkspaceManagerRpc(${request})`);
298
316
  }catch(error){debuggerSession=null;throw error;}
299
317
  }
300
- const page=targetInfos.find(t=>t.type==='page'&&t.url.startsWith(EXTENSION_PREFIX)&&t.url.includes('/workspace.html'));
318
+ const page=targetInfos.find(t=>t.type==='page'&&t.url.startsWith(EXTENSION_PREFIX)&&(t.url.includes('/workspace.html')||t.url.includes('/helm.html')));
301
319
  if(page){
302
320
  const {sessionId}=await browser.send('Target.attachToTarget',{targetId:page.targetId,flatten:true});
303
321
  try{return await evaluateExtension(browser,sessionId,`chrome.runtime.sendMessage(${request})`);}
@@ -307,7 +325,7 @@ async function extensionCall(_session,method,args={}) {
307
325
  // Wake a suspended MV3 worker from a temporary background extension page.
308
326
  // Only this temporary target is closed; no user tab is reused or modified.
309
327
  const {targetId}=await browser.send('Target.createTarget',{
310
- url:`${EXTENSION_PREFIX}workspace.html?webhelm_wakeup=1`,background:true});
328
+ url:`${EXTENSION_PREFIX}helm.html?webhelm_wakeup=1`,background:true});
311
329
  let sessionId;
312
330
  try{
313
331
  ({sessionId}=await browser.send('Target.attachToTarget',{targetId,flatten:true}));
@@ -333,7 +351,7 @@ function jsonSafe(value){try{JSON.stringify(value);return {value}}catch{return {
333
351
  async function createSession(req={}) {
334
352
  const browser=await connectBrowser();
335
353
  const sid=crypto.randomBytes(8).toString('hex');
336
- const s={session_id:sid, workspace:req.workspace||'Harness', targetId:null,
354
+ const s={session_id:sid, workspace:req.workspace||await defaultWorkspaceName(), targetId:null,
337
355
  rootTargetId:null, workspaceSupported:false,targets:new Set(), attachments:new Map(), eventDisposers:new Map(),
338
356
  leasedTabs:new Map(), idleUrls:new Map(), mutex:new Mutex(), events:[], namespace:null,context:null,
339
357
  inFlight:0, closing:false, last_used_at:Date.now()};
@@ -376,10 +394,10 @@ async function extensionSupported() {
376
394
  if(!installed)return false;
377
395
  let error;
378
396
  for(let attempt=0;attempt<3;attempt++){
379
- try{await extensionCall({workspace:'Harness'},'workspace.list');return true;}
397
+ try{await extensionCall({workspace:'WebHelm'},'workspace.list');return true;}
380
398
  catch(e){error=e;if(attempt<2)await delay(150);}
381
399
  }
382
- throw Error(`Browser Workspace extension installed but RPC unavailable: ${error?.message||'unknown'}`);
400
+ throw Error(`WebHelm extension installed but RPC unavailable: ${error?.message||'unknown'}`);
383
401
  }
384
402
 
385
403
  async function execute(req) {
@@ -431,7 +449,8 @@ async function closeSessionOwned(s){
431
449
  s.closing=true;
432
450
  let release_error=null;
433
451
  try {
434
- const count=s.targets.size;
452
+ const returnedTabs=s.leasedTabs.size;
453
+ const closedChildTabs=s.targets.size - returnedTabs;
435
454
  await s.mutex.run(async()=>{
436
455
  const browser=await connectBrowser();
437
456
  // Return managed tabs to their original Extension pool; keep their CDP targets.
@@ -449,7 +468,7 @@ async function closeSessionOwned(s){
449
468
  s.targets.clear();s.attachments.clear();s.leasedTabs.clear();s.idleUrls.clear();s.eventDisposers.clear();
450
469
  sessions.delete(s.session_id);
451
470
  });
452
- return {session_id:s.session_id,workspace:s.workspace,target_id:s.rootTargetId,closed_tabs:count,returned_to_idle:true,release_error};
471
+ return {session_id:s.session_id,workspace:s.workspace,target_id:s.rootTargetId,closed_tabs:closedChildTabs,returned_tabs:returnedTabs,returned_to_idle:true,release_error};
453
472
  }catch(error){
454
473
  s.closing=false; // Keep ownership for a future retry; never reassign on unconfirmed navigation.
455
474
  throw error;
@@ -488,7 +507,7 @@ function heartbeatSession(sid){
488
507
  return {session_id:sid,alive:true,last_used_at:s.last_used_at};
489
508
  }
490
509
 
491
- async function workspaceOp(req){const h=makeHelpers({workspace:req.name||'Harness',targetId:'',targets:new Set(),attachments:new Map()});if(req.op==='workspace-create')return h.workspace_create(req.name,req.pool_size);if(req.op==='workspace-delete')return h.workspace_delete(req.name,req.force);throw Error(`unknown op: ${req.op}`)}
510
+ async function workspaceOp(req){const h=makeHelpers({workspace:req.name||await defaultWorkspaceName(),targetId:'',targets:new Set(),attachments:new Map()});if(req.op==='workspace-create')return h.workspace_create(req.name,req.pool_size);if(req.op==='workspace-delete')return h.workspace_delete(req.name,req.force);throw Error(`unknown op: ${req.op}`)}
492
511
  function getChatGPTQueue() {
493
512
  if (chatgptQueue) return chatgptQueue;
494
513
  chatgptQueue = new ChatGPTQueue({
@@ -505,7 +524,7 @@ function getChatGPTQueue() {
505
524
  }
506
525
 
507
526
  async function workspaceCapacity(req={}) {
508
- const name=typeof req.workspace==='string'&&req.workspace.trim()?req.workspace.trim():'Harness';
527
+ const name=typeof req.workspace==='string'&&req.workspace.trim()?req.workspace.trim():await defaultWorkspaceName();
509
528
  const observedAt=new Date().toISOString();
510
529
  try {
511
530
  const state=await extensionCall(null,'workspace.status',{name});
@@ -523,6 +542,58 @@ async function workspaceCapacity(req={}) {
523
542
  }
524
543
  }
525
544
 
545
+ // The popup never runs commands. The daemon is the only actor that consumes
546
+ // Extension control requests, over the already-authenticated CDP channel.
547
+ let controlPollActive = false;
548
+ function visibleSessions() {
549
+ return [...sessions.values()].map(s => ({
550
+ session_id:s.session_id,workspace:s.workspace,last_used_at:s.last_used_at,
551
+ }));
552
+ }
553
+ async function pollExtensionControl() {
554
+ if (controlPollActive || shuttingDown) return;
555
+ controlPollActive = true;
556
+ try {
557
+ const pendingAction = await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions()});
558
+ if (!pendingAction) return;
559
+ const {id,action,sessionId} = pendingAction;
560
+ // Keep the status panel fresh while a lengthy Session stop waits for
561
+ // browser navigation or an in-flight task.
562
+ const controlHeartbeat=setInterval(()=>{
563
+ extensionCall(null,'workspace.control.heartbeat',{sessions:visibleSessions()})
564
+ .catch(error=>console.warn(`WebHelm control heartbeat: ${error.message}`));
565
+ },3000);
566
+ let response;
567
+ try {
568
+ if (action === 'stop-all') {
569
+ response = await stopAllSessions();
570
+ } else if (action === 'stop-workspace') {
571
+ response = await stopWorkspaceSessions(pendingAction.workspace);
572
+ } else if (action === 'stop') {
573
+ response = {ok:true,stopped:await stopSession(sessionId)};
574
+ } else {
575
+ throw Error('Unknown extension control action');
576
+ }
577
+ } catch(error) {
578
+ response = {ok:false,error:String(error?.message||error)};
579
+ } finally {
580
+ clearInterval(controlHeartbeat);
581
+ }
582
+ await extensionCall(null,'workspace.control.complete',{id,response});
583
+ await extensionCall(null,'workspace.control.poll',{sessions:visibleSessions()});
584
+ } catch(error) {
585
+ console.warn(`WebHelm extension control sync failed: ${error.message}`);
586
+ } finally {
587
+ controlPollActive = false;
588
+ }
589
+ }
590
+ function stopWorkspaceSessions(workspace) {
591
+ return stopMatchingSessions(sessions, session => session.workspace === workspace, stopSession);
592
+ }
593
+ function stopAllSessions() {
594
+ return stopMatchingSessions(sessions, () => true, stopSession);
595
+ }
596
+
526
597
  /** Emit one canonical machine-readable action receipt as the final stdout line. */
527
598
  export function actionOutput(logs, value) {
528
599
  const entries=Array.isArray(logs)?logs:[];
@@ -575,7 +646,7 @@ async function runPlatform(req) {
575
646
  self_heal_capable:!auto,result};
576
647
  }
577
648
 
578
- export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true},chrome_guard:managedChromeGuard.status()};case'guard-check':{let status=await managedChromeGuard.tick();for(let retry=0;retry<6 && status.status!=='healthy' && !status.skipped;retry++){await delay(800);status=await managedChromeGuard.tick();}if(status.status!=='healthy' && !status.skipped)throw Error(`WebHelm Chrome health degraded: ${status.error||status.status}`);return {ok:true,chrome_guard:status};}case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':{const stopped=[];for(const id of [...sessions.keys()])stopped.push(await stopSession(id));return {stopped_count:stopped.length,error_count:0,stopped,errors:[]}}case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
649
+ export async function handle(req){switch(req.op){case'ping':return {ok:true,pid:process.pid,version:PACKAGE_VERSION,queue_paused:process.env.WEBHELM_CHATGPT_QUEUE_PAUSED==='1',session_count:sessions.size,managed_chrome:managedChrome||{profile_dir:PROFILE_DIR, endpoint:process.env.WEBHELM_CDP_URL||'http://127.0.0.1:9222',reused:true},chrome_guard:managedChromeGuard.status()};case'guard-check':{let status=await managedChromeGuard.tick();for(let retry=0;retry<6 && status.status!=='healthy' && !status.skipped;retry++){await delay(800);status=await managedChromeGuard.tick();}if(status.status!=='healthy' && !status.skipped)throw Error(`WebHelm Chrome health degraded: ${status.error||status.status}`);return {ok:true,chrome_guard:status};}case'heartbeat':return heartbeatSession(req.session_id);case'list':return {session_count:sessions.size,sessions:[...sessions.values()].map(s=>({session_id:s.session_id,workspace:s.workspace,target_id:s.targetId,last_used_at:s.last_used_at}))};case'start':return createSession(req);case'exec':return execute(req);case'stop':return stopSession(req.session_id);case'stop-all':return stopAllSessions();case'capacity':return workspaceCapacity(req);case'workspace-create':case'workspace-delete':return workspaceOp(req);case'chatgpt-new-turn-enqueue':return getChatGPTQueue().enqueue(req.config);case'platform-run':return runPlatform(req);default:throw Error(`unknown op: ${req.op}`)}}
579
650
  function serve(conn) {
580
651
  let buffer = '';
581
652
  conn.setEncoding('utf8');
@@ -593,7 +664,10 @@ function serve(conn) {
593
664
  });
594
665
  }
595
666
 
596
- async function main(){fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});let lockfd;try{lockfd=fs.openSync(LOCK,'wx',0o600);fs.writeFileSync(lockfd,String(process.pid))}catch{throw Error(`Node pilot lock exists: ${LOCK}`)}if(fs.existsSync(SOCKET)){try{const c=net.createConnection(SOCKET);await new Promise((res,rej)=>{c.once('connect',res);c.once('error',rej)});c.destroy();fs.closeSync(lockfd);fs.unlinkSync(LOCK);throw Error(`Node daemon socket already active: ${SOCKET}`)}catch(e){if(e.message.includes('already active'))throw e;fs.unlinkSync(SOCKET)}}socket=net.createServer(serve);await new Promise((res,rej)=>socket.listen(SOCKET,res).once('error',rej));fs.chmodSync(SOCKET,0o600);connectBrowser().catch(()=>{});managedChromeGuard.start();getChatGPTQueue();const cleanup=async()=>{if(shuttingDown)return;shuttingDown=true;managedChromeGuard.stop();if(chatgptQueue)await chatgptQueue.shutdown().catch(()=>{});await handle({op:'stop-all'}).catch(()=>{});socket?.close();try{if(fs.existsSync(SOCKET))fs.unlinkSync(SOCKET)}catch{}try{fs.closeSync(lockfd);fs.unlinkSync(LOCK)}catch{}process.exit(0)};process.on('SIGTERM',cleanup);process.on('SIGINT',cleanup)}
667
+ async function main(){fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});let lockfd;try{lockfd=fs.openSync(LOCK,'wx',0o600);fs.writeFileSync(lockfd,String(process.pid))}catch{throw Error(`Node pilot lock exists: ${LOCK}`)}if(fs.existsSync(SOCKET)){try{const c=net.createConnection(SOCKET);await new Promise((res,rej)=>{c.once('connect',res);c.once('error',rej)});c.destroy();fs.closeSync(lockfd);fs.unlinkSync(LOCK);throw Error(`Node daemon socket already active: ${SOCKET}`)}catch(e){if(e.message.includes('already active'))throw e;fs.unlinkSync(SOCKET)}}socket=net.createServer(serve);await new Promise((res,rej)=>socket.listen(SOCKET,res).once('error',rej));fs.chmodSync(SOCKET,0o600);connectBrowser().catch(()=>{});managedChromeGuard.start();getChatGPTQueue();
668
+ const controlTimer=setInterval(()=>pollExtensionControl(),3000);
669
+ pollExtensionControl().catch(()=>{});
670
+ const cleanup=async()=>{if(shuttingDown)return;shuttingDown=true;clearInterval(controlTimer);managedChromeGuard.stop();if(chatgptQueue)await chatgptQueue.shutdown().catch(()=>{});await handle({op:'stop-all'}).catch(()=>{});socket?.close();try{if(fs.existsSync(SOCKET))fs.unlinkSync(SOCKET)}catch{}try{fs.closeSync(lockfd);fs.unlinkSync(LOCK)}catch{}process.exit(0)};process.on('SIGTERM',cleanup);process.on('SIGINT',cleanup)}
597
671
  async function request(payload,ensure=true){if(ensure)await ensureDaemon();return new Promise((resolve,reject)=>{const c=net.createConnection(SOCKET);let data='';c.setEncoding('utf8');c.on('connect',()=>c.write(JSON.stringify(payload)+'\n'));c.on('data',x=>{data+=x;if(!data.includes('\n'))return;c.end();try{const r=JSON.parse(data);r.error?reject(Error(r.error)):resolve(r)}catch(e){reject(e)}});c.on('error',reject)})}
598
672
  export async function ensureDaemon(){try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}if(!process.env.WEBHELM_CDP_URL)requireMigratedConfig();fs.mkdirSync(RUNTIME,{recursive:true,mode:0o700});if(fs.existsSync(LOCK)){let owner=0;try{owner=Number(fs.readFileSync(LOCK,'utf8').trim())}catch{};let alive=false;if(owner>0)try{process.kill(owner,0);alive=true}catch{};if(alive)throw Error(`daemon owner lock exists but socket is unreachable; refusing duplicate startup (${LOCK})`);fs.unlinkSync(LOCK)}const child=await import('node:child_process');const log=fs.openSync(path.join(RUNTIME,'session-node.log'),'a');child.spawn(process.execPath,[fileURLToPath(import.meta.url),'daemon'],{detached:true,stdio:['ignore',log,log],env:process.env}).unref();for(let i=0;i<100;i++){await delay(100);try{const p=await request({op:'ping'},false);if(p.ok)return p}catch{}}throw Error(`WebHelm Node daemon did not start; see ${path.join(RUNTIME,'session-node.log')}`)}
599
673
  if(process.argv[2]==='daemon')main().catch(e=>{console.error(e);process.exit(1)});
@@ -0,0 +1,13 @@
1
+ // A Session is stopped by its daemon owner, never by direct tab mutation.
2
+ export async function stopMatchingSessions(entries, predicate, stopSession) {
3
+ const stopped = [], errors = [];
4
+ for (const [sessionId, session] of [...entries]) {
5
+ if (!predicate(session)) continue;
6
+ try { stopped.push(await stopSession(sessionId)); }
7
+ catch (error) {
8
+ errors.push({session_id:sessionId,error:String(error?.message || error)});
9
+ }
10
+ }
11
+ return {ok:errors.length === 0,stopped_count:stopped.length,
12
+ error_count:errors.length,stopped,errors};
13
+ }
@@ -8,7 +8,7 @@ export function staleSession(session, now = Date.now(), ttl = SESSION_IDLE_MS) {
8
8
 
9
9
  export function idleUrlFromIdentity(identityUrl, tabId) {
10
10
  const url = new URL(identityUrl);
11
- if (url.protocol !== 'chrome-extension:' || !url.pathname.endsWith('/workspace.html')) {
11
+ if (url.protocol !== 'chrome-extension:' || !['/workspace.html','/helm.html'].includes(url.pathname)) {
12
12
  throw new Error('invalid extension identity URL');
13
13
  }
14
14
  url.searchParams.set('role', 'idle');