@lelouchhe/webagent 0.1.10 → 0.2.2
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 +45 -268
- package/dist/index.html +2 -3
- package/dist/js/app.C4WRSLDF.js +10 -0
- package/dist/{styles.01a9ju9l.css → styles.01a6wdjv.css} +30 -5
- package/lib/bridge.js +284 -0
- package/lib/config.js +62 -0
- package/lib/daemon.js +278 -0
- package/lib/event-handler.js +106 -0
- package/lib/push-service.js +166 -0
- package/lib/routes.js +945 -0
- package/lib/server.js +82 -0
- package/lib/session-manager.js +277 -0
- package/lib/shared/constants.js +17 -0
- package/lib/sse-manager.js +80 -0
- package/lib/store.js +174 -0
- package/lib/title-service.js +74 -0
- package/lib/types.js +13 -0
- package/package.json +6 -4
- package/dist/js/app.IXP5KGP6.js +0 -8
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export function handleAgentEvent(event, sessions, store, bridge, config, sseManager, pushService) {
|
|
2
|
+
if ("sessionId" in event && event.sessionId && sessions.restoringSessions.has(event.sessionId))
|
|
3
|
+
return;
|
|
4
|
+
switch (event.type) {
|
|
5
|
+
case "connected":
|
|
6
|
+
event.cancelTimeout = config.cancelTimeout;
|
|
7
|
+
if (event.agent)
|
|
8
|
+
sessions.agentInfo = event.agent;
|
|
9
|
+
break;
|
|
10
|
+
case "session_created":
|
|
11
|
+
if (event.configOptions?.length)
|
|
12
|
+
sessions.cachedConfigOptions = event.configOptions;
|
|
13
|
+
for (const opt of event.configOptions ?? []) {
|
|
14
|
+
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
15
|
+
}
|
|
16
|
+
break;
|
|
17
|
+
case "config_option_update":
|
|
18
|
+
if (event.configOptions?.length)
|
|
19
|
+
sessions.cachedConfigOptions = event.configOptions;
|
|
20
|
+
for (const opt of event.configOptions ?? []) {
|
|
21
|
+
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
22
|
+
}
|
|
23
|
+
break;
|
|
24
|
+
case "message_chunk":
|
|
25
|
+
sessions.flushThinkingBuffer(event.sessionId);
|
|
26
|
+
sessions.appendAssistant(event.sessionId, event.text);
|
|
27
|
+
break;
|
|
28
|
+
case "thought_chunk":
|
|
29
|
+
sessions.flushAssistantBuffer(event.sessionId);
|
|
30
|
+
sessions.appendThinking(event.sessionId, event.text);
|
|
31
|
+
break;
|
|
32
|
+
case "tool_call":
|
|
33
|
+
sessions.flushBuffers(event.sessionId);
|
|
34
|
+
store.saveEvent(event.sessionId, event.type, { id: event.id, title: event.title, kind: event.kind, rawInput: event.rawInput });
|
|
35
|
+
break;
|
|
36
|
+
case "tool_call_update":
|
|
37
|
+
store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content });
|
|
38
|
+
break;
|
|
39
|
+
case "plan":
|
|
40
|
+
sessions.flushBuffers(event.sessionId);
|
|
41
|
+
store.saveEvent(event.sessionId, event.type, { entries: event.entries });
|
|
42
|
+
break;
|
|
43
|
+
case "permission_request": {
|
|
44
|
+
sessions.flushBuffers(event.sessionId);
|
|
45
|
+
store.saveEvent(event.sessionId, event.type, {
|
|
46
|
+
requestId: event.requestId, title: event.title, options: event.options,
|
|
47
|
+
});
|
|
48
|
+
sessions.pendingPermissions.set(event.requestId, {
|
|
49
|
+
requestId: event.requestId,
|
|
50
|
+
sessionId: event.sessionId,
|
|
51
|
+
title: event.title,
|
|
52
|
+
options: event.options.map((o) => ({ optionId: o.optionId, label: o.label ?? o.name ?? o.optionId })),
|
|
53
|
+
});
|
|
54
|
+
// Auto-approve permissions in autopilot mode (allow_once only to avoid persisting across mode switches)
|
|
55
|
+
const mode = store.getSession(event.sessionId)?.mode ?? "";
|
|
56
|
+
if (mode.includes("#autopilot")) {
|
|
57
|
+
const opt = event.options.find((o) => o.kind === "allow_once");
|
|
58
|
+
if (opt) {
|
|
59
|
+
bridge.resolvePermission(event.requestId, opt.optionId);
|
|
60
|
+
sessions.pendingPermissions.delete(event.requestId);
|
|
61
|
+
const optionName = opt.label ?? opt.optionId;
|
|
62
|
+
store.saveEvent(event.sessionId, "permission_response", {
|
|
63
|
+
requestId: event.requestId, optionName, denied: false,
|
|
64
|
+
});
|
|
65
|
+
// Broadcast both so the frontend can render then collapse the permission card
|
|
66
|
+
sseManager.broadcast(event);
|
|
67
|
+
const resolvedEvent = {
|
|
68
|
+
type: "permission_resolved",
|
|
69
|
+
sessionId: event.sessionId,
|
|
70
|
+
requestId: event.requestId,
|
|
71
|
+
optionName,
|
|
72
|
+
denied: false,
|
|
73
|
+
};
|
|
74
|
+
sseManager.broadcast(resolvedEvent);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "prompt_done":
|
|
81
|
+
sessions.activePrompts.delete(event.sessionId);
|
|
82
|
+
sessions.flushBuffers(event.sessionId);
|
|
83
|
+
store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason });
|
|
84
|
+
break;
|
|
85
|
+
case "error":
|
|
86
|
+
if (event.sessionId) {
|
|
87
|
+
sessions.activePrompts.delete(event.sessionId);
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
sseManager.broadcast(event);
|
|
92
|
+
// Push notification check (after broadcast so clients get the event first)
|
|
93
|
+
if (pushService && "sessionId" in event && event.sessionId) {
|
|
94
|
+
const session = store.getSession(event.sessionId);
|
|
95
|
+
const eventData = {};
|
|
96
|
+
if (event.type === "permission_request") {
|
|
97
|
+
eventData.description = event.title;
|
|
98
|
+
}
|
|
99
|
+
if (pushService.maybeNotify(event.sessionId, session?.title ?? null, event.type, eventData)) {
|
|
100
|
+
const notification = pushService.formatNotification(event.sessionId, session?.title ?? null, event.type, eventData);
|
|
101
|
+
pushService.sendToAll(notification).catch((err) => {
|
|
102
|
+
console.error("[push] failed to send:", err);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import webpush from "web-push";
|
|
2
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const VAPID_FILE = "vapid.json";
|
|
5
|
+
/** Remove a subscription after this many consecutive send failures. */
|
|
6
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
7
|
+
export class PushService {
|
|
8
|
+
store;
|
|
9
|
+
vapidKeys;
|
|
10
|
+
clientVisibility = new Map(); // clientId → visible
|
|
11
|
+
clientEndpoints = new Map(); // clientId → push endpoint
|
|
12
|
+
clientSessions = new Map(); // clientId → currently viewed sessionId
|
|
13
|
+
/** endpoint → consecutive failure count (absent or 0 = healthy) */
|
|
14
|
+
failureCounts = new Map();
|
|
15
|
+
constructor(store, dataDir, vapidSubject) {
|
|
16
|
+
this.store = store;
|
|
17
|
+
this.vapidKeys = this.loadOrGenerateKeys(dataDir);
|
|
18
|
+
webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// VAPID keys
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
loadOrGenerateKeys(dataDir) {
|
|
24
|
+
const filePath = join(dataDir, VAPID_FILE);
|
|
25
|
+
if (existsSync(filePath)) {
|
|
26
|
+
chmodSync(filePath, 0o600);
|
|
27
|
+
const keys = JSON.parse(readFileSync(filePath, "utf8"));
|
|
28
|
+
console.log("[push] loaded VAPID keys");
|
|
29
|
+
return keys;
|
|
30
|
+
}
|
|
31
|
+
const keys = webpush.generateVAPIDKeys();
|
|
32
|
+
writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", { mode: 0o600 });
|
|
33
|
+
console.log("[push] generated new VAPID keys");
|
|
34
|
+
return keys;
|
|
35
|
+
}
|
|
36
|
+
getPublicKey() {
|
|
37
|
+
return this.vapidKeys.publicKey;
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Notification formatting
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
formatNotification(sessionId, sessionTitle, eventType, eventData) {
|
|
43
|
+
const title = sessionTitle || "WebAgent";
|
|
44
|
+
let body;
|
|
45
|
+
switch (eventType) {
|
|
46
|
+
case "permission_request":
|
|
47
|
+
body = `⚿ ${eventData.description ?? "Permission requested"}`;
|
|
48
|
+
break;
|
|
49
|
+
case "prompt_done":
|
|
50
|
+
body = "✓ Task complete";
|
|
51
|
+
break;
|
|
52
|
+
case "bash_done": {
|
|
53
|
+
const cmd = eventData.command ?? "command";
|
|
54
|
+
const code = eventData.exitCode ?? "?";
|
|
55
|
+
body = `$ ${cmd} — exit ${code}`;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
default:
|
|
59
|
+
body = eventType;
|
|
60
|
+
}
|
|
61
|
+
return { title, body, data: { sessionId } };
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Client visibility tracking
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
setClientVisibility(clientId, visible) {
|
|
67
|
+
this.clientVisibility.set(clientId, visible);
|
|
68
|
+
}
|
|
69
|
+
setClientSession(clientId, sessionId) {
|
|
70
|
+
this.clientSessions.set(clientId, sessionId);
|
|
71
|
+
}
|
|
72
|
+
registerClient(clientId, endpoint) {
|
|
73
|
+
this.clientEndpoints.set(clientId, endpoint);
|
|
74
|
+
}
|
|
75
|
+
removeClient(clientId) {
|
|
76
|
+
this.clientVisibility.delete(clientId);
|
|
77
|
+
this.clientEndpoints.delete(clientId);
|
|
78
|
+
this.clientSessions.delete(clientId);
|
|
79
|
+
}
|
|
80
|
+
hasVisibleClient() {
|
|
81
|
+
for (const visible of this.clientVisibility.values()) {
|
|
82
|
+
if (visible)
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
/** Check if a specific endpoint has at least one visible client. */
|
|
88
|
+
isEndpointVisible(endpoint) {
|
|
89
|
+
for (const [clientId, ep] of this.clientEndpoints) {
|
|
90
|
+
if (ep === endpoint && this.clientVisibility.get(clientId))
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Check if any client (across all endpoints) is visible and viewing the given session.
|
|
97
|
+
* A client with no session set does not suppress any session's push.
|
|
98
|
+
* Used for global suppression: if any client sees this session, all endpoints are skipped.
|
|
99
|
+
*/
|
|
100
|
+
isSessionVisibleToAnyClient(sessionId) {
|
|
101
|
+
for (const [clientId, visible] of this.clientVisibility) {
|
|
102
|
+
if (visible && this.clientSessions.get(clientId) === sessionId)
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// High-level: decide whether to push, and if so, send
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
static NOTIFIABLE = new Set(["permission_request", "prompt_done", "bash_done"]);
|
|
111
|
+
/**
|
|
112
|
+
* Check if this event should trigger a push notification.
|
|
113
|
+
* Returns true if a notification should be sent (caller should then call sendToAll).
|
|
114
|
+
* Global session visibility suppression happens inside sendToAll.
|
|
115
|
+
*/
|
|
116
|
+
maybeNotify(sessionId, sessionTitle, eventType, eventData) {
|
|
117
|
+
if (!PushService.NOTIFIABLE.has(eventType))
|
|
118
|
+
return false;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Send push to all subscriptions
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
async sendToAll(notification) {
|
|
125
|
+
const subs = this.store.getAllSubscriptions();
|
|
126
|
+
if (subs.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
const payload = JSON.stringify(notification);
|
|
129
|
+
// Global visibility: if any client is viewing this session, suppress all push
|
|
130
|
+
if (this.isSessionVisibleToAnyClient(notification.data.sessionId))
|
|
131
|
+
return;
|
|
132
|
+
const results = await Promise.allSettled(subs.map((sub) => this.sendOne({ endpoint: sub.endpoint, keys: { auth: sub.auth, p256dh: sub.p256dh } }, payload)));
|
|
133
|
+
for (let i = 0; i < results.length; i++) {
|
|
134
|
+
const result = results[i];
|
|
135
|
+
const endpoint = subs[i].endpoint;
|
|
136
|
+
if (result.status === "fulfilled") {
|
|
137
|
+
this.failureCounts.delete(endpoint);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const err = result.reason;
|
|
141
|
+
if (err.statusCode === 410) {
|
|
142
|
+
// Subscription expired — clean up immediately
|
|
143
|
+
this.store.removeSubscription(endpoint);
|
|
144
|
+
this.failureCounts.delete(endpoint);
|
|
145
|
+
console.log(`[push] removed expired subscription (410): ${endpoint.slice(0, 60)}…`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const count = (this.failureCounts.get(endpoint) ?? 0) + 1;
|
|
149
|
+
if (count >= MAX_CONSECUTIVE_FAILURES) {
|
|
150
|
+
this.store.removeSubscription(endpoint);
|
|
151
|
+
this.failureCounts.delete(endpoint);
|
|
152
|
+
console.log(`[push] removed subscription after ${count} consecutive failures: ${endpoint.slice(0, 60)}…`);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
this.failureCounts.set(endpoint, count);
|
|
156
|
+
console.error(`[push] send failed (${count}/${MAX_CONSECUTIVE_FAILURES}) for ${endpoint.slice(0, 60)}…:`, result.reason);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Send a single push notification. Extracted for testability. */
|
|
163
|
+
sendOne(sub, payload) {
|
|
164
|
+
return webpush.sendNotification(sub, payload);
|
|
165
|
+
}
|
|
166
|
+
}
|