webhelm 1.0.19 → 1.0.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhelm",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "Agent browser automation in a visible managed Chrome with persistent JavaScript sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,11 +60,18 @@ const chatgptQueuePolicy = {
60
60
  db.prepare("UPDATE turns SET state='CHECKING',lease_owner=?,lease_until=?,attempts=attempts+1 WHERE id=?").run(owner,now+leaseSeconds,row.id);
61
61
  return this.one(db, "SELECT * FROM turns WHERE id=?", row.id);
62
62
  },
63
+ // A queue adapter registers a display description; Actions stay unaware of queues.
64
+ describe(row) {
65
+ const firstLine = row.message.split(/\r?\n/, 1)[0].replace(/\s+/g, ' ').trim();
66
+ const title = firstLine ? (firstLine.length > 52 ? `${firstLine.slice(0, 51)}…` : firstLine) : 'ChatGPT message';
67
+ const prefix = row.project_id ? `/g/${encodeURIComponent(row.project_id)}` : '';
68
+ return {title, url:`https://chatgpt.com${prefix}/c/${encodeURIComponent(row.thread_id)}`};
69
+ },
63
70
  cancel(db, id) {
64
71
  const row=this.one(db, "SELECT * FROM turns WHERE id=?", id);
65
72
  if (!row) return {ok:false,queue_id:id,error:"queue item not found"};
66
- if (row.state!=="QUEUED") return {ok:false,queue_id:id,state:row.state,error:"only queued items can be cancelled"};
67
- db.prepare("UPDATE turns SET state='CANCELLED',lease_owner=NULL,lease_until=NULL WHERE id=? AND state='QUEUED'").run(id);
73
+ if (!["QUEUED","WAITING","BLOCKED"].includes(row.state)) return {ok:false,queue_id:id,state:row.state,error:"cannot remove a running or uncertain item"};
74
+ db.prepare("UPDATE turns SET state='CANCELLED',lease_owner=NULL,lease_until=NULL WHERE id=? AND state IN ('QUEUED','WAITING','BLOCKED')").run(id);
68
75
  return {ok:true,queue_id:id,state:"CANCELLED"};
69
76
  },
70
77
  list(db) {
@@ -14,8 +14,6 @@ Commands:
14
14
  webhelm status Report browser and daemon status (JSON)
15
15
  webhelm ensure Check and recover managed Chrome health
16
16
  webhelm capacity Report available browser capacity (JSON)
17
- webhelm queue list List active ChatGPT new-turn queue items
18
- webhelm queue cancel QUEUE_ID Cancel an item that is still queued
19
17
  webhelm session start [--url URL] Start a persistent browser Session
20
18
  webhelm session list List CLI-owned Sessions
21
19
  webhelm session exec ID [--code-file F] Run JavaScript in a Session
@@ -89,15 +87,6 @@ Output: JSON capacity information.
89
87
 
90
88
  Example:
91
89
  webhelm capacity`,
92
- queue: `webhelm queue — inspect or cancel ChatGPT new-turn queue items
93
-
94
- Usage:
95
- webhelm queue list
96
- webhelm queue cancel QUEUE_ID
97
-
98
- Cancellation succeeds only while an item remains QUEUED. Items already being
99
- checked, sent, or awaiting reconciliation cannot be cancelled.
100
- Output: JSON queue state or cancellation receipt.`,
101
90
  migrate: `webhelm migrate — migrate supported legacy local browser data
102
91
 
103
92
  Usage:
package/runtime/cli.mjs CHANGED
@@ -149,15 +149,6 @@ async function main(args = process.argv.slice(2)) {
149
149
  // reload an older daemon's Extension while its Sessions are still active.
150
150
  return emit(await request(payload));
151
151
  }
152
- if (group === 'queue') {
153
- if (command === 'list') return emit(await request({op:'queue-list'}));
154
- if (command === 'cancel') {
155
- const queueId=args[2];
156
- if (!queueId) throw Error('queue cancel requires QUEUE_ID');
157
- return emit(await request({op:'queue-cancel',queue_id:queueId}));
158
- }
159
- throw Error('queue requires list or cancel QUEUE_ID');
160
- }
161
152
  if (group === 'session') {
162
153
  if (command === 'start') return emit(await request({op:'start', workspace:CLI_WORKSPACE, url:arg(args,'--url')}));
163
154
  if (command === 'list') {
@@ -42,7 +42,16 @@ export class DurableQueueCore {
42
42
  cancel(id) {
43
43
  return this._transaction(() => this.policy.cancel(this.db, id));
44
44
  }
45
- list() { return this.policy.list(this.db); }
45
+ list() {
46
+ const result = this.policy.list(this.db);
47
+ if (typeof this.policy.describe !== 'function') return result;
48
+ const rows = new Map(this.rows().map(row => [row.id, row]));
49
+ return {...result, items: result.items.map(item => {
50
+ const row = rows.get(item.id);
51
+ const description = row && this.policy.describe(row);
52
+ return description ? {...item, title: description.title, url: description.url} : item;
53
+ })};
54
+ }
46
55
  start() {
47
56
  if (this.started) return this;
48
57
  this.started = true;
@@ -85,7 +85,7 @@ export class ControlChannel {
85
85
  if (state.request) throw new Error('Another WebHelm operation is already running');
86
86
  if (!Number.isFinite(state.daemon?.seenAt) || this.now() - state.daemon.seenAt >= FRESH_MS)
87
87
  throw new Error('WebHelm daemon is not responding');
88
- if (!(state.queue || []).some(item => item.id === queueId && item.state === 'QUEUED'))
88
+ if (!(state.queue || []).some(item => item.id === queueId && ['QUEUED','WAITING','BLOCKED'].includes(item.state)))
89
89
  throw new Error('Queue item is no longer cancellable');
90
90
  const request = {id:this.makeId(), action:'queue-cancel', queueId, state:'pending', createdAt:this.now()};
91
91
  await this.save({...state, request, lastResult:null});
@@ -1,10 +1,46 @@
1
- export function createControlRpc({control, manager, summarizeWorkspaces, scheduleBadge}) {
1
+ function validTabId(tabId) { return Number.isSafeInteger(tabId) && tabId > 0; }
2
+ function validateQueueUrl(url) {
3
+ const parsed = new URL(url);
4
+ if (parsed.protocol !== 'https:' || !parsed.hostname) throw Error('Queue target must be an HTTPS URL');
5
+ return parsed.href;
6
+ }
7
+
8
+ export function createControlRpc({control, manager, summarizeWorkspaces, scheduleBadge, chromeApi=globalThis.chrome ?? control.chrome}) {
2
9
  return async function controlRpc(method, args = {}) {
3
10
  switch (method) {
4
11
  case "workspace.control.status": {
5
12
  const state = await control.status();
6
13
  const summary = summarizeWorkspaces((await manager.list()).workspaces, state.sessions);
7
- return {...state,...summary};
14
+ const workspaces = await Promise.all(summary.workspaces.map(async workspace => ({
15
+ ...workspace,
16
+ sessions: await Promise.all(workspace.sessions.map(async session => {
17
+ if (!validTabId(session.tabId)) return session;
18
+ try {
19
+ const tab = await chromeApi.tabs.get(session.tabId);
20
+ return {...session, tab_title: tab.title || 'Untitled tab'};
21
+ } catch { return {...session, tabId:null}; }
22
+ })),
23
+ })));
24
+ return {...state,...summary,workspaces};
25
+ }
26
+ case "workspace.control.activateTab": {
27
+ const state = await control.status();
28
+ if (!state.connected) throw Error('WebHelm daemon is not responding');
29
+ const session = state.sessions.find(s => s.session_id === args.sessionId);
30
+ if (!session || !validTabId(session.tabId)) throw Error('Session tab is no longer active');
31
+ const tab = await chromeApi.tabs.get(session.tabId);
32
+ await chromeApi.tabs.update(tab.id,{active:true});
33
+ await chromeApi.windows.update(tab.windowId,{focused:true});
34
+ return {ok:true};
35
+ }
36
+ case "workspace.control.openQueue": {
37
+ const state = await control.status();
38
+ if (!state.connected) throw Error('WebHelm daemon is not responding');
39
+ const item = state.queue.find(item => item.id === args.queueId);
40
+ if (!item || typeof item.url !== 'string') throw Error('Queue entry no longer available');
41
+ const url = validateQueueUrl(item.url);
42
+ await chromeApi.tabs.create({url, active:true});
43
+ return {ok:true};
8
44
  }
9
45
  case "workspace.control.requestStop":
10
46
  return control.requestStop(args.action, args.sessionId, args.workspace);
@@ -37,6 +37,12 @@
37
37
  .queue-info { min-width:0; flex:1; overflow-wrap:anywhere; }
38
38
  .queue-info span { display:block; color:light-dark(var(--muted),#acb8c9); font-size:11px; }
39
39
  .queue-row button { width:auto; padding:5px 9px; font-size:11px; }
40
+ button.tab-title, button.queue-title { flex:1; min-width:0; width:100%; padding:0; border:0; border-radius:2px; background:transparent; color:inherit; text-align:left; font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
41
+ button.tab-title:hover, button.queue-title:hover { text-decoration:underline; }
42
+ button.tab-title:disabled, button.queue-title:disabled { cursor:default; text-decoration:none; }
43
+ .session-info, .queue-info { overflow:hidden; }
44
+ .queue-info .queue-title { margin:0; }
45
+
40
46
  #message { margin-top:8px; font-size:11px; overflow-wrap:anywhere; }
41
47
  #message:empty { display:none; }
42
48
  [hidden] { display:none !important; }
@@ -48,8 +54,8 @@
48
54
  <span id="daemon-status" class="daemon-state" role="status">Connecting…</span>
49
55
  </header>
50
56
  <div id="workspace-list" class="session-list" aria-label="Workspaces" aria-live="polite">Connecting…</div>
51
- <strong>ChatGPT queue</strong>
52
- <div id="queue-list" class="session-list" aria-label="ChatGPT queue" aria-live="polite">No queued turns</div>
57
+ <strong>Queue</strong>
58
+ <div id="queue-list" class="session-list" aria-label="Queue" aria-live="polite">No queue entries</div>
53
59
  <section id="confirmation" hidden aria-label="Confirm clear sessions">
54
60
  <p id="confirm-text">Stop the selected WebHelm session(s)? Tasks in progress may be interrupted. Queued jobs remain and may create new sessions.</p>
55
61
  <div class="buttons">
@@ -23,9 +23,10 @@ function showConfirmation(action, params = {}) {
23
23
  }
24
24
  }
25
25
 
26
- function actionButton(label, disabled, onClick) {
26
+ function actionButton(label, disabled, onClick, className = '') {
27
27
  const button = document.createElement('button');
28
28
  button.textContent = label;
29
+ if (className) button.className = className;
29
30
  button.disabled = disabled;
30
31
  button.addEventListener('click', onClick);
31
32
  return button;
@@ -33,7 +34,7 @@ function actionButton(label, disabled, onClick) {
33
34
 
34
35
  function renderWorkspaces(workspaces, busy, connected) {
35
36
  const signature = JSON.stringify(workspaces.map(w => [
36
- w.name,w.initialized,w.available,w.poolSize,w.idleTabs,w.leasedTabs,w.sessions.map(s => s.session_id),
37
+ w.name,w.initialized,w.available,w.poolSize,w.idleTabs,w.leasedTabs,w.sessions.map(s => [s.session_id,s.tab_title,s.tabId]),
37
38
  ])) + ':' + busy + ':' + connected;
38
39
  if (signature === lastSignature) return;
39
40
  lastSignature = signature;
@@ -63,9 +64,12 @@ function renderWorkspaces(workspaces, busy, connected) {
63
64
  sessionRow.className = 'session-row';
64
65
  const details = document.createElement('div');
65
66
  details.className = 'session-info';
66
- const identifier = document.createElement('span');
67
- identifier.textContent = `Session ${session.session_id.slice(0, 12)}…`;
68
- details.append(identifier);
67
+ const title = actionButton(session.tab_title || 'Untitled tab', busy || !connected || !session.tabId, async () => {
68
+ try { await rpc('workspace.control.activateTab', {sessionId:session.session_id}); }
69
+ catch(error) { el('message').textContent=error.message; }
70
+ }, 'tab-title');
71
+ title.title = session.tab_title || 'Untitled tab';
72
+ details.append(title);
69
73
  sessionRow.append(details, actionButton('Stop', busy || !connected,
70
74
  () => showConfirmation('stop', {sessionId:session.session_id})));
71
75
  row.append(sessionRow);
@@ -77,18 +81,25 @@ function renderWorkspaces(workspaces, busy, connected) {
77
81
  function renderQueue(items, busy, connected) {
78
82
  const target=el('queue-list');
79
83
  target.replaceChildren();
80
- if (!items.length) { target.textContent='No active turns'; return; }
84
+ if (!items.length) { target.textContent='No queue entries'; return; }
81
85
  for (const item of items) {
82
86
  const row=document.createElement('div'); row.className='queue-row';
83
87
  const info=document.createElement('div'); info.className='queue-info';
84
- const title=document.createElement('strong'); title.textContent=`Thread ${item.thread_id}`;
88
+ const title=actionButton(item.title || 'ChatGPT message', !connected || !item.url, async()=>{
89
+ try { await rpc('workspace.control.openQueue',{queueId:item.id}); }
90
+ catch(error) { el('message').textContent=error.message; }
91
+ }, 'queue-title');
92
+ title.title=item.title || 'ChatGPT message';
85
93
  const detail=document.createElement('span'); detail.textContent=`${item.state.toLowerCase()} · ${item.attempts} attempt${item.attempts===1?'':'s'}`;
86
94
  info.append(title,detail); row.append(info);
87
- if (item.state==='QUEUED') row.append(actionButton('Cancel',busy || !connected,async()=>{
95
+ const safe=['QUEUED','WAITING','BLOCKED'].includes(item.state);
96
+ const remove=actionButton('Remove',!safe || busy || !connected,async()=>{
88
97
  try { const result=await rpc('workspace.control.queueCancel',{queueId:item.id}); if(!result.id) throw new Error('Missing queue request ID'); el('message').textContent='Waiting for WebHelm daemon…'; }
89
98
  catch(error) { el('message').textContent=error.message; }
90
99
  refresh();
91
- }));
100
+ });
101
+ if (!safe) remove.title='Cannot remove a running or unverified submission';
102
+ row.append(remove);
92
103
  target.append(row);
93
104
  }
94
105
  }
@@ -105,10 +116,10 @@ async function refresh() {
105
116
  renderQueue(state.queue || [], busy, state.connected);
106
117
  el('confirm').disabled = !state.connected || busy;
107
118
  if (busy) {
108
- el('message').textContent = 'Stopping sessions…';
119
+ el('message').textContent = 'Applying WebHelm action…';
109
120
  } else if (state.lastResult) {
110
121
  el('message').textContent = state.lastResult.ok
111
- ? `Last action: stopped ${state.lastResult.stoppedCount} session(s).`
122
+ ? (state.lastResult.queue_id ? 'Queue entry removed.' : `Last action: stopped ${state.lastResult.stoppedCount} session(s).`)
112
123
  : `Last action failed: ${state.lastResult.error}`;
113
124
  }
114
125
  } catch (error) {
@@ -92,7 +92,8 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
92
92
  }
93
93
  if (request?.method?.startsWith("workspace.control.") &&
94
94
  (sender?.url !== chrome.runtime.getURL("popup.html") ||
95
- !["workspace.control.requestStop", "workspace.control.queueCancel", "workspace.control.status"].includes(request.method))) {
95
+ !["workspace.control.requestStop", "workspace.control.queueCancel", "workspace.control.status",
96
+ "workspace.control.activateTab", "workspace.control.openQueue"].includes(request.method))) {
96
97
  sendResponse({ok:false,error:{message:"WebHelm control actions require the toolbar popup"}});
97
98
  return false;
98
99
  }
@@ -615,6 +615,7 @@ let controlPollActive = false;
615
615
  function visibleSessions() {
616
616
  return [...sessions.values()].map(s => ({
617
617
  session_id:s.session_id,workspace:s.workspace,last_used_at:s.last_used_at,
618
+ tabId:s.leasedTabs.get(s.targetId) ?? s.leasedTabs.get(s.rootTargetId) ?? null,
618
619
  }));
619
620
  }
620
621
  async function pollExtensionControl() {