@lelouchhe/webagent 0.1.7 → 0.1.10
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 +21 -18
- package/config.toml +1 -1
- package/dist/index.html +2 -2
- package/dist/js/app.IXP5KGP6.js +8 -0
- package/dist/{styles.mmmmfxhu.css → styles.01a9ju9l.css} +8 -0
- package/dist/sw.js +1 -1
- package/package.json +7 -8
- package/dist/js/app.mmmmfxhu.js +0 -34
- package/dist/js/commands.mmmmfxhu.js +0 -647
- package/dist/js/connection.mmmmfxhu.js +0 -87
- package/dist/js/events.mmmmfxhu.js +0 -694
- package/dist/js/images.mmmmfxhu.js +0 -58
- package/dist/js/input.mmmmfxhu.js +0 -215
- package/dist/js/render.mmmmfxhu.js +0 -200
- package/dist/js/state.mmmmfxhu.js +0 -203
- package/lib/bridge.js +0 -284
- package/lib/config.js +0 -62
- package/lib/daemon.js +0 -278
- package/lib/event-handler.js +0 -95
- package/lib/push-service.js +0 -112
- package/lib/routes.js +0 -202
- package/lib/server.js +0 -70
- package/lib/session-manager.js +0 -199
- package/lib/store.js +0 -120
- package/lib/title-service.js +0 -71
- package/lib/types.js +0 -48
- package/lib/ws-handler.js +0 -280
package/lib/event-handler.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
}
|
package/lib/push-service.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
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
|
-
export class PushService {
|
|
6
|
-
store;
|
|
7
|
-
vapidKeys;
|
|
8
|
-
clientVisibility = new Map(); // clientId → visible
|
|
9
|
-
constructor(store, dataDir, vapidSubject) {
|
|
10
|
-
this.store = store;
|
|
11
|
-
this.vapidKeys = this.loadOrGenerateKeys(dataDir);
|
|
12
|
-
webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
|
|
13
|
-
}
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// VAPID keys
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
loadOrGenerateKeys(dataDir) {
|
|
18
|
-
const filePath = join(dataDir, VAPID_FILE);
|
|
19
|
-
if (existsSync(filePath)) {
|
|
20
|
-
chmodSync(filePath, 0o600);
|
|
21
|
-
const keys = JSON.parse(readFileSync(filePath, "utf8"));
|
|
22
|
-
console.log("[push] loaded VAPID keys");
|
|
23
|
-
return keys;
|
|
24
|
-
}
|
|
25
|
-
const keys = webpush.generateVAPIDKeys();
|
|
26
|
-
writeFileSync(filePath, JSON.stringify(keys, null, 2) + "\n", { mode: 0o600 });
|
|
27
|
-
console.log("[push] generated new VAPID keys");
|
|
28
|
-
return keys;
|
|
29
|
-
}
|
|
30
|
-
getPublicKey() {
|
|
31
|
-
return this.vapidKeys.publicKey;
|
|
32
|
-
}
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// Notification formatting
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
formatNotification(sessionId, sessionTitle, eventType, eventData) {
|
|
37
|
-
const title = sessionTitle ? `WebAgent · ${sessionTitle}` : "WebAgent";
|
|
38
|
-
let body;
|
|
39
|
-
switch (eventType) {
|
|
40
|
-
case "permission_request":
|
|
41
|
-
body = `⚿ ${eventData.description ?? "Permission requested"}`;
|
|
42
|
-
break;
|
|
43
|
-
case "prompt_done":
|
|
44
|
-
body = "✓ Task complete";
|
|
45
|
-
break;
|
|
46
|
-
case "bash_done": {
|
|
47
|
-
const cmd = eventData.command ?? "command";
|
|
48
|
-
const code = eventData.exitCode ?? "?";
|
|
49
|
-
body = `$ ${cmd} — exit ${code}`;
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
default:
|
|
53
|
-
body = eventType;
|
|
54
|
-
}
|
|
55
|
-
return { title, body, data: { sessionId } };
|
|
56
|
-
}
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
// Client visibility tracking
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
setClientVisibility(clientId, visible) {
|
|
61
|
-
this.clientVisibility.set(clientId, visible);
|
|
62
|
-
}
|
|
63
|
-
removeClient(clientId) {
|
|
64
|
-
this.clientVisibility.delete(clientId);
|
|
65
|
-
}
|
|
66
|
-
hasVisibleClient() {
|
|
67
|
-
for (const visible of this.clientVisibility.values()) {
|
|
68
|
-
if (visible)
|
|
69
|
-
return true;
|
|
70
|
-
}
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
// High-level: decide whether to push, and if so, send
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
static NOTIFIABLE = new Set(["permission_request", "prompt_done", "bash_done"]);
|
|
77
|
-
/**
|
|
78
|
-
* Check if this event should trigger a push notification.
|
|
79
|
-
* Returns true if a notification was queued (caller should then call sendToAll).
|
|
80
|
-
*/
|
|
81
|
-
maybeNotify(sessionId, sessionTitle, eventType, eventData) {
|
|
82
|
-
if (!PushService.NOTIFIABLE.has(eventType))
|
|
83
|
-
return false;
|
|
84
|
-
if (this.hasVisibleClient())
|
|
85
|
-
return false;
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// Send push to all subscriptions
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
async sendToAll(notification) {
|
|
92
|
-
const subs = this.store.getAllSubscriptions();
|
|
93
|
-
if (subs.length === 0)
|
|
94
|
-
return;
|
|
95
|
-
const payload = JSON.stringify(notification);
|
|
96
|
-
const results = await Promise.allSettled(subs.map((sub) => webpush.sendNotification({ endpoint: sub.endpoint, keys: { auth: sub.auth, p256dh: sub.p256dh } }, payload)));
|
|
97
|
-
for (let i = 0; i < results.length; i++) {
|
|
98
|
-
const result = results[i];
|
|
99
|
-
if (result.status === "rejected") {
|
|
100
|
-
const err = result.reason;
|
|
101
|
-
if (err.statusCode === 410) {
|
|
102
|
-
// Subscription expired — clean up
|
|
103
|
-
this.store.removeSubscription(subs[i].endpoint);
|
|
104
|
-
console.log(`[push] removed expired subscription: ${subs[i].endpoint}`);
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
console.error(`[push] send failed for ${subs[i].endpoint}:`, result.reason);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
package/lib/routes.js
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { join, extname } from "node:path";
|
|
3
|
-
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
4
|
-
const MIME = {
|
|
5
|
-
".html": "text/html",
|
|
6
|
-
".js": "application/javascript",
|
|
7
|
-
".css": "text/css",
|
|
8
|
-
".json": "application/json",
|
|
9
|
-
".svg": "image/svg+xml",
|
|
10
|
-
".png": "image/png",
|
|
11
|
-
".jpg": "image/jpeg",
|
|
12
|
-
".jpeg": "image/jpeg",
|
|
13
|
-
".gif": "image/gif",
|
|
14
|
-
".webp": "image/webp",
|
|
15
|
-
};
|
|
16
|
-
export function createRequestHandler(store, publicDir, dataDir, limits, pushService) {
|
|
17
|
-
return async (req, res) => {
|
|
18
|
-
const url = req.url ?? "/";
|
|
19
|
-
// --- API routes ---
|
|
20
|
-
if (url.startsWith("/api/")) {
|
|
21
|
-
res.setHeader("Content-Type", "application/json");
|
|
22
|
-
// GET /api/sessions
|
|
23
|
-
if (url === "/api/sessions" && req.method === "GET") {
|
|
24
|
-
res.end(JSON.stringify(store.listSessions()));
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
// GET /api/sessions/:id/events?thinking=0|1
|
|
28
|
-
const eventsMatch = url.match(/^\/api\/sessions\/([^/]+)\/events(\?.*)?$/);
|
|
29
|
-
if (eventsMatch && req.method === "GET") {
|
|
30
|
-
const sessionId = decodeURIComponent(eventsMatch[1]);
|
|
31
|
-
const params = new URLSearchParams(eventsMatch[2]?.slice(1) ?? "");
|
|
32
|
-
const excludeThinking = params.get("thinking") === "0";
|
|
33
|
-
const afterSeqRaw = params.get("after_seq");
|
|
34
|
-
const afterSeq = afterSeqRaw != null ? Number(afterSeqRaw) : undefined;
|
|
35
|
-
const session = store.getSession(sessionId);
|
|
36
|
-
if (!session) {
|
|
37
|
-
res.writeHead(404);
|
|
38
|
-
res.end(JSON.stringify({ error: "Session not found" }));
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
const events = store.getEvents(sessionId, { excludeThinking, afterSeq });
|
|
42
|
-
res.end(JSON.stringify(events));
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
// POST /api/images/:sessionId
|
|
46
|
-
const imgMatch = url.match(/^\/api\/images\/([^/]+)$/);
|
|
47
|
-
if (imgMatch && req.method === "POST") {
|
|
48
|
-
const sessionId = decodeURIComponent(imgMatch[1]);
|
|
49
|
-
if (!SAFE_ID.test(sessionId)) {
|
|
50
|
-
res.writeHead(400);
|
|
51
|
-
res.end(JSON.stringify({ error: "Invalid session ID" }));
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
// Enforce upload size limit
|
|
55
|
-
const contentLength = parseInt(req.headers["content-length"] ?? "0", 10);
|
|
56
|
-
if (contentLength > limits.image_upload) {
|
|
57
|
-
res.writeHead(413);
|
|
58
|
-
res.end(JSON.stringify({ error: "Upload too large" }));
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const chunks = [];
|
|
62
|
-
let totalSize = 0;
|
|
63
|
-
for await (const chunk of req) {
|
|
64
|
-
totalSize += chunk.length;
|
|
65
|
-
if (totalSize > limits.image_upload) {
|
|
66
|
-
res.writeHead(413);
|
|
67
|
-
res.end(JSON.stringify({ error: "Upload too large" }));
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
chunks.push(chunk);
|
|
71
|
-
}
|
|
72
|
-
let body;
|
|
73
|
-
try {
|
|
74
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
res.writeHead(400);
|
|
78
|
-
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const { data, mimeType } = body;
|
|
82
|
-
const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
|
|
83
|
-
const seq = Date.now();
|
|
84
|
-
const relPath = `images/${sessionId}/${seq}.${ext}`;
|
|
85
|
-
const absPath = join(dataDir, relPath);
|
|
86
|
-
await mkdir(join(dataDir, "images", sessionId), { recursive: true });
|
|
87
|
-
await writeFile(absPath, Buffer.from(data, "base64"));
|
|
88
|
-
const imgUrl = `/data/${relPath}`;
|
|
89
|
-
res.end(JSON.stringify({ path: relPath, url: imgUrl }));
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
// --- Push notification routes ---
|
|
93
|
-
// GET /api/push/vapid-key
|
|
94
|
-
if (url === "/api/push/vapid-key" && req.method === "GET") {
|
|
95
|
-
if (!pushService) {
|
|
96
|
-
res.writeHead(404);
|
|
97
|
-
res.end(JSON.stringify({ error: "Push not configured" }));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
res.end(JSON.stringify({ publicKey: pushService.getPublicKey() }));
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
// POST /api/push/subscribe
|
|
104
|
-
if (url === "/api/push/subscribe" && req.method === "POST") {
|
|
105
|
-
if (!pushService) {
|
|
106
|
-
res.writeHead(404);
|
|
107
|
-
res.end(JSON.stringify({ error: "Push not configured" }));
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
const chunks = [];
|
|
111
|
-
for await (const chunk of req)
|
|
112
|
-
chunks.push(chunk);
|
|
113
|
-
let body;
|
|
114
|
-
try {
|
|
115
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
res.writeHead(400);
|
|
119
|
-
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
if (!body.endpoint || !body.keys?.auth || !body.keys?.p256dh) {
|
|
123
|
-
res.writeHead(400);
|
|
124
|
-
res.end(JSON.stringify({ error: "Missing endpoint or keys (auth, p256dh)" }));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
|
|
128
|
-
res.writeHead(201);
|
|
129
|
-
res.end(JSON.stringify({ ok: true }));
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
// POST /api/push/unsubscribe
|
|
133
|
-
if (url === "/api/push/unsubscribe" && req.method === "POST") {
|
|
134
|
-
if (!pushService) {
|
|
135
|
-
res.writeHead(404);
|
|
136
|
-
res.end(JSON.stringify({ error: "Push not configured" }));
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
const chunks = [];
|
|
140
|
-
for await (const chunk of req)
|
|
141
|
-
chunks.push(chunk);
|
|
142
|
-
let body;
|
|
143
|
-
try {
|
|
144
|
-
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
res.writeHead(400);
|
|
148
|
-
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
if (body.endpoint) {
|
|
152
|
-
store.removeSubscription(body.endpoint);
|
|
153
|
-
}
|
|
154
|
-
res.end(JSON.stringify({ ok: true }));
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
res.writeHead(404);
|
|
158
|
-
res.end(JSON.stringify({ error: "Not found" }));
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
// --- Serve uploaded images: /data/images/... ---
|
|
162
|
-
if (url.startsWith("/data/images/")) {
|
|
163
|
-
const filePath = join(dataDir, url.slice(6)); // strip "/data/"
|
|
164
|
-
if (!filePath.startsWith(join(dataDir, "images"))) {
|
|
165
|
-
res.writeHead(403);
|
|
166
|
-
res.end("Forbidden");
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
try {
|
|
170
|
-
const data = await readFile(filePath);
|
|
171
|
-
const ext = extname(filePath);
|
|
172
|
-
res.writeHead(200, {
|
|
173
|
-
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
174
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
175
|
-
});
|
|
176
|
-
res.end(data);
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
res.writeHead(404);
|
|
180
|
-
res.end("Not found");
|
|
181
|
-
}
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
// --- Static files ---
|
|
185
|
-
const filePath = join(publicDir, url === "/" ? "/index.html" : url);
|
|
186
|
-
if (!filePath.startsWith(publicDir)) {
|
|
187
|
-
res.writeHead(403);
|
|
188
|
-
res.end("Forbidden");
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
try {
|
|
192
|
-
const data = await readFile(filePath);
|
|
193
|
-
const ext = extname(filePath);
|
|
194
|
-
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
195
|
-
res.end(data);
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
res.writeHead(404);
|
|
199
|
-
res.end("Not found");
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
}
|
package/lib/server.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { WebSocketServer } from "ws";
|
|
5
|
-
import { loadConfig } from "./config.js";
|
|
6
|
-
import { AgentBridge } from "./bridge.js";
|
|
7
|
-
import { Store } from "./store.js";
|
|
8
|
-
import { SessionManager } from "./session-manager.js";
|
|
9
|
-
import { TitleService } from "./title-service.js";
|
|
10
|
-
import { createRequestHandler } from "./routes.js";
|
|
11
|
-
import { setupWsHandler, broadcast } from "./ws-handler.js";
|
|
12
|
-
import { handleAgentEvent } from "./event-handler.js";
|
|
13
|
-
import { PushService } from "./push-service.js";
|
|
14
|
-
const config = loadConfig();
|
|
15
|
-
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
16
|
-
const PUBLIC_DIR = join(__dirname, "..", config.public_dir);
|
|
17
|
-
// --- Core dependencies ---
|
|
18
|
-
const store = new Store(config.data_dir);
|
|
19
|
-
console.log(`[store] using ${config.data_dir}/`);
|
|
20
|
-
const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
|
|
21
|
-
const titleService = new TitleService(store, sessions, config.default_cwd);
|
|
22
|
-
const pushService = new PushService(store, config.data_dir, config.push.vapid_subject);
|
|
23
|
-
console.log(`[push] VAPID public key ready`);
|
|
24
|
-
let bridge = null;
|
|
25
|
-
// --- HTTP + WebSocket servers ---
|
|
26
|
-
const server = createServer(createRequestHandler(store, PUBLIC_DIR, config.data_dir, config.limits, pushService));
|
|
27
|
-
const wss = new WebSocketServer({ server });
|
|
28
|
-
setupWsHandler({
|
|
29
|
-
wss,
|
|
30
|
-
store,
|
|
31
|
-
sessions,
|
|
32
|
-
titleService,
|
|
33
|
-
getBridge: () => bridge,
|
|
34
|
-
limits: config.limits,
|
|
35
|
-
pushService,
|
|
36
|
-
});
|
|
37
|
-
async function initBridge() {
|
|
38
|
-
const b = new AgentBridge(config.agent_cmd);
|
|
39
|
-
b.on("event", (event) => {
|
|
40
|
-
handleAgentEvent(event, sessions, store, wss, b, { cancelTimeout: config.limits.cancel_timeout }, pushService);
|
|
41
|
-
});
|
|
42
|
-
await b.start();
|
|
43
|
-
bridge = b;
|
|
44
|
-
return b;
|
|
45
|
-
}
|
|
46
|
-
// --- Graceful shutdown ---
|
|
47
|
-
async function shutdown() {
|
|
48
|
-
console.log("\n[server] shutting down...");
|
|
49
|
-
sessions.killAllBashProcs();
|
|
50
|
-
wss.close();
|
|
51
|
-
await bridge?.shutdown();
|
|
52
|
-
store.close();
|
|
53
|
-
server.close();
|
|
54
|
-
process.exit(0);
|
|
55
|
-
}
|
|
56
|
-
process.on("SIGINT", shutdown);
|
|
57
|
-
process.on("SIGTERM", shutdown);
|
|
58
|
-
// --- Start ---
|
|
59
|
-
server.listen(config.port, "0.0.0.0", async () => {
|
|
60
|
-
console.log(`[server] listening on http://localhost:${config.port}`);
|
|
61
|
-
console.log(`[bridge] starting: ${config.agent_cmd}...`);
|
|
62
|
-
try {
|
|
63
|
-
await initBridge();
|
|
64
|
-
console.log(`[bridge] ready`);
|
|
65
|
-
sessions.hydrate();
|
|
66
|
-
}
|
|
67
|
-
catch (err) {
|
|
68
|
-
console.error(`[bridge] failed to start:`, err);
|
|
69
|
-
}
|
|
70
|
-
});
|
package/lib/session-manager.js
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import { rm } from "node:fs/promises";
|
|
2
|
-
import { stat } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
/** Known config option IDs that we persist per-session. */
|
|
5
|
-
const PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
|
|
6
|
-
/**
|
|
7
|
-
* Centralizes all session-related state that was previously scattered
|
|
8
|
-
* across module-level variables in server.ts.
|
|
9
|
-
*/
|
|
10
|
-
export class SessionManager {
|
|
11
|
-
liveSessions = new Set();
|
|
12
|
-
restoringSessions = new Set();
|
|
13
|
-
sessionHasTitle = new Set();
|
|
14
|
-
assistantBuffers = new Map();
|
|
15
|
-
thinkingBuffers = new Map();
|
|
16
|
-
activePrompts = new Set();
|
|
17
|
-
runningBashProcs = new Map();
|
|
18
|
-
cachedConfigOptions = [];
|
|
19
|
-
store;
|
|
20
|
-
defaultCwd;
|
|
21
|
-
dataDir;
|
|
22
|
-
constructor(store, defaultCwd, dataDir) {
|
|
23
|
-
this.store = store;
|
|
24
|
-
this.defaultCwd = defaultCwd;
|
|
25
|
-
this.dataDir = dataDir;
|
|
26
|
-
}
|
|
27
|
-
/** Populate sessionHasTitle from existing DB sessions on startup. */
|
|
28
|
-
hydrate() {
|
|
29
|
-
for (const s of this.store.listSessions()) {
|
|
30
|
-
if (s.title)
|
|
31
|
-
this.sessionHasTitle.add(s.id);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
/** Create a new session in both bridge and store, inheriting the source session's config. */
|
|
35
|
-
async createSession(bridge, cwd, inheritFromSessionId) {
|
|
36
|
-
const sessionCwd = cwd ?? this.defaultCwd;
|
|
37
|
-
try {
|
|
38
|
-
const info = await stat(sessionCwd);
|
|
39
|
-
if (!info.isDirectory())
|
|
40
|
-
throw new Error("not a directory");
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
throw new Error(`Directory does not exist: ${sessionCwd}`);
|
|
44
|
-
}
|
|
45
|
-
const sourceSession = inheritFromSessionId
|
|
46
|
-
? this.store.getSession(inheritFromSessionId)
|
|
47
|
-
: null;
|
|
48
|
-
const sessionId = await bridge.newSession(sessionCwd);
|
|
49
|
-
this.liveSessions.add(sessionId);
|
|
50
|
-
this.store.createSession(sessionId, sessionCwd);
|
|
51
|
-
// Inherit config options from source session
|
|
52
|
-
if (sourceSession) {
|
|
53
|
-
const inherited = [
|
|
54
|
-
{ configId: "model", value: sourceSession.model },
|
|
55
|
-
{ configId: "reasoning_effort", value: sourceSession.reasoning_effort },
|
|
56
|
-
];
|
|
57
|
-
for (const { configId, value } of inherited) {
|
|
58
|
-
if (!value)
|
|
59
|
-
continue;
|
|
60
|
-
try {
|
|
61
|
-
await bridge.setConfigOption(sessionId, configId, value);
|
|
62
|
-
this.store.updateSessionConfig(sessionId, configId, value);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
// Option may no longer be available; ignore
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
const session = this.store.getSession(sessionId);
|
|
70
|
-
return {
|
|
71
|
-
sessionId,
|
|
72
|
-
configOptions: session ? this.buildConfigOptions(session) : [],
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
/** Resume a session — returns event to send to the requesting client. */
|
|
76
|
-
async resumeSession(bridge, sessionId) {
|
|
77
|
-
const session = this.store.getSession(sessionId);
|
|
78
|
-
if (!session)
|
|
79
|
-
throw new Error("Session not found");
|
|
80
|
-
if (this.liveSessions.has(sessionId)) {
|
|
81
|
-
// Session already live — build configOptions with stored overrides
|
|
82
|
-
const configOptions = this.buildConfigOptions(session);
|
|
83
|
-
return {
|
|
84
|
-
type: "session_created",
|
|
85
|
-
sessionId,
|
|
86
|
-
cwd: session.cwd,
|
|
87
|
-
title: session.title,
|
|
88
|
-
configOptions,
|
|
89
|
-
busyKind: this.getBusyKind(sessionId) ?? undefined,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
// Restore via ACP
|
|
93
|
-
this.restoringSessions.add(sessionId);
|
|
94
|
-
try {
|
|
95
|
-
const restored = await bridge.loadSession(sessionId, session.cwd);
|
|
96
|
-
this.liveSessions.add(sessionId);
|
|
97
|
-
if (session.title)
|
|
98
|
-
this.sessionHasTitle.add(sessionId);
|
|
99
|
-
const configOptions = this.applyStoredConfig(restored.configOptions, session);
|
|
100
|
-
console.log(`[session] restored: ${sessionId.slice(0, 8)}…`);
|
|
101
|
-
return {
|
|
102
|
-
type: "session_created",
|
|
103
|
-
sessionId,
|
|
104
|
-
cwd: session.cwd,
|
|
105
|
-
title: session.title,
|
|
106
|
-
configOptions,
|
|
107
|
-
busyKind: this.getBusyKind(sessionId) ?? undefined,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
catch (err) {
|
|
111
|
-
console.error(`[session] restore failed:`, err);
|
|
112
|
-
throw err;
|
|
113
|
-
}
|
|
114
|
-
finally {
|
|
115
|
-
this.restoringSessions.delete(sessionId);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
/** Build configOptions from cache, overriding currentValue with stored session values. */
|
|
119
|
-
buildConfigOptions(session) {
|
|
120
|
-
return this.applyStoredConfig(this.cachedConfigOptions, session);
|
|
121
|
-
}
|
|
122
|
-
/** Override currentValue in configOptions with stored session values. */
|
|
123
|
-
applyStoredConfig(configOptions, session) {
|
|
124
|
-
if (!configOptions.length)
|
|
125
|
-
return this.cachedConfigOptions;
|
|
126
|
-
const stored = {
|
|
127
|
-
model: session.model,
|
|
128
|
-
mode: session.mode,
|
|
129
|
-
reasoning_effort: session.reasoning_effort,
|
|
130
|
-
};
|
|
131
|
-
return configOptions.map((opt) => {
|
|
132
|
-
const override = stored[opt.id];
|
|
133
|
-
if (override)
|
|
134
|
-
return { ...opt, currentValue: override };
|
|
135
|
-
return opt;
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
/** Delete a session from store and clean up all state (including images). */
|
|
139
|
-
deleteSession(sessionId) {
|
|
140
|
-
this.store.deleteSession(sessionId);
|
|
141
|
-
this.liveSessions.delete(sessionId);
|
|
142
|
-
this.sessionHasTitle.delete(sessionId);
|
|
143
|
-
this.assistantBuffers.delete(sessionId);
|
|
144
|
-
this.thinkingBuffers.delete(sessionId);
|
|
145
|
-
this.activePrompts.delete(sessionId);
|
|
146
|
-
this.runningBashProcs.delete(sessionId);
|
|
147
|
-
// Remove uploaded images for this session
|
|
148
|
-
rm(join(this.dataDir, "images", sessionId), { recursive: true, force: true }).catch(() => { });
|
|
149
|
-
}
|
|
150
|
-
/** Flush assistant/thinking buffers to store. */
|
|
151
|
-
flushBuffers(sessionId) {
|
|
152
|
-
this.flushAssistantBuffer(sessionId);
|
|
153
|
-
this.flushThinkingBuffer(sessionId);
|
|
154
|
-
}
|
|
155
|
-
/** Flush only the assistant message buffer to store. */
|
|
156
|
-
flushAssistantBuffer(sessionId) {
|
|
157
|
-
const assistant = this.assistantBuffers.get(sessionId);
|
|
158
|
-
if (assistant) {
|
|
159
|
-
this.store.saveEvent(sessionId, "assistant_message", { text: assistant });
|
|
160
|
-
this.assistantBuffers.delete(sessionId);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
/** Flush only the thinking buffer to store. */
|
|
164
|
-
flushThinkingBuffer(sessionId) {
|
|
165
|
-
const thinking = this.thinkingBuffers.get(sessionId);
|
|
166
|
-
if (thinking) {
|
|
167
|
-
this.store.saveEvent(sessionId, "thinking", { text: thinking });
|
|
168
|
-
this.thinkingBuffers.delete(sessionId);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
/** Append to assistant message buffer. */
|
|
172
|
-
appendAssistant(sessionId, text) {
|
|
173
|
-
const buf = (this.assistantBuffers.get(sessionId) ?? "") + text;
|
|
174
|
-
this.assistantBuffers.set(sessionId, buf);
|
|
175
|
-
}
|
|
176
|
-
/** Append to thinking buffer. */
|
|
177
|
-
appendThinking(sessionId, text) {
|
|
178
|
-
const buf = (this.thinkingBuffers.get(sessionId) ?? "") + text;
|
|
179
|
-
this.thinkingBuffers.set(sessionId, buf);
|
|
180
|
-
}
|
|
181
|
-
/** Get CWD for a session (falls back to default). */
|
|
182
|
-
getSessionCwd(sessionId) {
|
|
183
|
-
return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
|
|
184
|
-
}
|
|
185
|
-
getBusyKind(sessionId) {
|
|
186
|
-
if (this.runningBashProcs.has(sessionId))
|
|
187
|
-
return "bash";
|
|
188
|
-
if (this.activePrompts.has(sessionId))
|
|
189
|
-
return "agent";
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
/** Kill all running bash processes (for shutdown). */
|
|
193
|
-
killAllBashProcs() {
|
|
194
|
-
const forceSignal = process.platform === "win32" ? undefined : "SIGKILL";
|
|
195
|
-
for (const [, proc] of this.runningBashProcs)
|
|
196
|
-
proc.kill(forceSignal);
|
|
197
|
-
this.runningBashProcs.clear();
|
|
198
|
-
}
|
|
199
|
-
}
|