@lelouchhe/webagent 0.1.3 → 0.1.5
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 +49 -10
- package/bin/webagent.mjs +31 -18
- package/config.toml +3 -0
- package/dist/index.html +3 -3
- package/dist/js/app.mmlj0xfy.js +34 -0
- package/dist/js/{commands.mmjvjb37.js → commands.mmlj0xfy.js} +198 -5
- package/dist/js/{connection.mmjvjb37.js → connection.mmlj0xfy.js} +15 -4
- package/dist/js/{events.mmjvjb37.js → events.mmlj0xfy.js} +69 -7
- package/dist/js/{images.mmjvjb37.js → images.mmlj0xfy.js} +1 -1
- package/dist/js/{input.mmjvjb37.js → input.mmlj0xfy.js} +23 -4
- package/dist/js/{render.mmjvjb37.js → render.mmlj0xfy.js} +1 -1
- package/dist/js/{state.mmjvjb37.js → state.mmlj0xfy.js} +2 -0
- package/dist/sw.js +47 -1
- package/lib/config.js +5 -0
- package/lib/daemon.js +278 -0
- package/lib/event-handler.js +95 -0
- package/lib/push-service.js +112 -0
- package/lib/routes.js +66 -1
- package/lib/server.js +7 -81
- package/lib/store.js +19 -0
- package/lib/types.js +1 -0
- package/lib/ws-handler.js +19 -1
- package/package.json +3 -1
- package/dist/js/app.mmjvjb37.js +0 -10
- /package/dist/{styles.mmjvjb37.css → styles.mmlj0xfy.css} +0 -0
|
@@ -4,12 +4,35 @@ import {
|
|
|
4
4
|
state, dom, setBusy, setConfigValue, getConfigOption, updateConfigOptions,
|
|
5
5
|
updateModeUI, resetSessionUI, requestNewSession, setHashSessionId, updateSessionInfo,
|
|
6
6
|
setConnectionStatus, clearCancelTimer,
|
|
7
|
-
} from './state.
|
|
7
|
+
} from './state.mmlj0xfy.js';
|
|
8
8
|
import {
|
|
9
9
|
addMessage, addSystem, finishAssistant, finishThinking, hideWaiting,
|
|
10
10
|
scrollToBottom, renderMd, escHtml, renderPatchDiff, addBashBlock, finishBash, appendMessageElement,
|
|
11
11
|
formatLocalTime,
|
|
12
|
-
} from './render.
|
|
12
|
+
} from './render.mmlj0xfy.js';
|
|
13
|
+
|
|
14
|
+
const NOTIFY_TIP_KEY = 'webagent_notify_tip_shown';
|
|
15
|
+
const NOTIFY_TIP_DENIED_KEY = 'webagent_notify_tip_denied_shown';
|
|
16
|
+
|
|
17
|
+
function showNotifyTip() {
|
|
18
|
+
if (typeof Notification === 'undefined') return;
|
|
19
|
+
if (state.replayInProgress) return;
|
|
20
|
+
|
|
21
|
+
const perm = Notification.permission;
|
|
22
|
+
if (perm === 'granted') return; // already enabled
|
|
23
|
+
|
|
24
|
+
if (perm === 'denied') {
|
|
25
|
+
if (localStorage.getItem(NOTIFY_TIP_DENIED_KEY)) return;
|
|
26
|
+
localStorage.setItem(NOTIFY_TIP_DENIED_KEY, '1');
|
|
27
|
+
addSystem('tip: notifications are blocked — allow in browser site settings to enable');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// permission === 'default'
|
|
32
|
+
if (localStorage.getItem(NOTIFY_TIP_KEY)) return;
|
|
33
|
+
localStorage.setItem(NOTIFY_TIP_KEY, '1');
|
|
34
|
+
addSystem('tip: use /notify to enable background notifications');
|
|
35
|
+
}
|
|
13
36
|
|
|
14
37
|
function finishPromptIfIdle() {
|
|
15
38
|
if (!state.pendingPromptDone) return;
|
|
@@ -18,8 +41,8 @@ function finishPromptIfIdle() {
|
|
|
18
41
|
finishThinking();
|
|
19
42
|
finishAssistant();
|
|
20
43
|
setBusy(false);
|
|
21
|
-
dom.input.focus();
|
|
22
44
|
state.pendingPromptDone = false;
|
|
45
|
+
showNotifyTip();
|
|
23
46
|
}
|
|
24
47
|
|
|
25
48
|
function cancelPendingTurnUI() {
|
|
@@ -32,7 +55,7 @@ function cancelPendingTurnUI() {
|
|
|
32
55
|
}
|
|
33
56
|
for (const requestId of state.pendingPermissionRequestIds) {
|
|
34
57
|
const permEl = document.querySelector(`.permission[data-request-id="${requestId}"]`);
|
|
35
|
-
if (!permEl) continue;
|
|
58
|
+
if (!permEl || !permEl.querySelector('button')) continue;
|
|
36
59
|
const titleEl = permEl.querySelector('.title');
|
|
37
60
|
const title = titleEl?.textContent || '⚿';
|
|
38
61
|
permEl.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — cancelled</span>`;
|
|
@@ -118,6 +141,36 @@ export async function loadNewEvents(sid) {
|
|
|
118
141
|
}
|
|
119
142
|
}
|
|
120
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Resend permission responses that were sent optimistically but never confirmed
|
|
146
|
+
* by the server (e.g. WS dropped before delivery). Call after loadNewEvents/loadHistory
|
|
147
|
+
* on reconnect.
|
|
148
|
+
*/
|
|
149
|
+
export function retryUnconfirmedPermissions() {
|
|
150
|
+
for (const [requestId, response] of state.unconfirmedPermissions) {
|
|
151
|
+
const el = document.querySelector(`.permission[data-request-id="${requestId}"]`);
|
|
152
|
+
if (!el || !el.querySelector('button')) {
|
|
153
|
+
// Element gone or already resolved — clean up
|
|
154
|
+
state.unconfirmedPermissions.delete(requestId);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// Still pending in DOM — resend and optimistically resolve
|
|
158
|
+
if (state.ws && state.ws.readyState === 1) {
|
|
159
|
+
state.ws.send(JSON.stringify({
|
|
160
|
+
type: 'permission_response',
|
|
161
|
+
sessionId: response.sessionId,
|
|
162
|
+
requestId,
|
|
163
|
+
optionId: response.optionId,
|
|
164
|
+
optionName: response.optionName,
|
|
165
|
+
denied: response.denied,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
const title = el.dataset.title ? `⚿ ${escHtml(el.dataset.title)}` : '⚿';
|
|
169
|
+
el.innerHTML = `<span style="opacity:0.5">${title} — ${escHtml(response.optionName)}</span>`;
|
|
170
|
+
state.unconfirmedPermissions.delete(requestId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
121
174
|
export function replayEvent(type, data, events, idx) {
|
|
122
175
|
switch (type) {
|
|
123
176
|
case 'user_message': {
|
|
@@ -236,7 +289,7 @@ export function replayEvent(type, data, events, idx) {
|
|
|
236
289
|
const el = document.querySelector(`.permission[data-request-id="${data.requestId}"]`);
|
|
237
290
|
if (el) {
|
|
238
291
|
const title = el.dataset.title ? `⚿ ${el.dataset.title}` : '⚿';
|
|
239
|
-
const action = data.denied ? 'denied' :
|
|
292
|
+
const action = data.optionName || (data.denied ? 'denied' : 'allowed');
|
|
240
293
|
el.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — ${escHtml(action)}</span>`;
|
|
241
294
|
}
|
|
242
295
|
break;
|
|
@@ -468,6 +521,8 @@ export function handleEvent(msg) {
|
|
|
468
521
|
|
|
469
522
|
case 'permission_request': {
|
|
470
523
|
if (state.turnEnded) break;
|
|
524
|
+
// Dedup: skip if a permission element with this requestId already exists (e.g. bridge restore)
|
|
525
|
+
if (document.querySelector(`.permission[data-request-id="${msg.requestId}"]`)) break;
|
|
471
526
|
state.pendingPermissionRequestIds.add(msg.requestId);
|
|
472
527
|
setBusy(true);
|
|
473
528
|
finishThinking();
|
|
@@ -494,6 +549,13 @@ export function handleEvent(msg) {
|
|
|
494
549
|
}));
|
|
495
550
|
} catch { /* connection may be broken; cleanup still runs */ }
|
|
496
551
|
state.pendingPermissionRequestIds.delete(msg.requestId);
|
|
552
|
+
// Track for retry on reconnect (cleared when server confirms via permission_resolved)
|
|
553
|
+
state.unconfirmedPermissions.set(msg.requestId, {
|
|
554
|
+
sessionId: state.sessionId,
|
|
555
|
+
optionId: opt.optionId,
|
|
556
|
+
optionName: opt.name,
|
|
557
|
+
denied: isDeny,
|
|
558
|
+
});
|
|
497
559
|
permEl.innerHTML = `<span style="opacity:0.5">⚿ ${escHtml(msg.title)} — ${escHtml(opt.name)}</span>`;
|
|
498
560
|
finishPromptIfIdle();
|
|
499
561
|
};
|
|
@@ -505,10 +567,11 @@ export function handleEvent(msg) {
|
|
|
505
567
|
|
|
506
568
|
case 'permission_resolved': {
|
|
507
569
|
state.pendingPermissionRequestIds.delete(msg.requestId);
|
|
570
|
+
state.unconfirmedPermissions.delete(msg.requestId);
|
|
508
571
|
const permTarget = document.querySelector(`.permission[data-request-id="${msg.requestId}"]`);
|
|
509
572
|
if (msg.sessionId === state.sessionId && permTarget) {
|
|
510
573
|
const title = permTarget.dataset.title ? `⚿ ${permTarget.dataset.title}` : '⚿';
|
|
511
|
-
const action = msg.denied ? 'denied' :
|
|
574
|
+
const action = msg.optionName || (msg.denied ? 'denied' : 'allowed');
|
|
512
575
|
permTarget.innerHTML = `<span style="opacity:0.5">${escHtml(title)} — ${escHtml(action)}</span>`;
|
|
513
576
|
}
|
|
514
577
|
finishPromptIfIdle();
|
|
@@ -547,7 +610,6 @@ export function handleEvent(msg) {
|
|
|
547
610
|
finishBash(state.currentBashEl, msg.code, msg.signal);
|
|
548
611
|
if (msg.error) addSystem(`err: ${msg.error}`);
|
|
549
612
|
setBusy(false);
|
|
550
|
-
dom.input.focus();
|
|
551
613
|
break;
|
|
552
614
|
}
|
|
553
615
|
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
import {
|
|
4
4
|
state, dom, setBusy, sendCancel,
|
|
5
5
|
getConfigOption, getConfigValue, updateNewBtnVisibility,
|
|
6
|
-
} from './state.
|
|
7
|
-
import { addMessage, addSystem, addBashBlock, showWaiting } from './render.
|
|
8
|
-
import { handleSlashCommand, hideSlashMenu, handleSlashMenuKey, updateSlashMenu } from './commands.
|
|
9
|
-
import { renderAttachPreview } from './images.
|
|
6
|
+
} from './state.mmlj0xfy.js';
|
|
7
|
+
import { addMessage, addSystem, addBashBlock, showWaiting } from './render.mmlj0xfy.js';
|
|
8
|
+
import { handleSlashCommand, hideSlashMenu, handleSlashMenuKey, updateSlashMenu } from './commands.mmlj0xfy.js';
|
|
9
|
+
import { renderAttachPreview } from './images.mmlj0xfy.js';
|
|
10
|
+
|
|
11
|
+
function wsReady() {
|
|
12
|
+
return state.ws && state.ws.readyState === 1;
|
|
13
|
+
}
|
|
10
14
|
|
|
11
15
|
// Wire up cancel-timeout feedback (state.js cannot import render.js directly)
|
|
12
16
|
state._onCancelTimeout = () => addSystem('warn: Agent not responding to cancel');
|
|
@@ -32,6 +36,10 @@ function sendMessage() {
|
|
|
32
36
|
addSystem('warn: Session not ready yet, please wait…');
|
|
33
37
|
return;
|
|
34
38
|
}
|
|
39
|
+
if (!wsReady()) {
|
|
40
|
+
addSystem('warn: Not connected, please retry');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
35
43
|
dom.input.value = '';
|
|
36
44
|
dom.input.style.height = 'auto';
|
|
37
45
|
dom.inputArea.classList.remove('bash-mode');
|
|
@@ -55,6 +63,11 @@ function sendMessage() {
|
|
|
55
63
|
return;
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
if (!wsReady()) {
|
|
67
|
+
addSystem('warn: Not connected, please retry');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
// Show user message with image thumbnails
|
|
59
72
|
const msgEl = addMessage('user', text || '(image)');
|
|
60
73
|
for (const img of state.pendingImages) {
|
|
@@ -77,6 +90,12 @@ function sendMessage() {
|
|
|
77
90
|
body: JSON.stringify({ data: img.data, mimeType: img.mimeType }),
|
|
78
91
|
}).then(r => r.json()).then(j => ({ data: img.data, mimeType: img.mimeType, path: j.path }))
|
|
79
92
|
)).then(uploaded => {
|
|
93
|
+
if (!wsReady()) {
|
|
94
|
+
msgEl.remove();
|
|
95
|
+
addSystem('warn: Not connected, please retry');
|
|
96
|
+
setBusy(false);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
80
99
|
state.ws.send(JSON.stringify({ type: 'prompt', sessionId: state.sessionId, text: text || 'What is in this image?', images: uploaded }));
|
|
81
100
|
});
|
|
82
101
|
} else {
|
|
@@ -43,6 +43,7 @@ export const state = {
|
|
|
43
43
|
lastEventSeq: 0,
|
|
44
44
|
replayInProgress: false,
|
|
45
45
|
replayQueue: [],
|
|
46
|
+
unconfirmedPermissions: new Map(),
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const CONNECTION_STATUS_CLASSES = {
|
|
@@ -112,6 +113,7 @@ export function resetSessionUI() {
|
|
|
112
113
|
state.followMessages = true;
|
|
113
114
|
state.pendingToolCallIds.clear();
|
|
114
115
|
state.pendingPermissionRequestIds.clear();
|
|
116
|
+
state.unconfirmedPermissions.clear();
|
|
115
117
|
state.pendingPromptDone = false;
|
|
116
118
|
state.turnEnded = false;
|
|
117
119
|
state._cancelTimerId = null;
|
package/dist/sw.js
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
|
-
// Minimal service worker for PWA installability.
|
|
1
|
+
// Minimal service worker for PWA installability + push notifications.
|
|
2
2
|
// No offline caching — app requires WebSocket connection.
|
|
3
3
|
|
|
4
4
|
self.addEventListener('install', () => self.skipWaiting());
|
|
5
5
|
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
|
6
|
+
|
|
7
|
+
// --- Push notifications ---
|
|
8
|
+
|
|
9
|
+
self.addEventListener('push', (e) => {
|
|
10
|
+
if (!e.data) return;
|
|
11
|
+
|
|
12
|
+
let payload;
|
|
13
|
+
try {
|
|
14
|
+
payload = e.data.json();
|
|
15
|
+
} catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { title, body, data } = payload;
|
|
20
|
+
e.waitUntil(
|
|
21
|
+
self.registration.showNotification(title || 'WebAgent', {
|
|
22
|
+
body: body || '',
|
|
23
|
+
icon: '/icon-192.png',
|
|
24
|
+
badge: '/icon-192.png',
|
|
25
|
+
tag: data?.sessionId || 'default',
|
|
26
|
+
data: data || {},
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
self.addEventListener('notificationclick', (e) => {
|
|
32
|
+
e.notification.close();
|
|
33
|
+
|
|
34
|
+
const sessionId = e.notification.data?.sessionId;
|
|
35
|
+
const urlHash = sessionId ? `/#${sessionId}` : '/';
|
|
36
|
+
|
|
37
|
+
e.waitUntil(
|
|
38
|
+
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
|
39
|
+
// Focus existing window if open
|
|
40
|
+
for (const client of clients) {
|
|
41
|
+
if (client.url.includes(self.location.origin)) {
|
|
42
|
+
client.focus();
|
|
43
|
+
client.postMessage({ type: 'navigate', sessionId });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Otherwise open a new window
|
|
48
|
+
return self.clients.openWindow(urlHash);
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
});
|
package/lib/config.js
CHANGED
|
@@ -16,6 +16,11 @@ const ConfigSchema = z.object({
|
|
|
16
16
|
image_upload: 10_485_760,
|
|
17
17
|
cancel_timeout: 10_000,
|
|
18
18
|
}),
|
|
19
|
+
push: z.object({
|
|
20
|
+
vapid_subject: z.string().default("mailto:webagent@localhost"),
|
|
21
|
+
}).default({
|
|
22
|
+
vapid_subject: "mailto:webagent@localhost",
|
|
23
|
+
}),
|
|
19
24
|
});
|
|
20
25
|
let _config = null;
|
|
21
26
|
function parseArgs() {
|
package/lib/daemon.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Constants
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const PID_FILE = "webagent.pid";
|
|
10
|
+
const LOG_FILE = "webagent.log";
|
|
11
|
+
const RESTART_DELAY_INITIAL = 1_000;
|
|
12
|
+
const RESTART_DELAY_MAX = 30_000;
|
|
13
|
+
const STABLE_THRESHOLD_MS = 60_000;
|
|
14
|
+
const KILL_GRACE_MS = 5_000;
|
|
15
|
+
const SUBCOMMANDS = ["start", "stop", "status", "restart"];
|
|
16
|
+
/** Read and validate the PID file at `filePath`. Returns null if missing or stale. */
|
|
17
|
+
export function readPidInfo(filePath) {
|
|
18
|
+
if (!existsSync(filePath))
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const info = JSON.parse(readFileSync(filePath, "utf8"));
|
|
22
|
+
if (typeof info.pid !== "number" || !Number.isFinite(info.pid))
|
|
23
|
+
return null;
|
|
24
|
+
process.kill(info.pid, 0); // existence check — throws if dead
|
|
25
|
+
return info;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Process is dead or file corrupt — clean up
|
|
29
|
+
try {
|
|
30
|
+
unlinkSync(filePath);
|
|
31
|
+
}
|
|
32
|
+
catch { /* ignore */ }
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Write PID info to `filePath`. */
|
|
37
|
+
export function writePidInfo(filePath, info) {
|
|
38
|
+
writeFileSync(filePath, JSON.stringify(info) + "\n");
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Arg helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
export function isSubcommand(arg) {
|
|
44
|
+
return SUBCOMMANDS.includes(arg);
|
|
45
|
+
}
|
|
46
|
+
/** Resolve relative `--config` values to absolute paths (based on cwd). */
|
|
47
|
+
export function resolveArgs(args) {
|
|
48
|
+
const result = [...args];
|
|
49
|
+
for (let i = 0; i < result.length; i++) {
|
|
50
|
+
if (result[i] === "--config" && i + 1 < result.length && !isAbsolute(result[i + 1])) {
|
|
51
|
+
result[i + 1] = resolve(result[i + 1]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Command dispatch
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
export async function run(command, args) {
|
|
60
|
+
const pidFile = join(process.cwd(), PID_FILE);
|
|
61
|
+
const logFile = join(process.cwd(), LOG_FILE);
|
|
62
|
+
switch (command) {
|
|
63
|
+
case "start": return cmdStart(pidFile, logFile, args);
|
|
64
|
+
case "stop": return cmdStop(pidFile);
|
|
65
|
+
case "status": return cmdStatus(pidFile, logFile);
|
|
66
|
+
case "restart": return cmdRestart(pidFile, logFile);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Commands
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
async function cmdStart(pidFile, logFile, args) {
|
|
73
|
+
const existing = readPidInfo(pidFile);
|
|
74
|
+
if (existing) {
|
|
75
|
+
console.log(`webagent is already running (pid ${existing.pid})`);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const serverJs = join(__dirname, "server.js");
|
|
80
|
+
if (!existsSync(serverJs)) {
|
|
81
|
+
console.error(`server not found: ${serverJs}`);
|
|
82
|
+
console.error('run "npx tsc -p tsconfig.build.json" first if developing from source');
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const resolved = resolveArgs(args);
|
|
87
|
+
const daemonJs = join(__dirname, "daemon.js");
|
|
88
|
+
const log = openSync(logFile, "a");
|
|
89
|
+
const child = spawn(process.execPath, [daemonJs, "__supervisor", ...resolved], { detached: true, stdio: ["ignore", log, log], cwd: process.cwd() });
|
|
90
|
+
child.unref();
|
|
91
|
+
closeSync(log);
|
|
92
|
+
// Poll for PID file (supervisor writes it on startup)
|
|
93
|
+
for (let i = 0; i < 6; i++) {
|
|
94
|
+
await sleep(500);
|
|
95
|
+
const info = readPidInfo(pidFile);
|
|
96
|
+
if (info) {
|
|
97
|
+
console.log(`webagent started (pid ${info.pid})`);
|
|
98
|
+
console.log(`log: ${logFile}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
console.error("webagent failed to start");
|
|
103
|
+
console.error(`check log: ${logFile}`);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
}
|
|
106
|
+
async function cmdStop(pidFile) {
|
|
107
|
+
const info = readPidInfo(pidFile);
|
|
108
|
+
if (!info) {
|
|
109
|
+
console.log("webagent is not running");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
process.kill(info.pid, "SIGTERM");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
console.log("webagent is not running (stale pid file removed)");
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(pidFile);
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Wait for exit
|
|
124
|
+
const deadline = Date.now() + 10_000;
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
await sleep(300);
|
|
127
|
+
try {
|
|
128
|
+
process.kill(info.pid, 0);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Gone — supervisor cleans up PID file, but be safe
|
|
132
|
+
try {
|
|
133
|
+
unlinkSync(pidFile);
|
|
134
|
+
}
|
|
135
|
+
catch { /* ignore */ }
|
|
136
|
+
console.log("webagent stopped");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
console.error(`webagent (pid ${info.pid}) did not stop within 10s`);
|
|
141
|
+
console.error(`try: kill -9 ${info.pid}`);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
}
|
|
144
|
+
async function cmdStatus(pidFile, logFile) {
|
|
145
|
+
const info = readPidInfo(pidFile);
|
|
146
|
+
if (!info) {
|
|
147
|
+
console.log("webagent is not running");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const uptimeMs = Date.now() - new Date(info.started).getTime();
|
|
151
|
+
const h = Math.floor(uptimeMs / 3_600_000);
|
|
152
|
+
const m = Math.floor((uptimeMs % 3_600_000) / 60_000);
|
|
153
|
+
console.log(`webagent is running (pid ${info.pid})`);
|
|
154
|
+
console.log(` started: ${info.started}`);
|
|
155
|
+
console.log(` uptime: ${h}h ${m}m`);
|
|
156
|
+
console.log(` args: ${info.args.join(" ") || "(none)"}`);
|
|
157
|
+
console.log(` log: ${logFile}`);
|
|
158
|
+
}
|
|
159
|
+
async function cmdRestart(pidFile, logFile) {
|
|
160
|
+
const info = readPidInfo(pidFile);
|
|
161
|
+
if (!info) {
|
|
162
|
+
console.log("webagent is not running");
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (process.platform === "win32") {
|
|
167
|
+
// No SIGHUP on Windows — fall back to stop + start (non-atomic)
|
|
168
|
+
await cmdStop(pidFile);
|
|
169
|
+
await cmdStart(pidFile, logFile, info.args);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Unix: atomic restart via SIGHUP to supervisor
|
|
173
|
+
try {
|
|
174
|
+
process.kill(info.pid, "SIGHUP");
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
console.error(`failed to signal webagent (pid ${info.pid})`);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Wait briefly and verify
|
|
182
|
+
await sleep(2000);
|
|
183
|
+
const newInfo = readPidInfo(pidFile);
|
|
184
|
+
if (newInfo) {
|
|
185
|
+
console.log(`webagent restarted (pid ${newInfo.pid})`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
console.error("webagent may have failed to restart");
|
|
189
|
+
console.error(`check log: ${logFile}`);
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Supervisor (internal — launched by `start` as a detached process)
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
function runSupervisor(serverArgs) {
|
|
197
|
+
const serverJs = join(__dirname, "server.js");
|
|
198
|
+
const pidFile = join(process.cwd(), PID_FILE);
|
|
199
|
+
writePidInfo(pidFile, { pid: process.pid, args: serverArgs, started: new Date().toISOString() });
|
|
200
|
+
let child = null;
|
|
201
|
+
let stopping = false;
|
|
202
|
+
let lastStart = 0;
|
|
203
|
+
let delay = RESTART_DELAY_INITIAL;
|
|
204
|
+
let timer = null;
|
|
205
|
+
function spawnServer() {
|
|
206
|
+
lastStart = Date.now();
|
|
207
|
+
child = spawn(process.execPath, [serverJs, ...serverArgs], { stdio: "inherit" });
|
|
208
|
+
child.on("exit", onChildExit);
|
|
209
|
+
}
|
|
210
|
+
function onChildExit(code, signal) {
|
|
211
|
+
child = null;
|
|
212
|
+
if (stopping)
|
|
213
|
+
return;
|
|
214
|
+
if (Date.now() - lastStart > STABLE_THRESHOLD_MS) {
|
|
215
|
+
delay = RESTART_DELAY_INITIAL;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
delay = Math.min(delay * 2, RESTART_DELAY_MAX);
|
|
219
|
+
}
|
|
220
|
+
console.log(`[supervisor] server exited (code=${code} signal=${signal}), restarting in ${delay}ms`);
|
|
221
|
+
timer = setTimeout(spawnServer, delay);
|
|
222
|
+
}
|
|
223
|
+
function killChild() {
|
|
224
|
+
if (timer) {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
timer = null;
|
|
227
|
+
}
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
if (!child) {
|
|
230
|
+
resolve();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const c = child;
|
|
234
|
+
c.once("exit", () => resolve());
|
|
235
|
+
c.kill("SIGTERM");
|
|
236
|
+
setTimeout(() => { try {
|
|
237
|
+
c.kill("SIGKILL");
|
|
238
|
+
}
|
|
239
|
+
catch { /* ignore */ } }, KILL_GRACE_MS);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
async function shutdown() {
|
|
243
|
+
if (stopping)
|
|
244
|
+
return;
|
|
245
|
+
stopping = true;
|
|
246
|
+
await killChild();
|
|
247
|
+
try {
|
|
248
|
+
unlinkSync(pidFile);
|
|
249
|
+
}
|
|
250
|
+
catch { /* ignore */ }
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
process.on("SIGTERM", () => { shutdown(); });
|
|
254
|
+
process.on("SIGINT", () => { shutdown(); });
|
|
255
|
+
if (process.platform !== "win32") {
|
|
256
|
+
process.on("SIGHUP", async () => {
|
|
257
|
+
console.log("[supervisor] SIGHUP received, restarting server");
|
|
258
|
+
delay = RESTART_DELAY_INITIAL;
|
|
259
|
+
await killChild();
|
|
260
|
+
if (!stopping)
|
|
261
|
+
spawnServer();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
console.log(`[supervisor] started (pid ${process.pid})`);
|
|
265
|
+
spawnServer();
|
|
266
|
+
}
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Utility
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
function sleep(ms) {
|
|
271
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
272
|
+
}
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Direct execution: node daemon.js __supervisor [server args...]
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
if (process.argv[2] === "__supervisor") {
|
|
277
|
+
runSupervisor(process.argv.slice(3));
|
|
278
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { broadcast } from "./ws-handler.js";
|
|
2
|
+
export function handleAgentEvent(event, sessions, store, wss, bridge, config, pushService) {
|
|
3
|
+
if ("sessionId" in event && event.sessionId && sessions.restoringSessions.has(event.sessionId))
|
|
4
|
+
return;
|
|
5
|
+
switch (event.type) {
|
|
6
|
+
case "connected":
|
|
7
|
+
event.cancelTimeout = config.cancelTimeout;
|
|
8
|
+
break;
|
|
9
|
+
case "session_created":
|
|
10
|
+
if (event.configOptions?.length)
|
|
11
|
+
sessions.cachedConfigOptions = event.configOptions;
|
|
12
|
+
for (const opt of event.configOptions ?? []) {
|
|
13
|
+
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
14
|
+
}
|
|
15
|
+
break;
|
|
16
|
+
case "config_option_update":
|
|
17
|
+
if (event.configOptions?.length)
|
|
18
|
+
sessions.cachedConfigOptions = event.configOptions;
|
|
19
|
+
for (const opt of event.configOptions ?? []) {
|
|
20
|
+
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
21
|
+
}
|
|
22
|
+
break;
|
|
23
|
+
case "message_chunk":
|
|
24
|
+
sessions.flushThinkingBuffer(event.sessionId);
|
|
25
|
+
sessions.appendAssistant(event.sessionId, event.text);
|
|
26
|
+
break;
|
|
27
|
+
case "thought_chunk":
|
|
28
|
+
sessions.flushAssistantBuffer(event.sessionId);
|
|
29
|
+
sessions.appendThinking(event.sessionId, event.text);
|
|
30
|
+
break;
|
|
31
|
+
case "tool_call":
|
|
32
|
+
sessions.flushBuffers(event.sessionId);
|
|
33
|
+
store.saveEvent(event.sessionId, event.type, { id: event.id, title: event.title, kind: event.kind, rawInput: event.rawInput });
|
|
34
|
+
break;
|
|
35
|
+
case "tool_call_update":
|
|
36
|
+
store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content });
|
|
37
|
+
break;
|
|
38
|
+
case "plan":
|
|
39
|
+
sessions.flushBuffers(event.sessionId);
|
|
40
|
+
store.saveEvent(event.sessionId, event.type, { entries: event.entries });
|
|
41
|
+
break;
|
|
42
|
+
case "permission_request": {
|
|
43
|
+
sessions.flushBuffers(event.sessionId);
|
|
44
|
+
store.saveEvent(event.sessionId, event.type, {
|
|
45
|
+
requestId: event.requestId, title: event.title, options: event.options,
|
|
46
|
+
});
|
|
47
|
+
// Auto-approve permissions in autopilot mode (allow_once only to avoid persisting across mode switches)
|
|
48
|
+
const mode = store.getSession(event.sessionId)?.mode ?? "";
|
|
49
|
+
if (mode.includes("#autopilot")) {
|
|
50
|
+
const opt = event.options.find((o) => o.kind === "allow_once");
|
|
51
|
+
if (opt) {
|
|
52
|
+
bridge.resolvePermission(event.requestId, opt.optionId);
|
|
53
|
+
const optionName = opt.label ?? opt.optionId;
|
|
54
|
+
store.saveEvent(event.sessionId, "permission_response", {
|
|
55
|
+
requestId: event.requestId, optionName, denied: false,
|
|
56
|
+
});
|
|
57
|
+
broadcast(wss, {
|
|
58
|
+
type: "permission_resolved",
|
|
59
|
+
sessionId: event.sessionId,
|
|
60
|
+
requestId: event.requestId,
|
|
61
|
+
optionName,
|
|
62
|
+
denied: false,
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "prompt_done":
|
|
70
|
+
sessions.activePrompts.delete(event.sessionId);
|
|
71
|
+
sessions.flushBuffers(event.sessionId);
|
|
72
|
+
store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason });
|
|
73
|
+
break;
|
|
74
|
+
case "error":
|
|
75
|
+
if (event.sessionId) {
|
|
76
|
+
sessions.activePrompts.delete(event.sessionId);
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
broadcast(wss, event);
|
|
81
|
+
// Push notification check (after broadcast so WS clients get the event first)
|
|
82
|
+
if (pushService && "sessionId" in event && event.sessionId) {
|
|
83
|
+
const session = store.getSession(event.sessionId);
|
|
84
|
+
const eventData = {};
|
|
85
|
+
if (event.type === "permission_request") {
|
|
86
|
+
eventData.description = event.title;
|
|
87
|
+
}
|
|
88
|
+
if (pushService.maybeNotify(event.sessionId, session?.title ?? null, event.type, eventData)) {
|
|
89
|
+
const notification = pushService.formatNotification(event.sessionId, session?.title ?? null, event.type, eventData);
|
|
90
|
+
pushService.sendToAll(notification).catch((err) => {
|
|
91
|
+
console.error("[push] failed to send:", err);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|