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 +10 -0
- package/package.json +3 -2
- package/runtime/cli.mjs +2 -2
- package/runtime/extension/control-channel.mjs +120 -0
- package/runtime/extension/helm.html +125 -0
- package/runtime/extension/icons/webhelm-128.png +0 -0
- package/runtime/extension/icons/webhelm-16.png +0 -0
- package/runtime/extension/icons/webhelm-32.png +0 -0
- package/runtime/extension/icons/webhelm-48.png +0 -0
- package/runtime/extension/manifest.json +8 -4
- package/runtime/extension/popup.html +61 -0
- package/runtime/extension/popup.mjs +136 -0
- package/runtime/extension/service-worker.mjs +96 -45
- package/runtime/extension/workspace-manager.mjs +46 -6
- package/runtime/extension/workspace-page.mjs +25 -3
- package/runtime/extension/workspace-summary.mjs +35 -0
- package/runtime/extension/workspace.html +8 -5
- package/runtime/extension-manager.mjs +23 -5
- package/runtime/node-daemon.mjs +92 -18
- package/runtime/session-control.mjs +13 -0
- package/runtime/session-lifecycle.mjs +1 -1
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.
|
|
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
|
|
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>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
|
-
"name": "
|
|
4
|
-
"version": "0.
|
|
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": "
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
54
|
+
removeLegacyContextMenu().catch(() => {});
|
|
55
|
+
scheduleBadge();
|
|
30
56
|
});
|
|
31
|
-
|
|
32
57
|
chrome.runtime.onStartup.addListener(() => {
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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,
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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(`
|
|
45
|
+
return this.chrome.runtime.getURL(`helm.html?${queryString(workspace, slot)}`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
identityTitle(tabId) {
|
|
49
|
-
return `
|
|
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(`
|
|
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("
|
|
65
|
-
if (parsed.origin !== own.origin ||
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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>
|
|
5
|
+
<title>WebHelm · Legacy Tab</title>
|
|
4
6
|
<script type="module" src="workspace-page.mjs"></script>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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=[
|
|
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(
|
|
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)
|
|
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 ||
|
|
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}
|
|
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;
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
|
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||
|
|
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:'
|
|
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(`
|
|
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
|
|
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:
|
|
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||
|
|
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():
|
|
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':
|
|
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();
|
|
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:' || !
|
|
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');
|