webhelm 1.0.7 → 1.0.9
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 +1 -1
- package/package.json +1 -1
- package/runtime/extension/helm.html +1 -2
- package/runtime/extension/manifest.json +1 -1
- package/runtime/extension/workspace-manager.mjs +35 -13
- package/runtime/extension/workspace-page.mjs +6 -6
- package/runtime/node-daemon.mjs +1 -1
- package/runtime/session-lifecycle.mjs +7 -4
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ The daemon-managed Chrome Extension is installed from the npm Runtime and does n
|
|
|
35
35
|
|
|
36
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
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.
|
|
38
|
+
New installations use the default Chrome tab group `WebHelm` and the idle page `helm.html`. Idle URLs use only `workspace` and `role=idle`; Lease URLs use Chrome's actual `tabId` for initial CDP target discovery. There is no separate slot identity. The default `WebHelm` tab group is placed at the first position after pinned tabs when created or restored, while Session allocation does not override later user rearrangement. Chrome-saved tab group shortcuts in the bookmarks bar are separate from active groups and cannot safely be deduplicated through the Extension API. 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
39
|
|
|
40
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
41
|
|
package/package.json
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
.description { margin: 16px 0 24px; color: var(--subtle); }
|
|
73
73
|
dl {
|
|
74
74
|
display: grid;
|
|
75
|
-
grid-template-columns: minmax(0, 1fr)
|
|
75
|
+
grid-template-columns: minmax(0, 1fr);
|
|
76
76
|
gap: 16px;
|
|
77
77
|
margin: 0;
|
|
78
78
|
padding: 18px 0;
|
|
@@ -117,7 +117,6 @@
|
|
|
117
117
|
<p class="description" id="description">This page belongs to WebHelm's managed browser workspace.</p>
|
|
118
118
|
<dl>
|
|
119
119
|
<div><dt>Workspace</dt><dd id="workspace-name">—</dd></div>
|
|
120
|
-
<div><dt>Tab slot</dt><dd id="slot-number">—</dd></div>
|
|
121
120
|
</dl>
|
|
122
121
|
<footer id="help-text">Tab status does not indicate whether the daemon is connected.</footer>
|
|
123
122
|
</main>
|
|
@@ -18,8 +18,8 @@ export function normalizePoolSize(value, fallback = 8) {
|
|
|
18
18
|
return number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function queryString(workspace
|
|
22
|
-
return new URLSearchParams({ workspace, role: "idle"
|
|
21
|
+
function queryString(workspace) {
|
|
22
|
+
return new URLSearchParams({ workspace, role: "idle" }).toString();
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export class WorkspaceManager {
|
|
@@ -41,8 +41,8 @@ export class WorkspaceManager {
|
|
|
41
41
|
if (Number.isInteger(id)) this.ownedTabIds.add(id);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
idleUrl(workspace
|
|
45
|
-
return this.chrome.runtime.getURL(`helm.html?${queryString(workspace
|
|
44
|
+
idleUrl(workspace) {
|
|
45
|
+
return this.chrome.runtime.getURL(`helm.html?${queryString(workspace)}`);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
identityTitle(tabId) {
|
|
@@ -53,7 +53,7 @@ export class WorkspaceManager {
|
|
|
53
53
|
const query = new URLSearchParams({
|
|
54
54
|
workspace,
|
|
55
55
|
role: "lease",
|
|
56
|
-
|
|
56
|
+
tabId: String(tabId),
|
|
57
57
|
}).toString();
|
|
58
58
|
return this.chrome.runtime.getURL(`helm.html?${query}`);
|
|
59
59
|
}
|
|
@@ -67,7 +67,7 @@ export class WorkspaceManager {
|
|
|
67
67
|
return {
|
|
68
68
|
workspace: parsed.searchParams.get("workspace") || "",
|
|
69
69
|
role: parsed.searchParams.get("role") || "",
|
|
70
|
-
|
|
70
|
+
tabId: parsed.searchParams.get("tabId") || "",
|
|
71
71
|
};
|
|
72
72
|
} catch {
|
|
73
73
|
return null;
|
|
@@ -128,6 +128,22 @@ export class WorkspaceManager {
|
|
|
128
128
|
return await this.chrome.tabs.query({ groupId });
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Only position the default group when it is created, migrated or restored.
|
|
132
|
+
// Never move it during routine Session allocation or periodic health checks,
|
|
133
|
+
// since the user may intentionally reorder the tab strip.
|
|
134
|
+
async positionDefaultGroupFirst(groupId, name) {
|
|
135
|
+
if (name !== "WebHelm") return false;
|
|
136
|
+
const groupTabs = await this.groupTabs(groupId);
|
|
137
|
+
if (!groupTabs.length) return false;
|
|
138
|
+
const windowId = groupTabs[0].windowId;
|
|
139
|
+
const windowTabs = await this.chrome.tabs.query({windowId});
|
|
140
|
+
const firstUnpinnedIndex = windowTabs.filter(tab => tab.pinned).length;
|
|
141
|
+
const firstGroupIndex = Math.min(...groupTabs.map(tab => tab.index));
|
|
142
|
+
if (firstGroupIndex === firstUnpinnedIndex) return false;
|
|
143
|
+
await this.chrome.tabGroups.move(groupId, {index: firstUnpinnedIndex});
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
131
147
|
tabUrl(tab) {
|
|
132
148
|
return String(tab?.pendingUrl || tab?.url || "");
|
|
133
149
|
}
|
|
@@ -371,12 +387,13 @@ export class WorkspaceManager {
|
|
|
371
387
|
// Chrome groups are created by grouping a tab. Keep one seed tab so the
|
|
372
388
|
// workspace has an identity, then grow the physical pool on demand.
|
|
373
389
|
const created = [await this.chrome.tabs.create({
|
|
374
|
-
url: this.idleUrl(name
|
|
390
|
+
url: this.idleUrl(name),
|
|
375
391
|
active: false,
|
|
376
392
|
})];
|
|
377
393
|
for (const tab of created) this.markOwned(tab.id);
|
|
378
394
|
const groupId = await this.chrome.tabs.group({ tabIds: created.map((tab) => tab.id) });
|
|
379
395
|
await this.chrome.tabGroups.update(groupId, { title: name, collapsed: true });
|
|
396
|
+
await this.positionDefaultGroupFirst(groupId, name);
|
|
380
397
|
for (const tab of created) await this.waitForIdleTab(tab.id, name);
|
|
381
398
|
return await this.status(name);
|
|
382
399
|
}
|
|
@@ -455,6 +472,8 @@ export class WorkspaceManager {
|
|
|
455
472
|
for (const name of Object.keys(config.workspaces || {}).sort()) {
|
|
456
473
|
restored.push(await this.ensureWorkspace(name));
|
|
457
474
|
}
|
|
475
|
+
const defaultGroup = restored.find(item => item.name === "WebHelm" && item.initialized);
|
|
476
|
+
if (defaultGroup) await this.positionDefaultGroupFirst(defaultGroup.groupId, "WebHelm");
|
|
458
477
|
return { workspaces: restored };
|
|
459
478
|
}
|
|
460
479
|
|
|
@@ -489,9 +508,12 @@ export class WorkspaceManager {
|
|
|
489
508
|
for (const tab of tabs) this.markOwned(tab.id);
|
|
490
509
|
if (group) await this.chrome.tabGroups.update(group.id, {title: newName});
|
|
491
510
|
for (const tab of tabs) {
|
|
492
|
-
await this.chrome.tabs.update(tab.id, {url: this.idleUrl(newName
|
|
511
|
+
await this.chrome.tabs.update(tab.id, {url: this.idleUrl(newName), active: false});
|
|
493
512
|
updated.push(tab);
|
|
494
513
|
}
|
|
514
|
+
// Position before committing the new config so move failures still
|
|
515
|
+
// unwind the legacy migration coherently in the catch block.
|
|
516
|
+
if (group) await this.positionDefaultGroupFirst(group.id, newName);
|
|
495
517
|
config.workspaces[newName] = config.workspaces[oldName];
|
|
496
518
|
delete config.workspaces[oldName];
|
|
497
519
|
await this.saveConfig(config);
|
|
@@ -538,7 +560,7 @@ export class WorkspaceManager {
|
|
|
538
560
|
let tab = idle[0];
|
|
539
561
|
if (!tab) {
|
|
540
562
|
tab = await this.chrome.tabs.create({
|
|
541
|
-
url: this.idleUrl(normalizedName
|
|
563
|
+
url: this.idleUrl(normalizedName),
|
|
542
564
|
active: false,
|
|
543
565
|
});
|
|
544
566
|
this.markOwned(tab.id);
|
|
@@ -557,7 +579,7 @@ export class WorkspaceManager {
|
|
|
557
579
|
tabId: updated.id,
|
|
558
580
|
identityTitle,
|
|
559
581
|
identityUrl,
|
|
560
|
-
idleUrl: this.idleUrl(normalizedName
|
|
582
|
+
idleUrl: this.idleUrl(normalizedName),
|
|
561
583
|
active: Boolean(updated.active),
|
|
562
584
|
};
|
|
563
585
|
}
|
|
@@ -579,7 +601,7 @@ export class WorkspaceManager {
|
|
|
579
601
|
let tab = idle[0];
|
|
580
602
|
if (!tab) {
|
|
581
603
|
tab = await this.chrome.tabs.create({
|
|
582
|
-
url: this.idleUrl(normalizedName
|
|
604
|
+
url: this.idleUrl(normalizedName),
|
|
583
605
|
active: false,
|
|
584
606
|
});
|
|
585
607
|
// Group reconciliation can fire before the extension idle URL is visible.
|
|
@@ -614,7 +636,7 @@ export class WorkspaceManager {
|
|
|
614
636
|
} else {
|
|
615
637
|
try {
|
|
616
638
|
await this.chrome.tabs.update(wanted, {
|
|
617
|
-
url: this.idleUrl(normalizedName
|
|
639
|
+
url: this.idleUrl(normalizedName),
|
|
618
640
|
active: false,
|
|
619
641
|
});
|
|
620
642
|
await this.waitForIdleTab(wanted, normalizedName);
|
|
@@ -623,7 +645,7 @@ export class WorkspaceManager {
|
|
|
623
645
|
// the extension idle page. Preserve pool capacity by creating a clean
|
|
624
646
|
// idle replacement in the same group before removing the stale tab.
|
|
625
647
|
const replacement = await this.chrome.tabs.create({
|
|
626
|
-
url: this.idleUrl(normalizedName
|
|
648
|
+
url: this.idleUrl(normalizedName),
|
|
627
649
|
active: false,
|
|
628
650
|
});
|
|
629
651
|
this.markOwned(replacement.id);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
const params = new URLSearchParams(location.search);
|
|
2
2
|
const workspace = params.get("workspace") || "";
|
|
3
3
|
const role = params.get("role") || "";
|
|
4
|
-
|
|
5
|
-
const
|
|
4
|
+
// A lease carries the actual Chrome tabId for one-time CDP target discovery.
|
|
5
|
+
const tabId = params.get("tabId") || "";
|
|
6
|
+
const validTabId = /^\d+$/.test(tabId);
|
|
6
7
|
|
|
7
8
|
// Keep this exact lease title: WebHelm's tab identity protocol depends on it.
|
|
8
|
-
document.title = role === "lease" &&
|
|
9
|
-
? `${location.pathname.endsWith("/workspace.html") ? "__BW_TAB_" : "__WEBHELM_TAB_"}${
|
|
9
|
+
document.title = role === "lease" && validTabId
|
|
10
|
+
? `${location.pathname.endsWith("/workspace.html") ? "__BW_TAB_" : "__WEBHELM_TAB_"}${tabId}__`
|
|
10
11
|
: role === "idle" && workspace
|
|
11
12
|
? "WebHelm · Idle"
|
|
12
13
|
: "WebHelm";
|
|
@@ -17,13 +18,12 @@ const setText = (id, value) => {
|
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
setText("workspace-name", workspace || "Not assigned");
|
|
20
|
-
setText("slot-number", validSlot ? slot : "—");
|
|
21
21
|
|
|
22
22
|
if (role === "idle" && workspace) {
|
|
23
23
|
setText("status-label", "Idle · Awaiting next task");
|
|
24
24
|
setText("description", "This tab is in the WebHelm pool and will be reused automatically when a task needs it.");
|
|
25
25
|
setText("help-text", "No action required. Leave this tab open. This status describes the tab, not daemon connectivity.");
|
|
26
|
-
} else if (role === "lease" &&
|
|
26
|
+
} else if (role === "lease" && validTabId && workspace) {
|
|
27
27
|
setText("status-label", "Reserved for a task");
|
|
28
28
|
setText("description", "WebHelm has assigned this tab to a browser session.");
|
|
29
29
|
}
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -65,7 +65,7 @@ async function acquireIdentityLease(session) {
|
|
|
65
65
|
// Do not navigate/release a tab still owned by another Session.
|
|
66
66
|
throw Error('Workspace extension returned a target already owned by another Session');
|
|
67
67
|
}
|
|
68
|
-
lease.idleUrl ||= idleUrlFromIdentity(lease.identityUrl
|
|
68
|
+
lease.idleUrl ||= idleUrlFromIdentity(lease.identityUrl);
|
|
69
69
|
return {lease,targetId};
|
|
70
70
|
});
|
|
71
71
|
}
|
|
@@ -6,13 +6,16 @@ export function staleSession(session, now = Date.now(), ttl = SESSION_IDLE_MS) {
|
|
|
6
6
|
now - session.last_used_at >= ttl;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export function idleUrlFromIdentity(identityUrl
|
|
9
|
+
export function idleUrlFromIdentity(identityUrl) {
|
|
10
10
|
const url = new URL(identityUrl);
|
|
11
|
-
if (url.protocol !== 'chrome-extension:' || !['/workspace.html','/helm.html'].includes(url.pathname)) {
|
|
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
|
-
url.searchParams.
|
|
15
|
-
|
|
14
|
+
const workspace = url.searchParams.get('workspace');
|
|
15
|
+
if (!workspace) throw new Error('missing workspace in extension identity URL');
|
|
16
|
+
// Construct the canonical idle URL from scratch. Never copy lease identity
|
|
17
|
+
// or unrelated query parameters into an idle tab.
|
|
18
|
+
url.search = new URLSearchParams({workspace, role:'idle'}).toString();
|
|
16
19
|
return url.href;
|
|
17
20
|
}
|
|
18
21
|
|