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 +1 -1
- package/runtime/chatgpt/chatgpt-queue.mjs +9 -2
- package/runtime/durable-queue-core.mjs +10 -1
- package/runtime/extension/control-channel.mjs +1 -1
- package/runtime/extension/control-rpc.mjs +38 -2
- package/runtime/extension/popup.html +8 -2
- package/runtime/extension/popup.mjs +22 -11
- package/runtime/extension/service-worker.mjs +2 -1
- package/runtime/node-daemon.mjs +1 -0
package/package.json
CHANGED
|
@@ -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
|
|
67
|
-
db.prepare("UPDATE turns SET state='CANCELLED',lease_owner=NULL,lease_until=NULL WHERE id=? AND state
|
|
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() {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
52
|
-
<div id="queue-list" class="session-list" aria-label="
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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 = '
|
|
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"
|
|
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
|
}
|
package/runtime/node-daemon.mjs
CHANGED
|
@@ -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() {
|