@lelouchhe/webagent 0.1.0

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.
@@ -0,0 +1,264 @@
1
+ import { spawn } from "node:child_process";
2
+ import { WebSocket, WebSocketServer } from "ws";
3
+ import { WsMessageSchema, errorMessage } from "./types.ts";
4
+ import type { AgentEvent } from "./types.ts";
5
+ import type { AgentBridge } from "./bridge.ts";
6
+ import type { Store } from "./store.ts";
7
+ import type { SessionManager } from "./session-manager.ts";
8
+ import type { TitleService } from "./title-service.ts";
9
+ import type { Config } from "./config.ts";
10
+
11
+ interface WsHandlerDeps {
12
+ wss: WebSocketServer;
13
+ store: Store;
14
+ sessions: SessionManager;
15
+ titleService: TitleService;
16
+ getBridge: () => AgentBridge | null;
17
+ limits: Config["limits"];
18
+ }
19
+
20
+ function interruptBashProc(proc: ReturnType<SessionManager["runningBashProcs"]["get"]>): void {
21
+ if (!proc) return;
22
+ if (typeof proc.pid === "number") {
23
+ try {
24
+ process.kill(-proc.pid, "SIGINT");
25
+ return;
26
+ } catch {
27
+ // Fall through to direct child kill when the process is not a group leader.
28
+ }
29
+ }
30
+ proc.kill("SIGINT");
31
+ }
32
+
33
+ export function broadcast(wss: WebSocketServer, event: AgentEvent, exclude?: WebSocket): void {
34
+ const msg = JSON.stringify(event);
35
+ for (const client of wss.clients) {
36
+ if (client.readyState === WebSocket.OPEN && client !== exclude) {
37
+ try { client.send(msg); } catch { /* client gone mid-send */ }
38
+ }
39
+ }
40
+ }
41
+
42
+ function send(ws: WebSocket, event: AgentEvent): void {
43
+ if (ws.readyState === WebSocket.OPEN) {
44
+ try { ws.send(JSON.stringify(event)); } catch { /* client gone mid-send */ }
45
+ }
46
+ }
47
+
48
+ export function setupWsHandler(deps: WsHandlerDeps): void {
49
+ const { wss, store, sessions, titleService, getBridge, limits } = deps;
50
+
51
+ wss.on("connection", (ws) => {
52
+ console.log(`[ws] client connected (total: ${wss.clients.size})`);
53
+
54
+ const pingInterval = setInterval(() => {
55
+ if (ws.readyState === WebSocket.OPEN) ws.ping();
56
+ }, 30_000);
57
+
58
+ ws.on("message", async (raw) => {
59
+ // Parse & validate
60
+ let parsed: unknown;
61
+ try {
62
+ parsed = JSON.parse(raw.toString());
63
+ } catch {
64
+ send(ws, { type: "error", message: "Invalid JSON" });
65
+ return;
66
+ }
67
+
68
+ const result = WsMessageSchema.safeParse(parsed);
69
+ if (!result.success) {
70
+ send(ws, { type: "error", message: `Invalid message: ${result.error.message}` });
71
+ return;
72
+ }
73
+ const msg = result.data;
74
+
75
+ try {
76
+ const bridge = getBridge();
77
+
78
+ switch (msg.type) {
79
+ case "new_session": {
80
+ if (!bridge) { send(ws, { type: "error", message: "Agent not ready yet" }); return; }
81
+ const created = await sessions.createSession(bridge, msg.cwd, msg.inheritFromSessionId);
82
+ if (created.configOptions.length) {
83
+ send(ws, {
84
+ type: "config_option_update",
85
+ sessionId: created.sessionId,
86
+ configOptions: created.configOptions,
87
+ });
88
+ }
89
+ break;
90
+ }
91
+
92
+ case "resume_session": {
93
+ if (!bridge) { send(ws, { type: "error", message: "Agent not ready yet" }); return; }
94
+ try {
95
+ const event = await sessions.resumeSession(bridge, msg.sessionId);
96
+ send(ws, event);
97
+ } catch {
98
+ send(ws, { type: "session_expired", sessionId: msg.sessionId });
99
+ }
100
+ break;
101
+ }
102
+
103
+ case "delete_session": {
104
+ sessions.deleteSession(msg.sessionId);
105
+ broadcast(wss, { type: "session_deleted", sessionId: msg.sessionId });
106
+ console.log(`[session] deleted: ${msg.sessionId.slice(0, 8)}…`);
107
+ break;
108
+ }
109
+
110
+ case "prompt": {
111
+ if (!bridge) { send(ws, { type: "error", message: "No active bridge" }); return; }
112
+ const images = msg.images;
113
+ const userData = {
114
+ text: msg.text,
115
+ ...(images && { images: images.map((i) => ({ path: i.path, mimeType: i.mimeType })) }),
116
+ };
117
+ store.saveEvent(msg.sessionId, "user_message", userData);
118
+ store.updateSessionLastActive(msg.sessionId);
119
+ // Generate title once the session actually gets one; canceled/failed attempts can retry later.
120
+ if (!sessions.sessionHasTitle.has(msg.sessionId)) {
121
+ titleService.generate(bridge, msg.text, msg.sessionId, (title) => {
122
+ broadcast(wss, { type: "session_title_updated", sessionId: msg.sessionId, title });
123
+ });
124
+ }
125
+ // Broadcast to other clients
126
+ const userEvent = JSON.stringify({ type: "user_message", sessionId: msg.sessionId, ...userData });
127
+ for (const client of wss.clients) {
128
+ if (client !== ws && client.readyState === WebSocket.OPEN) {
129
+ client.send(userEvent);
130
+ }
131
+ }
132
+ sessions.activePrompts.add(msg.sessionId);
133
+ bridge.prompt(msg.sessionId, msg.text, images).catch((err: unknown) => {
134
+ send(ws, { type: "error", message: errorMessage(err) });
135
+ });
136
+ break;
137
+ }
138
+
139
+ case "permission_response": {
140
+ if (!bridge) return;
141
+ if (msg.denied) {
142
+ bridge.denyPermission(msg.requestId);
143
+ } else if (msg.optionId) {
144
+ bridge.resolvePermission(msg.requestId, msg.optionId);
145
+ }
146
+ if (msg.sessionId) {
147
+ store.saveEvent(msg.sessionId, "permission_response", {
148
+ requestId: msg.requestId,
149
+ optionName: msg.optionName || "",
150
+ denied: !!msg.denied,
151
+ });
152
+ }
153
+ broadcast(wss, {
154
+ type: "permission_resolved",
155
+ sessionId: msg.sessionId,
156
+ requestId: msg.requestId,
157
+ optionName: msg.optionName || "",
158
+ denied: !!msg.denied,
159
+ } as any);
160
+ break;
161
+ }
162
+
163
+ case "cancel": {
164
+ interruptBashProc(sessions.runningBashProcs.get(msg.sessionId));
165
+ if (bridge) {
166
+ await titleService.cancel(msg.sessionId, bridge);
167
+ }
168
+ await bridge?.cancel(msg.sessionId);
169
+ break;
170
+ }
171
+
172
+ case "set_config_option": {
173
+ if (!bridge) { send(ws, { type: "error", message: "Agent not ready yet" }); return; }
174
+ try {
175
+ const configOptions = await bridge.setConfigOption(msg.sessionId, msg.configId, msg.value);
176
+ for (const opt of configOptions) {
177
+ store.updateSessionConfig(msg.sessionId, opt.id, opt.currentValue);
178
+ }
179
+ send(ws, { type: "config_set", configId: msg.configId, value: msg.value } as any);
180
+ if (configOptions.length) {
181
+ broadcast(wss, { type: "config_option_update", sessionId: msg.sessionId, configOptions }, ws);
182
+ }
183
+ } catch (err: unknown) {
184
+ send(ws, { type: "error", message: `Failed to set ${msg.configId}: ${errorMessage(err)}` });
185
+ }
186
+ break;
187
+ }
188
+
189
+ case "bash_exec": {
190
+ if (sessions.runningBashProcs.has(msg.sessionId)) {
191
+ send(ws, { type: "error", message: "A bash command is already running in this session" });
192
+ return;
193
+ }
194
+ const cwd = sessions.getSessionCwd(msg.sessionId);
195
+ store.saveEvent(msg.sessionId, "bash_command", { command: msg.command });
196
+ // Broadcast to other clients
197
+ const bashEvent = JSON.stringify({
198
+ type: "bash_command", sessionId: msg.sessionId, command: msg.command,
199
+ });
200
+ for (const client of wss.clients) {
201
+ if (client !== ws && client.readyState === WebSocket.OPEN) {
202
+ client.send(bashEvent);
203
+ }
204
+ }
205
+
206
+ const child = spawn("bash", ["-c", msg.command], {
207
+ cwd,
208
+ detached: true,
209
+ env: { ...process.env, TERM: "dumb" },
210
+ stdio: ["ignore", "pipe", "pipe"],
211
+ });
212
+ sessions.runningBashProcs.set(msg.sessionId, child);
213
+ let output = "";
214
+ let outputTruncated = false;
215
+
216
+ const onData = (stream: string) => (chunk: Buffer) => {
217
+ const text = chunk.toString();
218
+ if (!outputTruncated) {
219
+ output += text;
220
+ if (output.length > limits.bash_output) {
221
+ output = output.slice(-limits.bash_output);
222
+ outputTruncated = true;
223
+ }
224
+ } else {
225
+ // Keep only the tail within the limit
226
+ output = (output + text).slice(-limits.bash_output);
227
+ }
228
+ broadcast(wss, { type: "bash_output", sessionId: msg.sessionId, text, stream } as any);
229
+ };
230
+ child.stdout!.on("data", onData("stdout"));
231
+ child.stderr!.on("data", onData("stderr"));
232
+
233
+ child.on("close", (code, signal) => {
234
+ sessions.runningBashProcs.delete(msg.sessionId);
235
+ const stored = outputTruncated ? "[truncated]\n" + output : output;
236
+ store.saveEvent(msg.sessionId, "bash_result", { output: stored, code, signal });
237
+ broadcast(wss, { type: "bash_done", sessionId: msg.sessionId, code, signal } as any);
238
+ });
239
+
240
+ child.on("error", (err) => {
241
+ sessions.runningBashProcs.delete(msg.sessionId);
242
+ const errMsg = errorMessage(err);
243
+ store.saveEvent(msg.sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
244
+ broadcast(wss, { type: "bash_done", sessionId: msg.sessionId, code: -1, signal: null, error: errMsg } as any);
245
+ });
246
+ break;
247
+ }
248
+
249
+ case "bash_cancel": {
250
+ interruptBashProc(sessions.runningBashProcs.get(msg.sessionId));
251
+ break;
252
+ }
253
+ }
254
+ } catch (err: unknown) {
255
+ send(ws, { type: "error", message: errorMessage(err) });
256
+ }
257
+ });
258
+
259
+ ws.on("close", () => {
260
+ clearInterval(pingInterval);
261
+ console.log(`[ws] client disconnected (total: ${wss.clients.size})`);
262
+ });
263
+ });
264
+ }