webhelm 1.0.1 → 1.0.3
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 +5 -4
- package/package.json +1 -1
- package/runtime/chrome.mjs +5 -1
- package/runtime/cli.mjs +13 -3
- package/runtime/config-paths.mjs +9 -2
- package/runtime/extension/manifest.json +25 -0
- package/runtime/extension/service-worker.mjs +117 -0
- package/runtime/extension/workspace-manager.mjs +661 -0
- package/runtime/extension/workspace-page.mjs +7 -0
- package/runtime/extension/workspace.html +8 -0
- package/runtime/extension-manager.mjs +105 -0
- package/runtime/migrate.mjs +34 -1
- package/runtime/node-daemon.mjs +133 -46
- package/runtime/session-lifecycle.mjs +24 -0
package/README.md
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
| Repository | Contents | Updates |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| [`lalalic/webhelm`](https://github.com/lalalic/webhelm) | npm Runtime/CLI, Skill (`skills/webhelm`),
|
|
9
|
+
| [`lalalic/webhelm`](https://github.com/lalalic/webhelm) | npm Runtime/CLI (including the Daemon-managed Chrome Extension), Skill (`skills/webhelm`), tests | npm `webhelm@latest`; Skill source separately |
|
|
10
10
|
| [`lalalic/webhelm-actions`](https://github.com/lalalic/webhelm-actions) | Site manifests, ChatGPT/Z.ai/DeepSeek/XHS/etc. Actions | Git checkout at `~/.local/webhelm` |
|
|
11
11
|
|
|
12
|
-
The npm package **does not include** the Skill or site Actions. The Skill's executable
|
|
12
|
+
The npm package **does not include** the Skill or site Actions. The Skill's executable uses `npm exec --yes --package=webhelm@latest -- webhelm` to avoid inherited npm lifecycle variable conflicts; its contents may update without a Runtime release.
|
|
13
13
|
|
|
14
14
|
## Install and use
|
|
15
15
|
|
|
@@ -24,15 +24,16 @@ npx --yes webhelm@latest session exec SESSION_ID <<'JS'
|
|
|
24
24
|
console.log(await snapshot());
|
|
25
25
|
JS
|
|
26
26
|
npx --yes webhelm@latest session stop SESSION_ID
|
|
27
|
+
# Long-running worker managers: session heartbeat SESSION_ID every 60 seconds
|
|
27
28
|
```
|
|
28
29
|
|
|
29
|
-
Use `npx skills add lalalic/webhelm --skill webhelm` to install agent guidance
|
|
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.
|
|
30
31
|
|
|
31
32
|
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.
|
|
32
33
|
|
|
33
34
|
## Architecture and compatibility
|
|
34
35
|
|
|
35
|
-
The Runtime stores its managed Chrome profile, persistent FIFO queue, and IPC socket under `~/.config/webhelm/`. Run `webhelm migrate` with the legacy daemon and Chrome stopped to migrate existing `~/.config/browser-workspace/` data without dropping logins or queued turns. The command leaves a compatibility symlink for legacy maintenance scripts. Only one daemon is permitted to own the queue. A CLI package update does not kill a running daemon or close another agent's tabs; upgrade daemons when idle.
|
|
36
|
+
The Runtime stores its managed Chrome profile, persistent FIFO queue, and IPC socket under `~/.config/webhelm/`. Run `webhelm migrate` with the legacy daemon and Chrome stopped to migrate existing `~/.config/browser-workspace/` data without dropping logins or queued turns. The command leaves a compatibility symlink for legacy maintenance scripts. Session idle GC runs on Node before new Session allocation (5 minutes without operations or explicit heartbeat). Managed tabs are navigated back to their Extension idle URL instead of closing CDP targets; the Extension does not use `tab.lastAccessed` GC. Only one daemon is permitted to own the queue. A CLI package update does not kill a running daemon or close another agent's tabs; upgrade daemons when idle.
|
|
36
37
|
|
|
37
38
|
NeoY and Relay can continue using `webhelm` session commands, `result.stdout` JSON receipts, and `admin_helper` returned by `status`. An `admin_helper` inside the installed Skill is a stable connector to the same Node IPC socket. `webhelm capacity` replaces the legacy Python capacity probe.
|
|
38
39
|
|
package/package.json
CHANGED
package/runtime/chrome.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import net from 'node:net';
|
|
4
4
|
import { spawn, execFileSync } from 'node:child_process';
|
|
5
5
|
|
|
6
|
-
import { CONFIG_ROOT, PROFILE_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
6
|
+
import { CONFIG_ROOT, PROFILE_DIR, OLD_PROFILE_DIR, requireMigratedConfig } from './config-paths.mjs';
|
|
7
|
+
import { stageExtension } from './extension-manager.mjs';
|
|
7
8
|
const ROOT = CONFIG_ROOT;
|
|
8
9
|
const PROFILE = PROFILE_DIR;
|
|
9
10
|
const STATE = path.join(ROOT, 'managed-chrome.json');
|
|
@@ -103,9 +104,12 @@ export async function ensureManagedChrome(){
|
|
|
103
104
|
if(override)return {mode:'override',reused:true,endpoint:override,profile_dir:PROFILE};
|
|
104
105
|
const port=await choosePort();
|
|
105
106
|
const chrome=browserExecutable();
|
|
107
|
+
const extension=stageExtension();
|
|
106
108
|
const log=fs.openSync(path.join(ROOT,'managed-chrome.log'),'a');
|
|
107
109
|
fs.mkdirSync(PROFILE,{recursive:true});
|
|
108
110
|
const child=spawn(chrome,[`--user-data-dir=${PROFILE}`,`--remote-debugging-port=${port}`,
|
|
111
|
+
'--disable-features=DisableLoadExtensionCommandLineSwitch',
|
|
112
|
+
`--load-extension=${extension.path}`,
|
|
109
113
|
'--no-first-run','--no-default-browser-check','about:blank'],
|
|
110
114
|
{detached:true,stdio:['ignore',log,log]});
|
|
111
115
|
child.unref();
|
package/runtime/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
7
7
|
import { ensureDaemon, request, handle, SOCKET } from './node-daemon.mjs';
|
|
8
8
|
import { PLATFORMS, listPlatforms, platformProfile, actionPath, validateConfig } from './platforms.mjs';
|
|
9
9
|
import { ACTIONS_DIR, ensureActions, syncActions, publishActions } from './actions-repo.mjs';
|
|
10
|
-
import { migrateConfig } from './migrate.mjs';
|
|
10
|
+
import { migrateConfig, migrateChromeProfile } from './migrate.mjs';
|
|
11
11
|
|
|
12
12
|
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
const emit = value => console.log(JSON.stringify(value));
|
|
@@ -27,6 +27,7 @@ Usage:
|
|
|
27
27
|
webhelm capacity [--workspace NAME]
|
|
28
28
|
webhelm session start [--url URL]
|
|
29
29
|
webhelm session list
|
|
30
|
+
webhelm session heartbeat SESSION_ID # keep a working Session alive
|
|
30
31
|
webhelm session exec SESSION_ID [--code-file FILE] # JavaScript
|
|
31
32
|
webhelm session stop SESSION_ID|--all
|
|
32
33
|
webhelm platform list
|
|
@@ -98,7 +99,12 @@ async function runPlatform(args) {
|
|
|
98
99
|
async function main(args = process.argv.slice(2)) {
|
|
99
100
|
const [group, command] = args;
|
|
100
101
|
if (!group || ['--help', '-h', 'help'].includes(group) || has(args, '--help') || has(args, '-h')) return help();
|
|
101
|
-
if (group === 'migrate')
|
|
102
|
+
if (group === 'migrate') {
|
|
103
|
+
await ensureActions(); // ~/.local/webhelm is the Actions Git checkout, never create an empty replacement.
|
|
104
|
+
const legacy=await migrateConfig();
|
|
105
|
+
const chrome=await migrateChromeProfile();
|
|
106
|
+
return emit({legacy,chrome});
|
|
107
|
+
}
|
|
102
108
|
if (group === 'setup') {
|
|
103
109
|
const repo=await ensureActions();
|
|
104
110
|
const chrome=await ensureDaemon();
|
|
@@ -106,7 +112,7 @@ async function main(args = process.argv.slice(2)) {
|
|
|
106
112
|
}
|
|
107
113
|
if (group === 'status') {
|
|
108
114
|
const daemon = await ensureDaemon();
|
|
109
|
-
return emit({version:
|
|
115
|
+
return emit({version:daemon.version, platform_dir:PLATFORMS, root:ROOT,
|
|
110
116
|
cli:fileURLToPath(import.meta.url),
|
|
111
117
|
admin_helper:process.env.WEBHELM_ADMIN_HELPER || (fs.existsSync(path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs')) ? path.join(os.homedir(),'.agents/skills/webhelm/scripts/admin.mjs') : path.join(ROOT,'admin.mjs')),
|
|
112
118
|
managed_chrome:daemon.managed_chrome,
|
|
@@ -126,6 +132,10 @@ async function main(args = process.argv.slice(2)) {
|
|
|
126
132
|
if (group === 'session') {
|
|
127
133
|
if (command === 'start') return emit(await request({op:'start', workspace:arg(args,'--workspace'), url:arg(args,'--url')}));
|
|
128
134
|
if (command === 'list') return emit(await request({op:'list'}));
|
|
135
|
+
if (command === 'heartbeat') {
|
|
136
|
+
if (!args[2]) throw Error('session heartbeat requires SESSION_ID');
|
|
137
|
+
return emit(await request({op:'heartbeat',session_id:args[2]}));
|
|
138
|
+
}
|
|
129
139
|
if (command === 'stop') return emit(await request(has(args,'--all') ? {op:'stop-all'} : {op:'stop',session_id:args[2]}));
|
|
130
140
|
if (command === 'exec') {
|
|
131
141
|
if (!args[2]) throw Error('session exec requires SESSION_ID');
|
package/runtime/config-paths.mjs
CHANGED
|
@@ -4,11 +4,18 @@ import fs from 'node:fs';
|
|
|
4
4
|
|
|
5
5
|
export const CONFIG_ROOT = path.join(os.homedir(), '.config', 'webhelm');
|
|
6
6
|
export const LEGACY_ROOT = path.join(os.homedir(), '.config', 'browser-workspace');
|
|
7
|
-
export const
|
|
7
|
+
export const LOCAL_ROOT = path.join(os.homedir(), '.local', 'webhelm');
|
|
8
|
+
export const OLD_PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
|
|
9
|
+
export const PROFILE_DIR = path.join(LOCAL_ROOT, 'chrome');
|
|
8
10
|
export const RUNTIME_DIR = path.join(CONFIG_ROOT, 'runtime');
|
|
9
11
|
|
|
10
12
|
// Avoid starting a second FIFO dispatcher or empty Chrome profile against legacy data.
|
|
11
13
|
export function requireMigratedConfig() {
|
|
12
|
-
if (fs.existsSync(path.join(LEGACY_ROOT, 'chrome')) && !fs.existsSync(PROFILE_DIR))
|
|
14
|
+
if (fs.existsSync(path.join(LEGACY_ROOT, 'chrome')) && !fs.existsSync(OLD_PROFILE_DIR) && !fs.existsSync(PROFILE_DIR))
|
|
13
15
|
throw Error(`Legacy Chrome profile exists at ${LEGACY_ROOT}. Stop old Chrome and WebHelm daemon, then run webhelm migrate.`);
|
|
16
|
+
if (fs.existsSync(OLD_PROFILE_DIR) && !fs.existsSync(PROFILE_DIR))
|
|
17
|
+
throw Error(`Chrome profile is still at ${OLD_PROFILE_DIR}. Stop active Sessions, WebHelm daemon and managed Chrome, then run webhelm migrate.`);
|
|
18
|
+
if (fs.existsSync(OLD_PROFILE_DIR) && fs.existsSync(PROFILE_DIR) &&
|
|
19
|
+
fs.realpathSync(OLD_PROFILE_DIR)!==fs.realpathSync(PROFILE_DIR))
|
|
20
|
+
throw Error('Two distinct WebHelm Chrome Profiles exist; refusing to use either until reconciled.');
|
|
14
21
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "Browser Workspace",
|
|
4
|
+
"version": "0.3.3",
|
|
5
|
+
"description": "Own named Chrome tab-group workspaces and lazy reusable tab pools for browser agents.",
|
|
6
|
+
"permissions": [
|
|
7
|
+
"storage",
|
|
8
|
+
"tabs",
|
|
9
|
+
"tabGroups",
|
|
10
|
+
"webNavigation",
|
|
11
|
+
"contextMenus"
|
|
12
|
+
],
|
|
13
|
+
"host_permissions": [
|
|
14
|
+
"http://*/*",
|
|
15
|
+
"https://*/*"
|
|
16
|
+
],
|
|
17
|
+
"background": {
|
|
18
|
+
"service_worker": "service-worker.mjs",
|
|
19
|
+
"type": "module"
|
|
20
|
+
},
|
|
21
|
+
"action": {
|
|
22
|
+
"default_title": "Browser Workspace"
|
|
23
|
+
},
|
|
24
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtovrqHYRxYDBSofmlykO8RDtsePhg8KV1fmq07nnhtbmYiNXAnIKrgRyLHeZv+cwm9DSgP5Ig7FDJ7Q6XrSi+Z1FpU6Y/SJkX5Rozo0oAJcX1ggoGD3vxpfHZThSSG/vRSnD8pH3XFf2nDUD7QP6GXc8dX9lN5pslRUNh1J4UCAOcu2UpPSsvpA+wPPTZr2JYzm3AevFXkQM6y0B+4exgw/Hy21ULST4nLWZihuCSu+gfWnftiTNzUPHtseRJcrpd0zT0R46d+OC+JRwXMVBpYSksDDtOXgERwMBSZf+vvskrEqYoXKN8Z9+Rrr7y9PTOvlwQioVLEDEjr9e7RIegQIDAQAB"
|
|
25
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { WorkspaceManager } from "./workspace-manager.mjs";
|
|
2
|
+
|
|
3
|
+
const manager = new WorkspaceManager(chrome);
|
|
4
|
+
const closingWindowIds = new Set();
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
manager.restoreConfiguredWorkspaces().catch((error) => {
|
|
8
|
+
console.warn("Browser Workspace failed to restore configured workspaces", error);
|
|
9
|
+
});
|
|
10
|
+
|
|
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
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
27
|
+
ensureContextMenus().catch((error) => {
|
|
28
|
+
console.warn("Browser Workspace failed to create extension menu", error);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
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);
|
|
40
|
+
});
|
|
41
|
+
|
|
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
|
+
});
|
|
48
|
+
|
|
49
|
+
globalThis.browserWorkspaceManagerRpc = async (request) => {
|
|
50
|
+
try {
|
|
51
|
+
return { ok: true, result: await manager.rpc(request) };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: {
|
|
56
|
+
message: error?.message || String(error),
|
|
57
|
+
name: error?.name || "Error",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
|
64
|
+
globalThis.browserWorkspaceManagerRpc(request).then(sendResponse);
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
chrome.tabs.onCreated.addListener((tab) => {
|
|
69
|
+
manager.inheritWorkspaceForCreatedTab(tab).catch((error) => {
|
|
70
|
+
console.warn("Browser Workspace failed to inherit child tab group", error);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
manager.reconcileCreatedTab(tab.id).catch((error) => {
|
|
75
|
+
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);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}, 150);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
chrome.webNavigation.onCreatedNavigationTarget.addListener((details) => {
|
|
83
|
+
manager.inheritNavigationTarget(details).catch((error) => {
|
|
84
|
+
console.warn("Browser Workspace failed to inherit navigation target", error);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => {
|
|
89
|
+
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);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
chrome.tabs.onActivated.addListener((activeInfo) => {
|
|
96
|
+
manager.noteActivatedTab(activeInfo).catch((error) => {
|
|
97
|
+
console.warn("Browser Workspace failed to record active tab context", error);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
chrome.tabs.onRemoved.addListener((_tabId, removeInfo) => {
|
|
102
|
+
if (!removeInfo?.isWindowClosing) return;
|
|
103
|
+
closingWindowIds.add(removeInfo.windowId);
|
|
104
|
+
setTimeout(() => closingWindowIds.delete(removeInfo.windowId), 1000);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
chrome.tabGroups.onRemoved.addListener((group) => {
|
|
108
|
+
// Chrome also emits onRemoved while moving a group between windows, and
|
|
109
|
+
// while a whole window is closing. Defer one turn so a moved group can
|
|
110
|
+
// reappear, and preserve persisted workspaces across normal window closes.
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
if (closingWindowIds.has(group.windowId)) return;
|
|
113
|
+
manager.removeDeletedGroup(group).catch((error) => {
|
|
114
|
+
console.warn("Browser Workspace failed to remove deleted workspace", error);
|
|
115
|
+
});
|
|
116
|
+
}, 100);
|
|
117
|
+
});
|