@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.
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/bin/webagent.mjs +23 -0
- package/config.toml +28 -0
- package/dist/icons/icon-180.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon.svg +4 -0
- package/dist/index.html +46 -0
- package/dist/js/app.mmjqzu9r.js +10 -0
- package/dist/js/commands.mmjqzu9r.js +454 -0
- package/dist/js/connection.mmjqzu9r.js +76 -0
- package/dist/js/events.mmjqzu9r.js +612 -0
- package/dist/js/images.mmjqzu9r.js +58 -0
- package/dist/js/input.mmjqzu9r.js +196 -0
- package/dist/js/render.mmjqzu9r.js +200 -0
- package/dist/js/state.mmjqzu9r.js +176 -0
- package/dist/manifest.json +26 -0
- package/dist/styles.mmjqzu9r.css +555 -0
- package/dist/sw.js +5 -0
- package/package.json +56 -0
- package/src/bridge.ts +317 -0
- package/src/config.ts +65 -0
- package/src/routes.ts +147 -0
- package/src/server.ts +159 -0
- package/src/session-manager.ts +223 -0
- package/src/store.ts +140 -0
- package/src/title-service.ts +81 -0
- package/src/types.ts +81 -0
- package/src/ws-handler.ts +264 -0
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from "node:child_process";
|
|
2
|
+
import { Writable, Readable } from "node:stream";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
5
|
+
import type { AgentEvent, ConfigOption } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export class AgentBridge extends EventEmitter {
|
|
8
|
+
private proc: ChildProcess | null = null;
|
|
9
|
+
private conn: acp.ClientSideConnection | null = null;
|
|
10
|
+
private permissionResolvers = new Map<string, (resp: acp.RequestPermissionResponse) => void>();
|
|
11
|
+
private permissionRequestSessions = new Map<string, string>();
|
|
12
|
+
private silentSessions = new Set<string>(); // Sessions that don't emit events
|
|
13
|
+
private silentBuffers = new Map<string, string>(); // Text buffers for silent sessions
|
|
14
|
+
readonly agentCmd: string;
|
|
15
|
+
|
|
16
|
+
constructor(agentCmd: string) {
|
|
17
|
+
super();
|
|
18
|
+
this.agentCmd = agentCmd;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async start(): Promise<void> {
|
|
22
|
+
const [cmd, ...args] = this.agentCmd.split(/\s+/);
|
|
23
|
+
this.proc = spawn(cmd, args, {
|
|
24
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!this.proc.stdin || !this.proc.stdout) {
|
|
28
|
+
throw new Error(`Failed to start: ${this.agentCmd}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const input = Writable.toWeb(this.proc.stdin);
|
|
32
|
+
const output = Readable.toWeb(this.proc.stdout) as ReadableStream<Uint8Array>;
|
|
33
|
+
const stream = acp.ndJsonStream(input, output);
|
|
34
|
+
|
|
35
|
+
const client: acp.Client = {
|
|
36
|
+
requestPermission: async (params) => this.handlePermission(params),
|
|
37
|
+
sessionUpdate: async (params) => this.handleSessionUpdate(params),
|
|
38
|
+
readTextFile: async (params) => this.handleReadFile(params),
|
|
39
|
+
writeTextFile: async (params) => this.handleWriteFile(params),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
this.conn = new acp.ClientSideConnection((_agent) => client, stream);
|
|
43
|
+
|
|
44
|
+
const init = await this.conn.initialize({
|
|
45
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
46
|
+
clientCapabilities: {
|
|
47
|
+
fs: { readTextFile: true, writeTextFile: true },
|
|
48
|
+
terminal: true,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const agentInfo = init.agentInfo;
|
|
53
|
+
this.emit("event", {
|
|
54
|
+
type: "connected",
|
|
55
|
+
agent: {
|
|
56
|
+
name: agentInfo?.name ?? "unknown",
|
|
57
|
+
version: agentInfo?.version ?? "?",
|
|
58
|
+
},
|
|
59
|
+
configOptions: [],
|
|
60
|
+
} satisfies AgentEvent);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async newSession(cwd: string, opts?: { silent?: boolean }): Promise<string> {
|
|
64
|
+
if (!this.conn) throw new Error("Not connected");
|
|
65
|
+
const session = await this.conn.newSession({ cwd, mcpServers: [] });
|
|
66
|
+
if (!opts?.silent) {
|
|
67
|
+
this.emit("event", {
|
|
68
|
+
type: "session_created",
|
|
69
|
+
sessionId: session.sessionId,
|
|
70
|
+
cwd,
|
|
71
|
+
configOptions: (session as any).configOptions ?? [],
|
|
72
|
+
} satisfies AgentEvent);
|
|
73
|
+
}
|
|
74
|
+
return session.sessionId;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async loadSession(sessionId: string, cwd: string): Promise<{ sessionId: string; configOptions: ConfigOption[] }> {
|
|
78
|
+
if (!this.conn) throw new Error("Not connected");
|
|
79
|
+
const session = await this.conn.loadSession({ sessionId, cwd, mcpServers: [] });
|
|
80
|
+
this.emit("event", {
|
|
81
|
+
type: "session_created",
|
|
82
|
+
sessionId: session.sessionId,
|
|
83
|
+
cwd,
|
|
84
|
+
configOptions: (session as any).configOptions ?? [],
|
|
85
|
+
} satisfies AgentEvent);
|
|
86
|
+
return { sessionId: session.sessionId, configOptions: (session as any).configOptions ?? [] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async setConfigOption(sessionId: string, configId: string, value: string): Promise<ConfigOption[]> {
|
|
90
|
+
if (!this.conn) throw new Error("Not connected");
|
|
91
|
+
const result = await this.conn.setSessionConfigOption({ sessionId, configId, value });
|
|
92
|
+
return (result as any).configOptions ?? [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async prompt(
|
|
96
|
+
sessionId: string,
|
|
97
|
+
text: string,
|
|
98
|
+
images?: Array<{ data: string; mimeType: string }>,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
if (!this.conn) throw new Error("Not connected");
|
|
101
|
+
try {
|
|
102
|
+
const promptParts: Array<
|
|
103
|
+
| { type: "text"; text: string }
|
|
104
|
+
| { type: "image"; data: string; mimeType: string }
|
|
105
|
+
> = [];
|
|
106
|
+
if (images) {
|
|
107
|
+
for (const img of images) {
|
|
108
|
+
promptParts.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
promptParts.push({ type: "text", text });
|
|
112
|
+
const result = await this.conn.prompt({
|
|
113
|
+
sessionId,
|
|
114
|
+
prompt: promptParts,
|
|
115
|
+
});
|
|
116
|
+
this.emit("event", {
|
|
117
|
+
type: "prompt_done",
|
|
118
|
+
sessionId,
|
|
119
|
+
stopReason: result.stopReason ?? "end_turn",
|
|
120
|
+
} satisfies AgentEvent);
|
|
121
|
+
} catch (err: unknown) {
|
|
122
|
+
const message = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
|
|
123
|
+
if (/cancel/i.test(message)) {
|
|
124
|
+
this.emit("event", {
|
|
125
|
+
type: "prompt_done",
|
|
126
|
+
sessionId,
|
|
127
|
+
stopReason: "cancelled",
|
|
128
|
+
} satisfies AgentEvent);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.emit("event", { type: "error", sessionId, message } satisfies AgentEvent);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async cancel(sessionId: string): Promise<void> {
|
|
136
|
+
for (const [requestId, requestSessionId] of this.permissionRequestSessions) {
|
|
137
|
+
if (requestSessionId === sessionId) {
|
|
138
|
+
this.denyPermission(requestId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await this.conn?.cancel({ sessionId });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Send a prompt and collect the full text response without emitting events. */
|
|
145
|
+
async promptForText(sessionId: string, text: string): Promise<string> {
|
|
146
|
+
if (!this.conn) throw new Error("Not connected");
|
|
147
|
+
this.silentSessions.add(sessionId);
|
|
148
|
+
this.silentBuffers.set(sessionId, "");
|
|
149
|
+
try {
|
|
150
|
+
await this.conn.prompt({ sessionId, prompt: [{ type: "text", text }] });
|
|
151
|
+
return this.silentBuffers.get(sessionId) ?? "";
|
|
152
|
+
} catch (err: unknown) {
|
|
153
|
+
const message = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
|
|
154
|
+
if (/cancel/i.test(message)) {
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
} finally {
|
|
159
|
+
this.silentSessions.delete(sessionId);
|
|
160
|
+
this.silentBuffers.delete(sessionId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
resolvePermission(requestId: string, optionId: string): void {
|
|
165
|
+
const resolve = this.permissionResolvers.get(requestId);
|
|
166
|
+
if (resolve) {
|
|
167
|
+
resolve({ outcome: { outcome: "selected", optionId } });
|
|
168
|
+
this.permissionResolvers.delete(requestId);
|
|
169
|
+
this.permissionRequestSessions.delete(requestId);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
denyPermission(requestId: string): void {
|
|
174
|
+
const resolve = this.permissionResolvers.get(requestId);
|
|
175
|
+
if (resolve) {
|
|
176
|
+
resolve({ outcome: { outcome: "cancelled" } });
|
|
177
|
+
this.permissionResolvers.delete(requestId);
|
|
178
|
+
this.permissionRequestSessions.delete(requestId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async shutdown(): Promise<void> {
|
|
183
|
+
// Reject all pending permissions
|
|
184
|
+
for (const [id, resolve] of this.permissionResolvers) {
|
|
185
|
+
resolve({ outcome: { outcome: "cancelled" } });
|
|
186
|
+
}
|
|
187
|
+
this.permissionResolvers.clear();
|
|
188
|
+
this.permissionRequestSessions.clear();
|
|
189
|
+
|
|
190
|
+
if (this.proc && this.proc.exitCode === null) {
|
|
191
|
+
this.proc.kill();
|
|
192
|
+
await new Promise<void>((resolve) => {
|
|
193
|
+
const timer = setTimeout(() => {
|
|
194
|
+
this.proc?.kill("SIGKILL");
|
|
195
|
+
resolve();
|
|
196
|
+
}, 5000);
|
|
197
|
+
this.proc?.on("exit", () => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
resolve();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
this.proc = null;
|
|
204
|
+
this.conn = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- ACP Client callbacks ---
|
|
208
|
+
|
|
209
|
+
private handlePermission(params: acp.RequestPermissionRequest): Promise<acp.RequestPermissionResponse> {
|
|
210
|
+
const requestId = crypto.randomUUID();
|
|
211
|
+
const title = params.toolCall?.title ?? "Permission requested";
|
|
212
|
+
const toolCallId = params.toolCall?.toolCallId ?? null;
|
|
213
|
+
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
// Register resolver BEFORE emitting, so synchronous auto-approve can find it
|
|
216
|
+
this.permissionResolvers.set(requestId, resolve);
|
|
217
|
+
this.permissionRequestSessions.set(requestId, params.sessionId);
|
|
218
|
+
this.emit("event", {
|
|
219
|
+
type: "permission_request",
|
|
220
|
+
requestId,
|
|
221
|
+
sessionId: params.sessionId,
|
|
222
|
+
title,
|
|
223
|
+
toolCallId,
|
|
224
|
+
options: params.options,
|
|
225
|
+
} satisfies AgentEvent);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private handleSessionUpdate(params: acp.SessionNotification): Promise<void> {
|
|
230
|
+
const update = params.update;
|
|
231
|
+
const sessionId = params.sessionId;
|
|
232
|
+
|
|
233
|
+
// Silent sessions: only buffer text, don't emit events
|
|
234
|
+
if (this.silentSessions.has(sessionId)) {
|
|
235
|
+
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
|
236
|
+
const buf = (this.silentBuffers.get(sessionId) ?? "") + update.content.text;
|
|
237
|
+
this.silentBuffers.set(sessionId, buf);
|
|
238
|
+
}
|
|
239
|
+
return Promise.resolve();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
switch (update.sessionUpdate) {
|
|
243
|
+
case "agent_message_chunk":
|
|
244
|
+
if (update.content.type === "text") {
|
|
245
|
+
this.emit("event", {
|
|
246
|
+
type: "message_chunk",
|
|
247
|
+
sessionId,
|
|
248
|
+
text: update.content.text,
|
|
249
|
+
} satisfies AgentEvent);
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
|
|
253
|
+
case "agent_thought_chunk":
|
|
254
|
+
if (update.content.type === "text") {
|
|
255
|
+
this.emit("event", {
|
|
256
|
+
type: "thought_chunk",
|
|
257
|
+
sessionId,
|
|
258
|
+
text: update.content.text,
|
|
259
|
+
} satisfies AgentEvent);
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
|
|
263
|
+
case "tool_call":
|
|
264
|
+
this.emit("event", {
|
|
265
|
+
type: "tool_call",
|
|
266
|
+
sessionId,
|
|
267
|
+
id: update.toolCallId ?? "",
|
|
268
|
+
title: update.title ?? "",
|
|
269
|
+
kind: update.kind ?? "unknown",
|
|
270
|
+
rawInput: update.rawInput,
|
|
271
|
+
} satisfies AgentEvent);
|
|
272
|
+
break;
|
|
273
|
+
|
|
274
|
+
case "tool_call_update":
|
|
275
|
+
this.emit("event", {
|
|
276
|
+
type: "tool_call_update",
|
|
277
|
+
sessionId,
|
|
278
|
+
id: update.toolCallId ?? "",
|
|
279
|
+
status: update.status ?? "",
|
|
280
|
+
content: update.content,
|
|
281
|
+
} satisfies AgentEvent);
|
|
282
|
+
break;
|
|
283
|
+
|
|
284
|
+
case "plan":
|
|
285
|
+
this.emit("event", {
|
|
286
|
+
type: "plan",
|
|
287
|
+
sessionId,
|
|
288
|
+
entries: update.entries ?? [],
|
|
289
|
+
} satisfies AgentEvent);
|
|
290
|
+
break;
|
|
291
|
+
|
|
292
|
+
case "config_option_update":
|
|
293
|
+
this.emit("event", {
|
|
294
|
+
type: "config_option_update",
|
|
295
|
+
sessionId,
|
|
296
|
+
configOptions: (update as any).configOptions ?? [],
|
|
297
|
+
} satisfies AgentEvent);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return Promise.resolve();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private async handleReadFile(params: acp.ReadTextFileRequest): Promise<acp.ReadTextFileResponse> {
|
|
305
|
+
const { readFile } = await import("node:fs/promises");
|
|
306
|
+
const content = await readFile(params.path, "utf-8");
|
|
307
|
+
return { content };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async handleWriteFile(params: acp.WriteTextFileRequest): Promise<acp.WriteTextFileResponse> {
|
|
311
|
+
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
312
|
+
const { dirname } = await import("node:path");
|
|
313
|
+
await mkdir(dirname(params.path), { recursive: true });
|
|
314
|
+
await writeFile(params.path, params.content);
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parse as parseTOML } from "smol-toml";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const ConfigSchema = z.object({
|
|
6
|
+
port: z.number().int().positive().default(6800),
|
|
7
|
+
data_dir: z.string().default("data"),
|
|
8
|
+
default_cwd: z.string().default(process.cwd()),
|
|
9
|
+
public_dir: z.string().default("dist"),
|
|
10
|
+
agent_cmd: z.string().default("copilot --acp"),
|
|
11
|
+
|
|
12
|
+
limits: z.object({
|
|
13
|
+
bash_output: z.number().int().positive().default(1_048_576), // 1 MB
|
|
14
|
+
image_upload: z.number().int().positive().default(10_485_760), // 10 MB
|
|
15
|
+
cancel_timeout: z.number().int().nonnegative().default(10_000), // 10s; 0 disables
|
|
16
|
+
}).default({
|
|
17
|
+
bash_output: 1_048_576,
|
|
18
|
+
image_upload: 10_485_760,
|
|
19
|
+
cancel_timeout: 10_000,
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
24
|
+
|
|
25
|
+
let _config: Config | null = null;
|
|
26
|
+
|
|
27
|
+
function parseArgs(): string | null {
|
|
28
|
+
const idx = process.argv.indexOf("--config");
|
|
29
|
+
if (idx !== -1 && idx + 1 < process.argv.length) {
|
|
30
|
+
return process.argv[idx + 1];
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadConfig(): Config {
|
|
36
|
+
const configPath = parseArgs();
|
|
37
|
+
let raw: Record<string, unknown> = {};
|
|
38
|
+
|
|
39
|
+
if (configPath) {
|
|
40
|
+
try {
|
|
41
|
+
const content = readFileSync(configPath, "utf-8");
|
|
42
|
+
raw = parseTOML(content) as Record<string, unknown>;
|
|
43
|
+
console.log(`[config] loaded: ${configPath}`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[config] failed to read ${configPath}:`, err);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
console.log("[config] no --config provided, using defaults");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = ConfigSchema.safeParse(raw);
|
|
53
|
+
if (!result.success) {
|
|
54
|
+
console.error("[config] validation error:", result.error.format());
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_config = result.data;
|
|
59
|
+
return _config;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getConfig(): Config {
|
|
63
|
+
if (!_config) throw new Error("Config not loaded. Call loadConfig() first.");
|
|
64
|
+
return _config;
|
|
65
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join, extname } from "node:path";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import type { Store } from "./store.ts";
|
|
5
|
+
import type { Config } from "./config.ts";
|
|
6
|
+
|
|
7
|
+
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
8
|
+
|
|
9
|
+
const MIME: Record<string, string> = {
|
|
10
|
+
".html": "text/html",
|
|
11
|
+
".js": "application/javascript",
|
|
12
|
+
".css": "text/css",
|
|
13
|
+
".json": "application/json",
|
|
14
|
+
".svg": "image/svg+xml",
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".gif": "image/gif",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createRequestHandler(store: Store, publicDir: string, dataDir: string, limits: Config["limits"]) {
|
|
23
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
24
|
+
const url = req.url ?? "/";
|
|
25
|
+
|
|
26
|
+
// --- API routes ---
|
|
27
|
+
if (url.startsWith("/api/")) {
|
|
28
|
+
res.setHeader("Content-Type", "application/json");
|
|
29
|
+
|
|
30
|
+
// GET /api/sessions
|
|
31
|
+
if (url === "/api/sessions" && req.method === "GET") {
|
|
32
|
+
res.end(JSON.stringify(store.listSessions()));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// GET /api/sessions/:id/events?thinking=0|1
|
|
37
|
+
const eventsMatch = url.match(/^\/api\/sessions\/([^/]+)\/events(\?.*)?$/);
|
|
38
|
+
if (eventsMatch && req.method === "GET") {
|
|
39
|
+
const sessionId = decodeURIComponent(eventsMatch[1]);
|
|
40
|
+
const params = new URLSearchParams(eventsMatch[2]?.slice(1) ?? "");
|
|
41
|
+
const excludeThinking = params.get("thinking") === "0";
|
|
42
|
+
const afterSeqRaw = params.get("after_seq");
|
|
43
|
+
const afterSeq = afterSeqRaw != null ? Number(afterSeqRaw) : undefined;
|
|
44
|
+
const session = store.getSession(sessionId);
|
|
45
|
+
if (!session) {
|
|
46
|
+
res.writeHead(404);
|
|
47
|
+
res.end(JSON.stringify({ error: "Session not found" }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const events = store.getEvents(sessionId, { excludeThinking, afterSeq });
|
|
51
|
+
res.end(JSON.stringify(events));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// POST /api/images/:sessionId
|
|
56
|
+
const imgMatch = url.match(/^\/api\/images\/([^/]+)$/);
|
|
57
|
+
if (imgMatch && req.method === "POST") {
|
|
58
|
+
const sessionId = decodeURIComponent(imgMatch[1]);
|
|
59
|
+
if (!SAFE_ID.test(sessionId)) {
|
|
60
|
+
res.writeHead(400);
|
|
61
|
+
res.end(JSON.stringify({ error: "Invalid session ID" }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Enforce upload size limit
|
|
65
|
+
const contentLength = parseInt(req.headers["content-length"] ?? "0", 10);
|
|
66
|
+
if (contentLength > limits.image_upload) {
|
|
67
|
+
res.writeHead(413);
|
|
68
|
+
res.end(JSON.stringify({ error: "Upload too large" }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const chunks: Buffer[] = [];
|
|
72
|
+
let totalSize = 0;
|
|
73
|
+
for await (const chunk of req) {
|
|
74
|
+
totalSize += (chunk as Buffer).length;
|
|
75
|
+
if (totalSize > limits.image_upload) {
|
|
76
|
+
res.writeHead(413);
|
|
77
|
+
res.end(JSON.stringify({ error: "Upload too large" }));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
chunks.push(chunk as Buffer);
|
|
81
|
+
}
|
|
82
|
+
let body: { data: string; mimeType: string };
|
|
83
|
+
try {
|
|
84
|
+
body = JSON.parse(Buffer.concat(chunks).toString());
|
|
85
|
+
} catch {
|
|
86
|
+
res.writeHead(400);
|
|
87
|
+
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const { data, mimeType } = body;
|
|
91
|
+
const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
|
|
92
|
+
const seq = Date.now();
|
|
93
|
+
const relPath = `images/${sessionId}/${seq}.${ext}`;
|
|
94
|
+
const absPath = join(dataDir, relPath);
|
|
95
|
+
await mkdir(join(dataDir, "images", sessionId), { recursive: true });
|
|
96
|
+
await writeFile(absPath, Buffer.from(data, "base64"));
|
|
97
|
+
const imgUrl = `/data/${relPath}`;
|
|
98
|
+
res.end(JSON.stringify({ path: relPath, url: imgUrl }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
res.writeHead(404);
|
|
103
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Serve uploaded images: /data/images/... ---
|
|
108
|
+
if (url.startsWith("/data/images/")) {
|
|
109
|
+
const filePath = join(dataDir, url.slice(6)); // strip "/data/"
|
|
110
|
+
if (!filePath.startsWith(join(dataDir, "images"))) {
|
|
111
|
+
res.writeHead(403);
|
|
112
|
+
res.end("Forbidden");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const data = await readFile(filePath);
|
|
117
|
+
const ext = extname(filePath);
|
|
118
|
+
res.writeHead(200, {
|
|
119
|
+
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
120
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
121
|
+
});
|
|
122
|
+
res.end(data);
|
|
123
|
+
} catch {
|
|
124
|
+
res.writeHead(404);
|
|
125
|
+
res.end("Not found");
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Static files ---
|
|
131
|
+
const filePath = join(publicDir, url === "/" ? "/index.html" : url);
|
|
132
|
+
if (!filePath.startsWith(publicDir)) {
|
|
133
|
+
res.writeHead(403);
|
|
134
|
+
res.end("Forbidden");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const data = await readFile(filePath);
|
|
139
|
+
const ext = extname(filePath);
|
|
140
|
+
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
141
|
+
res.end(data);
|
|
142
|
+
} catch {
|
|
143
|
+
res.writeHead(404);
|
|
144
|
+
res.end("Not found");
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
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.ts";
|
|
6
|
+
import { AgentBridge } from "./bridge.ts";
|
|
7
|
+
import { Store } from "./store.ts";
|
|
8
|
+
import { SessionManager } from "./session-manager.ts";
|
|
9
|
+
import { TitleService } from "./title-service.ts";
|
|
10
|
+
import { createRequestHandler } from "./routes.ts";
|
|
11
|
+
import { setupWsHandler, broadcast } from "./ws-handler.ts";
|
|
12
|
+
import type { AgentEvent } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
const config = loadConfig();
|
|
15
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
16
|
+
const PUBLIC_DIR = join(__dirname, "..", config.public_dir);
|
|
17
|
+
|
|
18
|
+
// --- Core dependencies ---
|
|
19
|
+
|
|
20
|
+
const store = new Store(config.data_dir);
|
|
21
|
+
console.log(`[store] using ${config.data_dir}/`);
|
|
22
|
+
|
|
23
|
+
const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
|
|
24
|
+
const titleService = new TitleService(store, sessions, config.default_cwd);
|
|
25
|
+
|
|
26
|
+
let bridge: AgentBridge | null = null;
|
|
27
|
+
|
|
28
|
+
// --- HTTP + WebSocket servers ---
|
|
29
|
+
|
|
30
|
+
const server = createServer(createRequestHandler(store, PUBLIC_DIR, config.data_dir, config.limits));
|
|
31
|
+
const wss = new WebSocketServer({ server });
|
|
32
|
+
|
|
33
|
+
setupWsHandler({
|
|
34
|
+
wss,
|
|
35
|
+
store,
|
|
36
|
+
sessions,
|
|
37
|
+
titleService,
|
|
38
|
+
getBridge: () => bridge,
|
|
39
|
+
limits: config.limits,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// --- Bridge initialization ---
|
|
43
|
+
|
|
44
|
+
async function initBridge(): Promise<AgentBridge> {
|
|
45
|
+
const b = new AgentBridge(config.agent_cmd);
|
|
46
|
+
|
|
47
|
+
b.on("event", (event: AgentEvent) => {
|
|
48
|
+
if (sessions.restoringSessions.has(event.sessionId)) return;
|
|
49
|
+
|
|
50
|
+
switch (event.type) {
|
|
51
|
+
case "connected":
|
|
52
|
+
event.cancelTimeout = config.limits.cancel_timeout;
|
|
53
|
+
break;
|
|
54
|
+
case "session_created":
|
|
55
|
+
if (event.configOptions?.length) sessions.cachedConfigOptions = event.configOptions;
|
|
56
|
+
for (const opt of event.configOptions ?? []) {
|
|
57
|
+
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
case "config_option_update":
|
|
61
|
+
if (event.configOptions?.length) sessions.cachedConfigOptions = event.configOptions;
|
|
62
|
+
for (const opt of event.configOptions ?? []) {
|
|
63
|
+
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
case "message_chunk":
|
|
67
|
+
sessions.flushThinkingBuffer(event.sessionId);
|
|
68
|
+
sessions.appendAssistant(event.sessionId, event.text);
|
|
69
|
+
break;
|
|
70
|
+
case "thought_chunk":
|
|
71
|
+
sessions.flushAssistantBuffer(event.sessionId);
|
|
72
|
+
sessions.appendThinking(event.sessionId, event.text);
|
|
73
|
+
break;
|
|
74
|
+
case "tool_call":
|
|
75
|
+
sessions.flushBuffers(event.sessionId);
|
|
76
|
+
store.saveEvent(event.sessionId, event.type, { id: event.id, title: event.title, kind: event.kind, rawInput: event.rawInput });
|
|
77
|
+
break;
|
|
78
|
+
case "tool_call_update":
|
|
79
|
+
store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content });
|
|
80
|
+
break;
|
|
81
|
+
case "plan":
|
|
82
|
+
sessions.flushBuffers(event.sessionId);
|
|
83
|
+
store.saveEvent(event.sessionId, event.type, { entries: event.entries });
|
|
84
|
+
break;
|
|
85
|
+
case "permission_request": {
|
|
86
|
+
sessions.flushBuffers(event.sessionId);
|
|
87
|
+
store.saveEvent(event.sessionId, event.type, {
|
|
88
|
+
requestId: event.requestId, title: event.title, options: event.options,
|
|
89
|
+
});
|
|
90
|
+
// Auto-approve permissions in autopilot mode (allow_once only to avoid persisting across mode switches)
|
|
91
|
+
const mode = store.getSession(event.sessionId)?.mode ?? "";
|
|
92
|
+
if (mode.includes("#autopilot")) {
|
|
93
|
+
const opt = event.options.find((o: any) => o.kind === "allow_once");
|
|
94
|
+
if (opt) {
|
|
95
|
+
b.resolvePermission(event.requestId, opt.optionId);
|
|
96
|
+
const optionName = opt.label ?? opt.optionId;
|
|
97
|
+
store.saveEvent(event.sessionId, "permission_response", {
|
|
98
|
+
requestId: event.requestId, optionName, denied: false,
|
|
99
|
+
});
|
|
100
|
+
// Skip broadcasting the permission_request — send resolved directly
|
|
101
|
+
broadcast(wss, {
|
|
102
|
+
type: "permission_resolved",
|
|
103
|
+
sessionId: event.sessionId,
|
|
104
|
+
requestId: event.requestId,
|
|
105
|
+
optionName,
|
|
106
|
+
denied: false,
|
|
107
|
+
} as any);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case "prompt_done":
|
|
114
|
+
sessions.activePrompts.delete(event.sessionId);
|
|
115
|
+
sessions.flushBuffers(event.sessionId);
|
|
116
|
+
store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason });
|
|
117
|
+
break;
|
|
118
|
+
case "error":
|
|
119
|
+
if (event.sessionId) {
|
|
120
|
+
sessions.activePrompts.delete(event.sessionId);
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
broadcast(wss, event);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await b.start();
|
|
128
|
+
bridge = b;
|
|
129
|
+
return b;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Graceful shutdown ---
|
|
133
|
+
|
|
134
|
+
async function shutdown() {
|
|
135
|
+
console.log("\n[server] shutting down...");
|
|
136
|
+
sessions.killAllBashProcs();
|
|
137
|
+
wss.close();
|
|
138
|
+
await bridge?.shutdown();
|
|
139
|
+
store.close();
|
|
140
|
+
server.close();
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
process.on("SIGINT", shutdown);
|
|
145
|
+
process.on("SIGTERM", shutdown);
|
|
146
|
+
|
|
147
|
+
// --- Start ---
|
|
148
|
+
|
|
149
|
+
server.listen(config.port, "0.0.0.0", async () => {
|
|
150
|
+
console.log(`[server] listening on http://localhost:${config.port}`);
|
|
151
|
+
console.log(`[bridge] starting: ${config.agent_cmd}...`);
|
|
152
|
+
try {
|
|
153
|
+
await initBridge();
|
|
154
|
+
console.log(`[bridge] ready`);
|
|
155
|
+
sessions.hydrate();
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error(`[bridge] failed to start:`, err);
|
|
158
|
+
}
|
|
159
|
+
});
|