webhelm 1.0.24 → 1.0.26

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
@@ -39,7 +39,7 @@ The daemon-managed Chrome Extension is installed from the npm Runtime and does n
39
39
 
40
40
  The compact popup shows daemon connectivity and per-workspace capacity and Session counts, with confirmation before stopping a Session or clearing Sessions in one workspace. The popup does not include a global stop-all action. 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.
41
41
 
42
- 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. Every newly created WebHelm-managed tab group (default and custom) is placed at the far right of its Chrome window. Existing groups retain their user-chosen position during restoration, legacy migration and Session allocation. 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.
42
+ 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. Every newly created WebHelm-managed tab group (default and custom) is placed at the far left of its Chrome window, immediately after pinned tabs. Existing groups retain their user-chosen position during restoration, legacy migration and Session allocation. 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.
43
43
 
44
44
  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.
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,35 @@
1
+ // Browser-side upload source for the existing WebHelm owned Session.
2
+ // Pure string generation; JSON encoding protects filenames and selector quotes.
3
+ const SELECTOR_JS = "(() => ['#upload-files','#upload-media','input[name=\"upload-media\"]','input[type=\"file\"]'].find(s=>document.querySelector(s))||null)()";
4
+ const OPEN_JS = "(() => {const b=[...document.querySelectorAll('button')].find(b=>(b.getAttribute('aria-label')||'').trim()==='Add files and more');if(b)b.click();return !!b;})()";
5
+ const STATE_JS = "(() => ({names:[...document.querySelectorAll('button[aria-label^=\"Remove \"]')].map(b=>(b.getAttribute('aria-label')||'').replace(/^Remove file\\s+\\d+:\\s*/,'').replace(/^Remove\\s+/,'')),pending:[...document.querySelectorAll('[role=\"progressbar\"],[aria-busy=\"true\"],[data-state=\"loading\"]')].some(e=>e.getClientRects().length>0)}))()";
6
+
7
+ export function attachmentUploadScript(paths) {
8
+ if (!Array.isArray(paths) || !paths.length) return "({ready:true})";
9
+ return [
10
+ "return await (async()=>{",
11
+ "const files="+JSON.stringify(paths)+";",
12
+ "const locate=()=>js("+JSON.stringify(SELECTOR_JS)+");",
13
+ "let input=await locate();",
14
+ "if(!input){",
15
+ " await js("+JSON.stringify(OPEN_JS)+");",
16
+ " for(let i=0;i<40&&!input;i++){await sleep(150);input=await locate();}",
17
+ "}",
18
+ "if(!input)throw Error('ChatGPT file input unavailable');",
19
+ "const names=[];",
20
+ "for(const file of files){",
21
+ " await upload_file(input,file);",
22
+ " const name=file.split('/').pop();names.push(name);",
23
+ " const expected=names.filter(n=>n===name).length;",
24
+ " let ready=false;",
25
+ " for(let i=0;i<480;i++){",
26
+ " const state=await js("+JSON.stringify(STATE_JS)+");",
27
+ " if(!state.pending&&state.names.filter(n=>n===name).length>=expected){ready=true;break;}",
28
+ " await sleep(250);",
29
+ " }",
30
+ " if(!ready)throw Error('ChatGPT attachment upload not confirmed: '+name);",
31
+ "}",
32
+ "return {ready:true,names};",
33
+ "})()",
34
+ ].join("\n");
35
+ }
@@ -2,22 +2,26 @@ export function targetUrl(projectId, threadId, message) {
2
2
  if (!threadId || !message) {
3
3
  throw new Error("thread_id and message are required");
4
4
  }
5
- const prefix = projectId ? `/g/${encodeURIComponent(projectId)}` : "";
6
- return `https://chatgpt.com${prefix}/c/${
7
- encodeURIComponent(threadId)
8
- }?${new URLSearchParams({ prompt: message })}`;
5
+ // A thread identity is global; ChatGPT may redirect /c/id to a
6
+ // Project-scoped /g/project-id[-slug]/c/id. Use the ordinary URL.
7
+ return "https://chatgpt.com/c/" + encodeURIComponent(threadId) +
8
+ "?" + new URLSearchParams({ prompt: message });
9
9
  }
10
10
  export const PROBE_JS =
11
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
12
  export function classifyProbe(observedUrl, expectedUrl, state) {
13
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";
14
+ const thread = url => {
15
+ const match = url.pathname.split('/c/')[1]?.split('/')[0] || null;
16
+ return match ? decodeURIComponent(match) : null;
17
+ };
18
+ if (actual.protocol !== "https:" || actual.host !== "chatgpt.com" ||
19
+ !thread(actual) || thread(actual) !== thread(expected)) return "BLOCKED";
18
20
  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
+ const prompt = expected.searchParams.get("prompt") || "";
22
+ const normalize = text => String(text || "").replace(/\s+/g, " ").trim();
23
+ if (actual.search && actual.searchParams.get("prompt") !== prompt) return "NOT_READY";
24
+ return state?.state === "READY" && normalize(state.composer_text) === normalize(prompt)
21
25
  ? "READY"
22
26
  : "NOT_READY";
23
27
  }
@@ -1,9 +1,11 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
- import { mkdirSync } from "node:fs";
3
- import { join, resolve } from "node:path";
2
+ import { mkdirSync, existsSync, statSync } from "node:fs";
3
+ import { join, resolve, isAbsolute } from "node:path";
4
+ import { attachmentUploadScript } from "./attachment-upload.mjs";
4
5
  import { createHash, randomUUID } from "node:crypto";
5
6
  import { classifyProbe, PROBE_JS, targetUrl } from "./chatgpt-probe.mjs";
6
- import { CLICK_SEND_JS, SEND_ENABLED_JS } from "./lifecycle-common.mjs";
7
+ import { SEND_ENABLED_JS } from "./lifecycle-common.mjs";
8
+ import { submissionScript } from "./submission-script.mjs";
7
9
  import { DurableQueueCore } from "../durable-queue-core.mjs";
8
10
 
9
11
  const transitions = new Set([
@@ -23,19 +25,26 @@ const chatgptQueuePolicy = {
23
25
  one(db, sql, ...args) { return db.prepare(sql).get(...args) || null; },
24
26
  rows(db) { return db.prepare("SELECT * FROM turns ORDER BY seq").all(); },
25
27
  enqueue(db, request = {}, now) {
26
- const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false} = request;
28
+ const {thread_id, message, idempotency_key, idempotencyKey, project_id=null, mergeable=false, file=[]} = request;
29
+ if (!Array.isArray(file) || file.length > 4 ||
30
+ file.some(p => typeof p !== "string" || !isAbsolute(p) ||
31
+ !existsSync(p) || !statSync(p).isFile())) {
32
+ throw new TypeError("file must contain at most 4 existing absolute file paths");
33
+ }
34
+ if (file.length && mergeable) throw new TypeError("attachment turns cannot merge");
35
+ const filesJson = JSON.stringify(file);
27
36
  const key=idempotency_key ?? idempotencyKey;
28
- if (!thread_id || !message || !key) throw new TypeError("thread_id, message, idempotency_key required");
37
+ if (!thread_id || (!message && !file.length) || !key) throw new TypeError("thread_id, message, idempotency_key required");
29
38
  if (typeof mergeable !== "boolean") throw new TypeError("mergeable must be a boolean");
30
39
  const merged=this.one(db, "SELECT * FROM merged_requests WHERE idem_key=?", key);
31
40
  if (merged) {
32
- if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id) throw new Error("idempotency key reused with different payload");
41
+ if (merged.thread_id!==thread_id || merged.message!==message || merged.project_id!==project_id || filesJson!=="[]") throw new Error("idempotency key reused with different payload");
33
42
  const row=this.one(db, "SELECT * FROM turns WHERE id=?", merged.queue_id);
34
43
  return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
35
44
  }
36
45
  const same=this.one(db, "SELECT * FROM turns WHERE idem_key=?", key);
37
46
  if (same) {
38
- if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id) throw new Error("idempotency key reused with different payload");
47
+ if (same.thread_id!==thread_id || same.message!==message || same.project_id!==project_id || (same.files_json||"[]")!==filesJson) throw new Error("idempotency key reused with different payload");
39
48
  return {ok:true,status:"queued",queue_id:same.id,thread_id:same.thread_id,state:same.state};
40
49
  }
41
50
  const tail=mergeable ? this.one(db, "SELECT * FROM turns WHERE thread_id=? ORDER BY seq DESC LIMIT 1", thread_id) : null;
@@ -49,7 +58,7 @@ const chatgptQueuePolicy = {
49
58
  return {ok:true,status:"queued",queue_id:row.id,thread_id:row.thread_id,state:row.state};
50
59
  }
51
60
  const id=randomUUID();
52
- db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable) VALUES(?,?,?,?,?,'QUEUED',?,?,?)").run(id,key,project_id,thread_id,message,now,now,mergeable?1:0);
61
+ db.prepare("INSERT INTO turns(id,idem_key,project_id,thread_id,message,state,next_check_at,created_at,mergeable,files_json) VALUES(?,?,?,?,?,'QUEUED',?,?,?,?)").run(id,key,project_id,thread_id,message,now,now,mergeable?1:0,filesJson);
53
62
  return {ok:true,status:"queued",queue_id:id,thread_id,state:"QUEUED"};
54
63
  },
55
64
  claim(db, owner, now, leaseSeconds) {
@@ -115,6 +124,9 @@ export class ChatGPTQueue extends DurableQueueCore {
115
124
  db.exec(
116
125
  `CREATE TABLE IF NOT EXISTS turns(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,idem_key TEXT NOT NULL UNIQUE,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,state TEXT NOT NULL,lease_owner TEXT,lease_until REAL,next_check_at REAL NOT NULL,created_at REAL NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,receipt TEXT,mergeable INTEGER NOT NULL DEFAULT 0); CREATE INDEX IF NOT EXISTS turns_thread_order ON turns(thread_id,seq); CREATE TABLE IF NOT EXISTS merged_requests(idem_key TEXT PRIMARY KEY,project_id TEXT,thread_id TEXT NOT NULL,message TEXT NOT NULL,queue_id TEXT NOT NULL);`,
117
126
  );
127
+ if (!db.prepare("PRAGMA table_info(turns)").all().some(row => row.name === "files_json")) {
128
+ db.exec("ALTER TABLE turns ADD COLUMN files_json TEXT NOT NULL DEFAULT '[]'");
129
+ }
118
130
  super(db, chatgptQueuePolicy, now, {
119
131
  concurrency: Math.min(4, Math.max(1, Number(concurrency) || 4)), intervalMs,
120
132
  });
@@ -211,7 +223,7 @@ export class ChatGPTQueue extends DurableQueueCore {
211
223
  let state = "NOT_READY";
212
224
  while (Date.now() < deadline) {
213
225
  const source =
214
- `await (async()=>{ const page=await page_info(); const state=await js(${
226
+ `return await (async()=>{ const page=await page_info(); const state=await js(${
215
227
  JSON.stringify(PROBE_JS)
216
228
  }); if(state?.state==='READY' && !(await js(${
217
229
  JSON.stringify(SEND_ENABLED_JS)
@@ -226,20 +238,12 @@ export class ChatGPTQueue extends DurableQueueCore {
226
238
  async _send(item) {
227
239
  const sid = this.sessions.get(item.id);
228
240
  if (!sid) throw new Error("missing prepared browser session");
229
- const expected =
230
- targetUrl(item.project_id, item.thread_id, item.message).split("?")[0];
231
- const source = `await (async()=>{ const gate=await js(${
232
- JSON.stringify(PROBE_JS)
233
- }); const gateUrl=(await page_info()).url||''; let receipt={diagnostic:'pre_send_gate_not_ready'}; if(gate?.state==='READY' && await js(${
234
- JSON.stringify(SEND_ENABLED_JS)
235
- }) && Boolean((gate.composer_text||'').trim()) && gateUrl===${
236
- JSON.stringify(expected)
237
- } && await js(${
238
- JSON.stringify(CLICK_SEND_JS)
239
- })){ const until=Date.now()+30000; let disabled=false; while(Date.now()<until){ disabled=!(await js(${
240
- JSON.stringify(SEND_ENABLED_JS)
241
- })); if(disabled) break; await new Promise(resolve=>setTimeout(resolve,200)); } receipt=disabled?{verified_by:'send-button-disabled'}:{diagnostic:'post_submit_send_state_unverified'}; } return receipt; })()`;
242
- return this._exec(sid, source);
241
+ const attachments = JSON.parse(item.files_json || "[]");
242
+ if (attachments.length) {
243
+ const upload = await this._exec(sid, attachmentUploadScript(attachments));
244
+ if (!upload?.ready) throw new Error("ChatGPT attachments not ready for Send");
245
+ }
246
+ return this._exec(sid, submissionScript(item));
243
247
  }
244
248
  async _finish(id) {
245
249
  const sid = this.sessions.get(id);
@@ -293,7 +297,7 @@ export class ChatGPTQueue extends DurableQueueCore {
293
297
  }
294
298
  this.transition(item.id, owner, "CHECKING", "SENDING");
295
299
  const receipt = await this._send(item);
296
- if (receipt?.verified_by !== "send-button-disabled") {
300
+ if (!["send-button-disabled", "user-turn"].includes(receipt?.verified_by)) {
297
301
  this.transition(item.id, owner, "SENDING", "UNCERTAIN", {
298
302
  receipt: receipt?.diagnostic || "missing_user_turn_receipt",
299
303
  });
@@ -0,0 +1,37 @@
1
+ import { PROBE_JS } from "./chatgpt-probe.mjs";
2
+ import { SEND_ENABLED_JS } from "./lifecycle-common.mjs";
3
+
4
+ /** Source executed inside a caller-owned WebHelm Session, never a separate tab. */
5
+ export function submissionScript(item) {
6
+ const prompt = item.message.slice(0, 120);
7
+ const selectorJS = "(() => {for(const s of ['button[aria-label=\"Send\"]','button[data-testid=\"send-button\"]','button[aria-label=\"Send prompt\"]']){const b=document.querySelector(s);if(b&&b.getClientRects().length&&!b.disabled)return s;}return null;})()";
8
+ const userTurnsJS = "(() => [...document.querySelectorAll('h4[data-conversation-role=\"user\"]')].filter(h=>(h.parentElement?.innerText||'').includes(" +
9
+ JSON.stringify(prompt) + ")).length)()";
10
+ return [
11
+ "return await (async()=>{",
12
+ "const gate=await js("+JSON.stringify(PROBE_JS)+");",
13
+ "const url=new URL((await page_info()).url);",
14
+ "const actualId=url.pathname.split('/c/')[1]?.split('/')[0]||'';",
15
+ "if(decodeURIComponent(actualId)!=="+JSON.stringify(item.thread_id)+
16
+ ")return {diagnostic:'thread_redirect_mismatch'};",
17
+ "if(gate?.state!=='READY'||!(await js("+JSON.stringify(SEND_ENABLED_JS)+")))",
18
+ " return {diagnostic:'pre_send_gate_not_ready'};",
19
+ "if((gate.composer_text||'').replace(/\\s+/g,' ').trim()!=="+
20
+ JSON.stringify(item.message.replace(/\s+/g,' ').trim())+")",
21
+
22
+ " return {diagnostic:'pre_send_gate_not_ready'};",
23
+ "const selector=await js("+JSON.stringify(selectorJS)+");",
24
+ "if(!selector)return {diagnostic:'send_control_not_found'};",
25
+ "const before=(await js("+JSON.stringify(userTurnsJS)+"))||0;",
26
+ "await click(selector);",
27
+ "for(let i=0;i<140;i++){",
28
+ " const count=(await js("+JSON.stringify(userTurnsJS)+"))||0;",
29
+ " if(count>before)return {verified_by:'user-turn'};",
30
+ " if(!(await js("+JSON.stringify(SEND_ENABLED_JS)+")))",
31
+ " return {verified_by:'send-button-disabled'};",
32
+ " await sleep(250);",
33
+ "}",
34
+ "return {diagnostic:'post_submit_user_turn_not_confirmed_no_resend'};",
35
+ "})()",
36
+ ].join("\n");
37
+ }
package/runtime/cli.mjs CHANGED
@@ -58,7 +58,8 @@ async function runPlatform(args) {
58
58
  if (sid && auto) throw Error('--session-id and --auto-session are mutually exclusive');
59
59
  if (platform === 'chatgpt' && action === 'new-turn' && mode === 'async') {
60
60
  if (sid || auto) throw Error('async new-turn cannot use browser session options');
61
- if (!config.thread_id || !config.message) throw Error('thread_id and message required');
61
+ if (!config.thread_id || (!config.message && !config.file?.length)) throw Error('thread_id and message or file required');
62
+ if (config.file !== undefined && (!Array.isArray(config.file) || config.file.length > 4 || config.file.some(f => typeof f !== 'string' || !path.isAbsolute(f) || !fs.existsSync(f)))) throw Error('file must contain at most 4 existing absolute paths');
62
63
  if (typeof config.mergeable !== 'undefined' && typeof config.mergeable !== 'boolean') throw Error('mergeable must be a boolean');
63
64
  const receipt = await request({op:'chatgpt-new-turn-enqueue', config: {...config, idempotency_key: config.idempotency_key || randomUUID(), mode}});
64
65
  if (!receipt.ok || !receipt.queue_id || receipt.thread_id !== String(config.thread_id)) throw Error('ChatGPT queue returned an invalid acceptance receipt');
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "WebHelm",
4
- "version": "0.4.9",
4
+ "version": "0.4.10",
5
5
  "description": "Own named Chrome tab-group workspaces and lazy reusable tab pools for browser agents.",
6
6
  "permissions": [
7
7
  "storage",
@@ -242,10 +242,14 @@ export class WorkspaceManager {
242
242
  return await this.chrome.tabs.query({ groupId });
243
243
  }
244
244
 
245
- // New groups belong at the right edge, regardless of Chrome's new-tab
246
- // insertion policy. Never reorder existing groups on restore or lease.
247
- async positionNewGroupRightmost(groupId) {
248
- await this.chrome.tabGroups.move(groupId, { index: -1 });
245
+ // New groups belong immediately after pinned tabs, regardless of Chrome's
246
+ // insertion policy. Existing groups keep their order on restore and lease.
247
+ async positionNewGroupLeftmost(groupId) {
248
+ const groupTabs = await this.groupTabs(groupId);
249
+ if (!groupTabs.length) return;
250
+ const windowTabs = await this.chrome.tabs.query({ windowId: groupTabs[0].windowId });
251
+ const firstUnpinned = windowTabs.filter(tab => tab.pinned).length;
252
+ await this.chrome.tabGroups.move(groupId, { index: firstUnpinned });
249
253
  }
250
254
 
251
255
  tabUrl(tab) {
@@ -497,7 +501,7 @@ export class WorkspaceManager {
497
501
  for (const tab of created) this.markOwned(tab.id);
498
502
  const groupId = await this.chrome.tabs.group({ tabIds: created.map((tab) => tab.id) });
499
503
  await this.chrome.tabGroups.update(groupId, { title: name, collapsed: true });
500
- await this.positionNewGroupRightmost(groupId);
504
+ await this.positionNewGroupLeftmost(groupId);
501
505
  for (const tab of created) await this.waitForIdleTab(tab.id, name);
502
506
  return await this.status(name);
503
507
  }