webhelm 1.0.20 → 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.20",
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) {
@@ -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() {