webhelm 0.0.0-stage → 1.0.1

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/PRIVACY.md ADDED
@@ -0,0 +1,21 @@
1
+ # Browser Workspace Privacy Policy
2
+
3
+ Browser Workspace is a Chrome extension that manages named tab-group workspaces and reusable tab pools inside the user's browser.
4
+
5
+ ## Data handling
6
+
7
+ Browser Workspace does not send, sell, share, or transfer user data to the developer or to third parties.
8
+
9
+ The extension uses Chrome's local extension storage only to keep workspace configuration and local lease metadata needed to restore and manage workspaces. This information stays in the user's browser profile.
10
+
11
+ The extension may read tab identifiers, URLs, tab-group membership, navigation-target relationships, and related browser state only as needed to create, group, navigate, reuse, release, or reclaim workspace tabs. This information is processed locally and is not transmitted to an external service.
12
+
13
+ Browser Workspace does not collect personally identifiable information, authentication information, financial information, health information, personal communications, location data, website content, or user activity for external storage or analytics.
14
+
15
+ ## Remote code
16
+
17
+ Browser Workspace does not execute remote JavaScript or WebAssembly. All extension code is packaged with the extension.
18
+
19
+ ## Contact
20
+
21
+ Questions about this privacy policy may be submitted through the Browser Workspace GitHub repository.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ # WebHelm
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [WebHelm](https://github.com/lalalic/webhelm) is a Node.js CLI and agent Skill for a visible, managed Chrome instance. One Runtime daemon controls CDP, persistent JavaScript browser Sessions, Chrome Tab Groups, and durable ChatGPT FIFO delivery.
4
+
5
+ ## Two repositories, independent releases
6
+
7
+ | Repository | Contents | Updates |
8
+ |---|---|---|
9
+ | [`lalalic/webhelm`](https://github.com/lalalic/webhelm) | npm Runtime/CLI, Skill (`skills/webhelm`), Chrome Extension, tests | npm `webhelm@latest`; Skill source separately |
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
+
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.
13
+
14
+ ## Install and use
15
+
16
+ ```sh
17
+ npx --yes webhelm@latest setup
18
+ npx --yes webhelm@latest status
19
+ npx --yes webhelm@latest capacity
20
+ npx --yes webhelm@latest platform list
21
+ npx --yes webhelm@latest platform sync
22
+ npx --yes webhelm@latest session start --url https://example.com
23
+ npx --yes webhelm@latest session exec SESSION_ID <<'JS'
24
+ console.log(await snapshot());
25
+ JS
26
+ npx --yes webhelm@latest session stop SESSION_ID
27
+ ```
28
+
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
+
31
+ 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
+ ## Architecture and compatibility
34
+
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
+
37
+ 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
+ ## Development
40
+
41
+ ```sh
42
+ npm ci
43
+ npm test
44
+ npm pack --dry-run
45
+ ```
46
+
47
+ Keep platform-specific code and tests in `lalalic/webhelm-actions`. Runtime tests should use mock CDP or owned, temporary real Chrome tabs; protect other active Sessions. GitHub Actions runs Runtime/Extension CI. The Skill and Extension remain version-controlled together; npm publishing uses the standalone package at the repository root.
48
+
49
+ ### Adding an unsupported platform
50
+
51
+ The WebHelm Skill includes [`skills/webhelm/NEW_PLATFORM.md`](skills/webhelm/NEW_PLATFORM.md). All new platform code belongs in **`~/.local/webhelm/platforms/<slug>`**, checked out from `lalalic/webhelm-actions`, including tests and docs. There is no separate `platforms/` directory in the Skill or npm package. Publishing an Action uses Git, not npm.
52
+
53
+ ## npm publishing
54
+
55
+ `.github/workflows/publish.yml` publishes CLI/Runtime changes on `main` using GitHub Actions and npm OIDC trusted publishing. It reuses Agents Relay's bounded-test and publication-report Actions, as the other repositories do. The CI workflow never stores an npm write token or asks for a local 2FA approval. Configure the **webhelm** npm package's Trusted Publisher once: GitHub user `lalalic`, repository `webhelm`, workflow filename `publish.yml` (no environment), with **Allow npm publish** enabled. New configurations otherwise default to staged-only publishing and require individual approvals. A successful publish is verified against the npm registry and reported to Agents Relay. Skill-only and platform Action changes do not trigger npm publication.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../runtime/cli.mjs';
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
+ "type": "module",
6
+ "bin": {
7
+ "webhelm": "bin/webhelm.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.5.0"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "runtime/",
15
+ "README.md",
16
+ "PRIVACY.md"
17
+ ],
18
+ "scripts": {
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"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/lalalic/webhelm.git"
26
+ },
27
+ "homepage": "https://github.com/lalalic/webhelm",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "yaml": "^2.9.1"
33
+ }
34
+ }
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ const run = promisify(execFile);
7
+
8
+ export const ACTIONS_DIR = path.resolve(process.env.WEBHELM_ACTIONS_HOME || path.join(os.homedir(), '.local', 'webhelm'));
9
+ const REMOTE = process.env.WEBHELM_ACTIONS_REPO || 'https://github.com/lalalic/webhelm-actions.git';
10
+ const options={timeout:90000,maxBuffer:1048576};
11
+ let initialSetup;
12
+ const git=(...args)=>run('git',args,options).then(r=>r.stdout.trim());
13
+ function verifyCheckout(){
14
+ if (!fs.existsSync(path.join(ACTIONS_DIR,'.git')) || !fs.existsSync(path.join(ACTIONS_DIR,'platforms')))
15
+ throw new Error(`Existing ${ACTIONS_DIR} is not a complete WebHelm Actions checkout; do not overwrite it`);
16
+ }
17
+ export async function ensureActions(){
18
+ if(initialSetup)return initialSetup;
19
+ initialSetup=(async()=>{
20
+ if(fs.existsSync(ACTIONS_DIR))verifyCheckout();
21
+ else {
22
+ fs.mkdirSync(path.dirname(ACTIONS_DIR),{recursive:true});
23
+ const staging=fs.mkdtempSync(path.join(path.dirname(ACTIONS_DIR),'.webhelm-actions-'));
24
+ try {
25
+ await git('clone','--depth','1','--branch','main','--',REMOTE,staging);
26
+ if(!fs.existsSync(path.join(staging,'platforms')))throw Error('Action repository has no platforms/');
27
+ fs.renameSync(staging,ACTIONS_DIR);
28
+ }finally{if(fs.existsSync(staging))fs.rmSync(staging,{recursive:true,force:true});}
29
+ }
30
+ return {actions_home:ACTIONS_DIR,platform_dir:path.join(ACTIONS_DIR,'platforms'),revision:await git('-C',ACTIONS_DIR,'rev-parse','--short','HEAD')};
31
+ })();
32
+ try{return await initialSetup;}catch(e){initialSetup=undefined;throw e;}
33
+ }
34
+ export async function syncActions(){
35
+ await ensureActions();
36
+ const changes=await git('-C',ACTIONS_DIR,'status','--porcelain');
37
+ if(changes)throw Error('Actions checkout has local changes; refusing to overwrite. Review/publish or commit before sync.');
38
+ await git('-C',ACTIONS_DIR,'fetch','--prune','origin','main');
39
+ // Updating is permitted only if current commit is an ancestor of origin/main.
40
+ await git('-C',ACTIONS_DIR,'merge-base','--is-ancestor','HEAD','origin/main');
41
+ await git('-C',ACTIONS_DIR,'merge','--ff-only','origin/main');
42
+ return {status:'synced',...(await ensureActions()),revision:await git('-C',ACTIONS_DIR,'rev-parse','--short','HEAD')};
43
+ }
44
+ export async function publishActions(message){
45
+ await ensureActions();
46
+ if(!message || message.trim().length<8 || message.length>250)throw Error('--message must describe the verified action change (8–250 characters)');
47
+ const staged=await git('-C',ACTIONS_DIR,'diff','--cached','--name-only');
48
+ if(staged.split('\n').filter(Boolean).some(s=>!s.startsWith('platforms/')))
49
+ throw Error('Refusing to publish unrelated staged files');
50
+ await git('-C',ACTIONS_DIR,'diff','--check');
51
+ const changed=await git('-C',ACTIONS_DIR,'status','--porcelain','--','platforms');
52
+ if(!changed)throw Error('No platform action changes to publish');
53
+ // Run repository contract tests before creating a commit. These must not perform live writes.
54
+ await run('npm',['test'],{cwd:ACTIONS_DIR,...options});
55
+ await git('-C',ACTIONS_DIR,'add','--','platforms');
56
+ await git('-C',ACTIONS_DIR,'diff','--cached','--check');
57
+ await git('-C',ACTIONS_DIR,'commit','-m',message.trim());
58
+ const revision=await git('-C',ACTIONS_DIR,'rev-parse','HEAD');
59
+ // Never force-push or overwrite collaborator commits.
60
+ await git('-C',ACTIONS_DIR,'push','origin','HEAD:refs/heads/main');
61
+ return {status:'published',revision,repository:REMOTE};
62
+ }
@@ -0,0 +1,7 @@
1
+ import net from 'node:net';
2
+ import { RUNTIME_DIR } from './config-paths.mjs';
3
+ import path from 'node:path';
4
+ const socketPath=()=>process.env.WEBHELM_SOCKET||path.join(process.env.WEBHELM_RUNTIME_DIR||RUNTIME_DIR,'session-node.sock');
5
+ function request(payload){return new Promise((resolve,reject)=>{const s=net.createConnection(socketPath());let buf='';s.setEncoding('utf8');s.on('connect',()=>s.write(JSON.stringify(payload)+'\n'));s.on('data',d=>{buf+=d;if(!buf.includes('\n'))return;s.end();try{const v=JSON.parse(buf);v.error?reject(Error(v.error)):resolve(v)}catch(e){reject(e)}});s.on('error',reject)})}
6
+ export async function ensureWorkspace(name,size=5){if(typeof name!=='string'||!name.trim())throw TypeError('workspace name must be a non-empty string');if(!Number.isInteger(size)||size<1)throw TypeError('workspace size must be a positive integer');return request({op:'workspace-create',name:name.trim(),pool_size:size})}
7
+ export async function deleteWorkspace(name,{force=false}={}){if(typeof name!=='string'||!name.trim())throw TypeError('workspace name must be a non-empty string');return request({op:'workspace-delete',name:name.trim(),force:Boolean(force)})}
@@ -0,0 +1,94 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ const patterns = [
6
+ [
7
+ "conversation_too_long",
8
+ /conversation_too_long|conversation is too long to continue|conversation too long/i,
9
+ ],
10
+ [
11
+ "thread_load_failed",
12
+ /thread_load_failed|could not load this chatgpt conversation|failed to load conversation/i,
13
+ ],
14
+ [
15
+ "authentication_required",
16
+ /login required|log in to chatgpt|session expired|sign in to chatgpt/i,
17
+ ],
18
+ ["rate_limited", /rate.limit|too many requests|http 429|usage limit/i],
19
+ [
20
+ "invalid_thread_id",
21
+ /invalid thread.id|thread.id is required|malformed thread/i,
22
+ ],
23
+ ["thread_redirect_mismatch", /thread redirect mismatch/i],
24
+ [
25
+ "conversation_busy",
26
+ /conversation is busy|turn completion.*not observed ready/i,
27
+ ],
28
+ [
29
+ "send_not_ready",
30
+ /lifecycle continuation readiness was not observed ready|send button.*disabled/i,
31
+ ],
32
+ ["composer_not_found", /composer.*not observed ready|composer.*not found/i],
33
+ ["composer_input_failed", /composer rejected|input failed|prompt.*mismatch/i],
34
+ ["send_click_failed", /send button changed before|send button.*click/i],
35
+ [
36
+ "submission_unverified",
37
+ /submission.*not observed|post.submit.*unverified|cleared composer/i,
38
+ ],
39
+ ["thread_creation_failed", /did not assign a durable thread id/i],
40
+ ["project_not_found", /project not found/i],
41
+ ["project_access_denied", /project.*access denied|permission.*project/i],
42
+ ["attachment_failed", /attachment.*failed|upload.*failed/i],
43
+ [
44
+ "attachment_processing_timeout",
45
+ /attachment.*timeout|attachment.*not observed ready/i,
46
+ ],
47
+ ["session_unavailable", /unknown session|session not found|session.*closed/i],
48
+ [
49
+ "browser_unavailable",
50
+ /chrome.*unavailable|cdp.*disconnect|daemon.*unreachable/i,
51
+ ],
52
+ ["execution_timeout", /timed out|timeout|runtime.evaluate/i],
53
+ ];
54
+ export function classify(error) {
55
+ const raw = String(error || "");
56
+ const code = patterns.find(([, re]) => re.test(raw))?.[0] ||
57
+ "delivery_failed";
58
+ const messages = {
59
+ send_not_ready: "Prompt entered, but Send button did not become ready",
60
+ thread_load_failed: "ChatGPT conversation could not be loaded",
61
+ conversation_too_long: "Conversation is too long to continue",
62
+ submission_unverified: "Submission could not be confirmed",
63
+ };
64
+ return {
65
+ ok: false,
66
+ code,
67
+ message: messages[code] ||
68
+ code.replaceAll("_", " ").replace(/^./, (c) => c.toUpperCase()),
69
+ };
70
+ }
71
+ export async function normalizeFailure(
72
+ result,
73
+ action,
74
+ {
75
+ logPath = join(
76
+ homedir(),
77
+ ".config/webhelm/runtime/chatgpt-delivery-errors.jsonl",
78
+ ),
79
+ } = {},
80
+ ) {
81
+ if (result?.ok !== false) return result;
82
+ const raw = String(result.stderr || result.error || "");
83
+ const diagnostic_id = randomUUID().replaceAll("-", "");
84
+ try {
85
+ await mkdir(dirname(logPath), { recursive: true });
86
+ await appendFile(
87
+ logPath,
88
+ JSON.stringify({ diagnostic_id, action, error: raw }) + "\n",
89
+ );
90
+ } catch {}
91
+ const normalized = classify(raw);
92
+ if ("cleanup" in result) normalized.cleanup = result.cleanup;
93
+ return normalized;
94
+ }
@@ -0,0 +1,23 @@
1
+ export function targetUrl(projectId, threadId, message) {
2
+ if (!threadId || !message) {
3
+ throw new Error("thread_id and message are required");
4
+ }
5
+ const prefix = projectId ? `/g/${encodeURIComponent(projectId)}` : "";
6
+ return `https://chatgpt.com${prefix}/c/${
7
+ encodeURIComponent(threadId)
8
+ }?${new URLSearchParams({ prompt: message })}`;
9
+ }
10
+ export const PROBE_JS =
11
+ `(() => { const visible=e=>!!e&&!!e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden'; const body=(document.body?.innerText||'').toLowerCase(); if(body.includes('could not load this chatgpt conversation'))return {state:'BLOCKED',error:'thread_load_failed'}; if(body.includes('log in to chatgpt')||body.includes('sign in to chatgpt'))return {state:'BLOCKED',error:'authentication_required'}; const stop=[...document.querySelectorAll('button')].some(b=>{if(!visible(b))return false;const l=(b.getAttribute('aria-label')||'').trim().toLowerCase(),t=(b.getAttribute('title')||'').trim().toLowerCase(),d=(b.getAttribute('data-testid')||'').trim().toLowerCase();return ['stop generating','stop answering','stop response','stop'].includes(l)||['stop generating','stop answering'].includes(t)||['stop-button','stop-generating-button'].includes(d)}); if(stop)return {state:'RUNNING',stop_visible:true}; const e=document.querySelector('#prompt-textarea,[contenteditable="true"][data-lexical-editor="true"],[role="textbox"][contenteditable="true"],textarea'); const editable=e?.getAttribute?.('contenteditable')==='true'; return {state:visible(e)?'READY':'NOT_READY',stop_visible:false,composer_text:e?(editable?(e.textContent||''):(e.value||e.textContent||e.innerText||'')):''}; })()`;
12
+ export function classifyProbe(observedUrl, expectedUrl, state) {
13
+ const actual = new URL(observedUrl), expected = new URL(expectedUrl);
14
+ if (
15
+ actual.protocol !== "https:" || actual.host !== "chatgpt.com" ||
16
+ actual.pathname.replace(/\/$/, "") !== expected.pathname.replace(/\/$/, "")
17
+ ) return "BLOCKED";
18
+ if (["RUNNING", "BLOCKED"].includes(state?.state)) return state.state;
19
+ if (actual.search) return "NOT_READY";
20
+ return state?.state === "READY" && String(state.composer_text || "").trim()
21
+ ? "READY"
22
+ : "NOT_READY";
23
+ }