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 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`), Chrome Extension, tests | npm `webhelm@latest`; Skill source separately |
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 is a small `npx --yes webhelm@latest` launcher; its contents may update without a Runtime release.
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 and the bundled Extension source. The Skill lives **in this repo** at `skills/webhelm/SKILL.md`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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') return emit(await migrateConfig());
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:'1.0.1', platform_dir:PLATFORMS, root:ROOT,
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');
@@ -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 PROFILE_DIR = path.join(CONFIG_ROOT, 'chrome');
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
+ });