@lelouchhe/webagent 0.1.0 → 0.1.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 +3 -0
- package/bin/webagent.mjs +2 -2
- package/dist/index.html +2 -2
- package/dist/js/app.mmjrsqet.js +10 -0
- package/dist/js/{commands.mmjqzu9r.js → commands.mmjrsqet.js} +3 -3
- package/dist/js/{connection.mmjqzu9r.js → connection.mmjrsqet.js} +3 -3
- package/dist/js/{events.mmjqzu9r.js → events.mmjrsqet.js} +2 -2
- package/dist/js/{images.mmjqzu9r.js → images.mmjrsqet.js} +1 -1
- package/dist/js/{input.mmjqzu9r.js → input.mmjrsqet.js} +4 -4
- package/dist/js/{render.mmjqzu9r.js → render.mmjrsqet.js} +1 -1
- package/lib/bridge.js +284 -0
- package/lib/config.js +57 -0
- package/lib/routes.js +137 -0
- package/lib/server.js +144 -0
- package/lib/session-manager.js +198 -0
- package/lib/store.js +101 -0
- package/lib/title-service.js +71 -0
- package/lib/types.js +47 -0
- package/lib/ws-handler.js +254 -0
- package/package.json +5 -4
- package/dist/js/app.mmjqzu9r.js +0 -10
- package/src/bridge.ts +0 -317
- package/src/config.ts +0 -65
- package/src/routes.ts +0 -147
- package/src/server.ts +0 -159
- package/src/session-manager.ts +0 -223
- package/src/store.ts +0 -140
- package/src/title-service.ts +0 -81
- package/src/types.ts +0 -81
- package/src/ws-handler.ts +0 -264
- /package/dist/js/{state.mmjqzu9r.js → state.mmjrsqet.js} +0 -0
- /package/dist/{styles.mmjqzu9r.css → styles.mmjrsqet.css} +0 -0
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# WebAgent
|
|
2
2
|
|
|
3
|
+
[](https://github.com/LelouchHe/webagent/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@lelouchhe/webagent)
|
|
5
|
+
|
|
3
6
|
A terminal-style web UI for ACP-compatible agents.
|
|
4
7
|
|
|
5
8
|
Tech stack: Node.js + TypeScript (`--experimental-strip-types`), real-time WebSocket communication (`ws`), SQLite persistence (`better-sqlite3`), Zod validation.
|
package/bin/webagent.mjs
CHANGED
|
@@ -5,11 +5,11 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const server = join(__dirname, "..", "
|
|
8
|
+
const server = join(__dirname, "..", "lib", "server.js");
|
|
9
9
|
|
|
10
10
|
const child = spawn(
|
|
11
11
|
process.execPath,
|
|
12
|
-
[
|
|
12
|
+
[server, ...process.argv.slice(2)],
|
|
13
13
|
{ stdio: "inherit" },
|
|
14
14
|
);
|
|
15
15
|
|
package/dist/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
14
14
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.3.2/dist/purify.min.js"></script>
|
|
15
15
|
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('theme') || 'auto');</script>
|
|
16
|
-
<link rel="stylesheet" href="/styles.
|
|
16
|
+
<link rel="stylesheet" href="/styles.mmjrsqet.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
19
|
|
|
@@ -41,6 +41,6 @@
|
|
|
41
41
|
<input type="file" id="file-input" accept="image/*" multiple hidden>
|
|
42
42
|
</div>
|
|
43
43
|
|
|
44
|
-
<script type="module" src="/js/app.
|
|
44
|
+
<script type="module" src="/js/app.mmjrsqet.js"></script>
|
|
45
45
|
</body>
|
|
46
46
|
</html>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Boot entry point — imports all modules and starts the app
|
|
2
|
+
|
|
3
|
+
import './render.mmjrsqet.js'; // theme, click-to-collapse listeners
|
|
4
|
+
import './commands.mmjrsqet.js'; // slash menu listeners
|
|
5
|
+
import './images.mmjrsqet.js'; // attach/paste listeners
|
|
6
|
+
import './input.mmjrsqet.js'; // keyboard/send listeners
|
|
7
|
+
import { connect } from './connection.mmjrsqet.js';
|
|
8
|
+
|
|
9
|
+
connect();
|
|
10
|
+
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
|
|
@@ -4,9 +4,9 @@ import {
|
|
|
4
4
|
state, dom, setBusy, resetSessionUI, requestNewSession, sendCancel,
|
|
5
5
|
getConfigOption, getConfigValue, setHashSessionId, updateSessionInfo,
|
|
6
6
|
updateNewBtnVisibility,
|
|
7
|
-
} from './state.
|
|
8
|
-
import { addSystem, addMessage, scrollToBottom, escHtml, formatLocalTime } from './render.
|
|
9
|
-
import { loadHistory } from './events.
|
|
7
|
+
} from './state.mmjrsqet.js';
|
|
8
|
+
import { addSystem, addMessage, scrollToBottom, escHtml, formatLocalTime } from './render.mmjrsqet.js';
|
|
9
|
+
import { loadHistory } from './events.mmjrsqet.js';
|
|
10
10
|
|
|
11
11
|
// --- Slash command execution ---
|
|
12
12
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// WebSocket connection lifecycle
|
|
2
2
|
|
|
3
|
-
import { state, setBusy, getHashSessionId, requestNewSession, resetSessionUI, setConnectionStatus, clearCancelTimer } from './state.
|
|
4
|
-
import { addSystem, finishThinking, finishAssistant, finishBash, scrollToBottom } from './render.
|
|
5
|
-
import { handleEvent, loadHistory, loadNewEvents } from './events.
|
|
3
|
+
import { state, setBusy, getHashSessionId, requestNewSession, resetSessionUI, setConnectionStatus, clearCancelTimer } from './state.mmjrsqet.js';
|
|
4
|
+
import { addSystem, finishThinking, finishAssistant, finishBash, scrollToBottom } from './render.mmjrsqet.js';
|
|
5
|
+
import { handleEvent, loadHistory, loadNewEvents } from './events.mmjrsqet.js';
|
|
6
6
|
|
|
7
7
|
export function connect() {
|
|
8
8
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
@@ -4,12 +4,12 @@ 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.mmjrsqet.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.mmjrsqet.js';
|
|
13
13
|
|
|
14
14
|
function finishPromptIfIdle() {
|
|
15
15
|
if (!state.pendingPromptDone) return;
|
|
@@ -3,10 +3,10 @@
|
|
|
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.mmjrsqet.js';
|
|
7
|
+
import { addMessage, addSystem, addBashBlock, showWaiting } from './render.mmjrsqet.js';
|
|
8
|
+
import { handleSlashCommand, hideSlashMenu, handleSlashMenuKey, updateSlashMenu } from './commands.mmjrsqet.js';
|
|
9
|
+
import { renderAttachPreview } from './images.mmjrsqet.js';
|
|
10
10
|
|
|
11
11
|
// Wire up cancel-timeout feedback (state.js cannot import render.js directly)
|
|
12
12
|
state._onCancelTimeout = () => addSystem('warn: Agent not responding to cancel');
|
package/lib/bridge.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
export class AgentBridge extends EventEmitter {
|
|
6
|
+
proc = null;
|
|
7
|
+
conn = null;
|
|
8
|
+
permissionResolvers = new Map();
|
|
9
|
+
permissionRequestSessions = new Map();
|
|
10
|
+
silentSessions = new Set(); // Sessions that don't emit events
|
|
11
|
+
silentBuffers = new Map(); // Text buffers for silent sessions
|
|
12
|
+
agentCmd;
|
|
13
|
+
constructor(agentCmd) {
|
|
14
|
+
super();
|
|
15
|
+
this.agentCmd = agentCmd;
|
|
16
|
+
}
|
|
17
|
+
async start() {
|
|
18
|
+
const [cmd, ...args] = this.agentCmd.split(/\s+/);
|
|
19
|
+
this.proc = spawn(cmd, args, {
|
|
20
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
21
|
+
});
|
|
22
|
+
if (!this.proc.stdin || !this.proc.stdout) {
|
|
23
|
+
throw new Error(`Failed to start: ${this.agentCmd}`);
|
|
24
|
+
}
|
|
25
|
+
const input = Writable.toWeb(this.proc.stdin);
|
|
26
|
+
const output = Readable.toWeb(this.proc.stdout);
|
|
27
|
+
const stream = acp.ndJsonStream(input, output);
|
|
28
|
+
const client = {
|
|
29
|
+
requestPermission: async (params) => this.handlePermission(params),
|
|
30
|
+
sessionUpdate: async (params) => this.handleSessionUpdate(params),
|
|
31
|
+
readTextFile: async (params) => this.handleReadFile(params),
|
|
32
|
+
writeTextFile: async (params) => this.handleWriteFile(params),
|
|
33
|
+
};
|
|
34
|
+
this.conn = new acp.ClientSideConnection((_agent) => client, stream);
|
|
35
|
+
const init = await this.conn.initialize({
|
|
36
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
37
|
+
clientCapabilities: {
|
|
38
|
+
fs: { readTextFile: true, writeTextFile: true },
|
|
39
|
+
terminal: true,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const agentInfo = init.agentInfo;
|
|
43
|
+
this.emit("event", {
|
|
44
|
+
type: "connected",
|
|
45
|
+
agent: {
|
|
46
|
+
name: agentInfo?.name ?? "unknown",
|
|
47
|
+
version: agentInfo?.version ?? "?",
|
|
48
|
+
},
|
|
49
|
+
configOptions: [],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async newSession(cwd, opts) {
|
|
53
|
+
if (!this.conn)
|
|
54
|
+
throw new Error("Not connected");
|
|
55
|
+
const session = await this.conn.newSession({ cwd, mcpServers: [] });
|
|
56
|
+
if (!opts?.silent) {
|
|
57
|
+
this.emit("event", {
|
|
58
|
+
type: "session_created",
|
|
59
|
+
sessionId: session.sessionId,
|
|
60
|
+
cwd,
|
|
61
|
+
configOptions: session.configOptions ?? [],
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return session.sessionId;
|
|
65
|
+
}
|
|
66
|
+
async loadSession(sessionId, cwd) {
|
|
67
|
+
if (!this.conn)
|
|
68
|
+
throw new Error("Not connected");
|
|
69
|
+
const session = await this.conn.loadSession({ sessionId, cwd, mcpServers: [] });
|
|
70
|
+
this.emit("event", {
|
|
71
|
+
type: "session_created",
|
|
72
|
+
sessionId: session.sessionId,
|
|
73
|
+
cwd,
|
|
74
|
+
configOptions: session.configOptions ?? [],
|
|
75
|
+
});
|
|
76
|
+
return { sessionId: session.sessionId, configOptions: session.configOptions ?? [] };
|
|
77
|
+
}
|
|
78
|
+
async setConfigOption(sessionId, configId, value) {
|
|
79
|
+
if (!this.conn)
|
|
80
|
+
throw new Error("Not connected");
|
|
81
|
+
const result = await this.conn.setSessionConfigOption({ sessionId, configId, value });
|
|
82
|
+
return result.configOptions ?? [];
|
|
83
|
+
}
|
|
84
|
+
async prompt(sessionId, text, images) {
|
|
85
|
+
if (!this.conn)
|
|
86
|
+
throw new Error("Not connected");
|
|
87
|
+
try {
|
|
88
|
+
const promptParts = [];
|
|
89
|
+
if (images) {
|
|
90
|
+
for (const img of images) {
|
|
91
|
+
promptParts.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
promptParts.push({ type: "text", text });
|
|
95
|
+
const result = await this.conn.prompt({
|
|
96
|
+
sessionId,
|
|
97
|
+
prompt: promptParts,
|
|
98
|
+
});
|
|
99
|
+
this.emit("event", {
|
|
100
|
+
type: "prompt_done",
|
|
101
|
+
sessionId,
|
|
102
|
+
stopReason: result.stopReason ?? "end_turn",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
const message = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
|
|
107
|
+
if (/cancel/i.test(message)) {
|
|
108
|
+
this.emit("event", {
|
|
109
|
+
type: "prompt_done",
|
|
110
|
+
sessionId,
|
|
111
|
+
stopReason: "cancelled",
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.emit("event", { type: "error", sessionId, message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async cancel(sessionId) {
|
|
119
|
+
for (const [requestId, requestSessionId] of this.permissionRequestSessions) {
|
|
120
|
+
if (requestSessionId === sessionId) {
|
|
121
|
+
this.denyPermission(requestId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await this.conn?.cancel({ sessionId });
|
|
125
|
+
}
|
|
126
|
+
/** Send a prompt and collect the full text response without emitting events. */
|
|
127
|
+
async promptForText(sessionId, text) {
|
|
128
|
+
if (!this.conn)
|
|
129
|
+
throw new Error("Not connected");
|
|
130
|
+
this.silentSessions.add(sessionId);
|
|
131
|
+
this.silentBuffers.set(sessionId, "");
|
|
132
|
+
try {
|
|
133
|
+
await this.conn.prompt({ sessionId, prompt: [{ type: "text", text }] });
|
|
134
|
+
return this.silentBuffers.get(sessionId) ?? "";
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
const message = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
|
|
138
|
+
if (/cancel/i.test(message)) {
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
this.silentSessions.delete(sessionId);
|
|
145
|
+
this.silentBuffers.delete(sessionId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
resolvePermission(requestId, optionId) {
|
|
149
|
+
const resolve = this.permissionResolvers.get(requestId);
|
|
150
|
+
if (resolve) {
|
|
151
|
+
resolve({ outcome: { outcome: "selected", optionId } });
|
|
152
|
+
this.permissionResolvers.delete(requestId);
|
|
153
|
+
this.permissionRequestSessions.delete(requestId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
denyPermission(requestId) {
|
|
157
|
+
const resolve = this.permissionResolvers.get(requestId);
|
|
158
|
+
if (resolve) {
|
|
159
|
+
resolve({ outcome: { outcome: "cancelled" } });
|
|
160
|
+
this.permissionResolvers.delete(requestId);
|
|
161
|
+
this.permissionRequestSessions.delete(requestId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async shutdown() {
|
|
165
|
+
// Reject all pending permissions
|
|
166
|
+
for (const [id, resolve] of this.permissionResolvers) {
|
|
167
|
+
resolve({ outcome: { outcome: "cancelled" } });
|
|
168
|
+
}
|
|
169
|
+
this.permissionResolvers.clear();
|
|
170
|
+
this.permissionRequestSessions.clear();
|
|
171
|
+
if (this.proc && this.proc.exitCode === null) {
|
|
172
|
+
this.proc.kill();
|
|
173
|
+
await new Promise((resolve) => {
|
|
174
|
+
const timer = setTimeout(() => {
|
|
175
|
+
this.proc?.kill("SIGKILL");
|
|
176
|
+
resolve();
|
|
177
|
+
}, 5000);
|
|
178
|
+
this.proc?.on("exit", () => {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
resolve();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
this.proc = null;
|
|
185
|
+
this.conn = null;
|
|
186
|
+
}
|
|
187
|
+
// --- ACP Client callbacks ---
|
|
188
|
+
handlePermission(params) {
|
|
189
|
+
const requestId = crypto.randomUUID();
|
|
190
|
+
const title = params.toolCall?.title ?? "Permission requested";
|
|
191
|
+
const toolCallId = params.toolCall?.toolCallId ?? null;
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
// Register resolver BEFORE emitting, so synchronous auto-approve can find it
|
|
194
|
+
this.permissionResolvers.set(requestId, resolve);
|
|
195
|
+
this.permissionRequestSessions.set(requestId, params.sessionId);
|
|
196
|
+
this.emit("event", {
|
|
197
|
+
type: "permission_request",
|
|
198
|
+
requestId,
|
|
199
|
+
sessionId: params.sessionId,
|
|
200
|
+
title,
|
|
201
|
+
toolCallId,
|
|
202
|
+
options: params.options,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
handleSessionUpdate(params) {
|
|
207
|
+
const update = params.update;
|
|
208
|
+
const sessionId = params.sessionId;
|
|
209
|
+
// Silent sessions: only buffer text, don't emit events
|
|
210
|
+
if (this.silentSessions.has(sessionId)) {
|
|
211
|
+
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") {
|
|
212
|
+
const buf = (this.silentBuffers.get(sessionId) ?? "") + update.content.text;
|
|
213
|
+
this.silentBuffers.set(sessionId, buf);
|
|
214
|
+
}
|
|
215
|
+
return Promise.resolve();
|
|
216
|
+
}
|
|
217
|
+
switch (update.sessionUpdate) {
|
|
218
|
+
case "agent_message_chunk":
|
|
219
|
+
if (update.content.type === "text") {
|
|
220
|
+
this.emit("event", {
|
|
221
|
+
type: "message_chunk",
|
|
222
|
+
sessionId,
|
|
223
|
+
text: update.content.text,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
case "agent_thought_chunk":
|
|
228
|
+
if (update.content.type === "text") {
|
|
229
|
+
this.emit("event", {
|
|
230
|
+
type: "thought_chunk",
|
|
231
|
+
sessionId,
|
|
232
|
+
text: update.content.text,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
case "tool_call":
|
|
237
|
+
this.emit("event", {
|
|
238
|
+
type: "tool_call",
|
|
239
|
+
sessionId,
|
|
240
|
+
id: update.toolCallId ?? "",
|
|
241
|
+
title: update.title ?? "",
|
|
242
|
+
kind: update.kind ?? "unknown",
|
|
243
|
+
rawInput: update.rawInput,
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
case "tool_call_update":
|
|
247
|
+
this.emit("event", {
|
|
248
|
+
type: "tool_call_update",
|
|
249
|
+
sessionId,
|
|
250
|
+
id: update.toolCallId ?? "",
|
|
251
|
+
status: update.status ?? "",
|
|
252
|
+
content: update.content ?? undefined,
|
|
253
|
+
});
|
|
254
|
+
break;
|
|
255
|
+
case "plan":
|
|
256
|
+
this.emit("event", {
|
|
257
|
+
type: "plan",
|
|
258
|
+
sessionId,
|
|
259
|
+
entries: update.entries ?? [],
|
|
260
|
+
});
|
|
261
|
+
break;
|
|
262
|
+
case "config_option_update":
|
|
263
|
+
this.emit("event", {
|
|
264
|
+
type: "config_option_update",
|
|
265
|
+
sessionId,
|
|
266
|
+
configOptions: update.configOptions ?? [],
|
|
267
|
+
});
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
return Promise.resolve();
|
|
271
|
+
}
|
|
272
|
+
async handleReadFile(params) {
|
|
273
|
+
const { readFile } = await import("node:fs/promises");
|
|
274
|
+
const content = await readFile(params.path, "utf-8");
|
|
275
|
+
return { content };
|
|
276
|
+
}
|
|
277
|
+
async handleWriteFile(params) {
|
|
278
|
+
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
279
|
+
const { dirname } = await import("node:path");
|
|
280
|
+
await mkdir(dirname(params.path), { recursive: true });
|
|
281
|
+
await writeFile(params.path, params.content);
|
|
282
|
+
return {};
|
|
283
|
+
}
|
|
284
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parse as parseTOML } from "smol-toml";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const ConfigSchema = z.object({
|
|
5
|
+
port: z.number().int().positive().default(6800),
|
|
6
|
+
data_dir: z.string().default("data"),
|
|
7
|
+
default_cwd: z.string().default(process.cwd()),
|
|
8
|
+
public_dir: z.string().default("dist"),
|
|
9
|
+
agent_cmd: z.string().default("copilot --acp"),
|
|
10
|
+
limits: z.object({
|
|
11
|
+
bash_output: z.number().int().positive().default(1_048_576), // 1 MB
|
|
12
|
+
image_upload: z.number().int().positive().default(10_485_760), // 10 MB
|
|
13
|
+
cancel_timeout: z.number().int().nonnegative().default(10_000), // 10s; 0 disables
|
|
14
|
+
}).default({
|
|
15
|
+
bash_output: 1_048_576,
|
|
16
|
+
image_upload: 10_485_760,
|
|
17
|
+
cancel_timeout: 10_000,
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
let _config = null;
|
|
21
|
+
function parseArgs() {
|
|
22
|
+
const idx = process.argv.indexOf("--config");
|
|
23
|
+
if (idx !== -1 && idx + 1 < process.argv.length) {
|
|
24
|
+
return process.argv[idx + 1];
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export function loadConfig() {
|
|
29
|
+
const configPath = parseArgs();
|
|
30
|
+
let raw = {};
|
|
31
|
+
if (configPath) {
|
|
32
|
+
try {
|
|
33
|
+
const content = readFileSync(configPath, "utf-8");
|
|
34
|
+
raw = parseTOML(content);
|
|
35
|
+
console.log(`[config] loaded: ${configPath}`);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
console.error(`[config] failed to read ${configPath}:`, err);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log("[config] no --config provided, using defaults");
|
|
44
|
+
}
|
|
45
|
+
const result = ConfigSchema.safeParse(raw);
|
|
46
|
+
if (!result.success) {
|
|
47
|
+
console.error("[config] validation error:", result.error.format());
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
_config = result.data;
|
|
51
|
+
return _config;
|
|
52
|
+
}
|
|
53
|
+
export function getConfig() {
|
|
54
|
+
if (!_config)
|
|
55
|
+
throw new Error("Config not loaded. Call loadConfig() first.");
|
|
56
|
+
return _config;
|
|
57
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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) {
|
|
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
|
+
res.writeHead(404);
|
|
93
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// --- Serve uploaded images: /data/images/... ---
|
|
97
|
+
if (url.startsWith("/data/images/")) {
|
|
98
|
+
const filePath = join(dataDir, url.slice(6)); // strip "/data/"
|
|
99
|
+
if (!filePath.startsWith(join(dataDir, "images"))) {
|
|
100
|
+
res.writeHead(403);
|
|
101
|
+
res.end("Forbidden");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const data = await readFile(filePath);
|
|
106
|
+
const ext = extname(filePath);
|
|
107
|
+
res.writeHead(200, {
|
|
108
|
+
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
109
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
110
|
+
});
|
|
111
|
+
res.end(data);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
res.writeHead(404);
|
|
115
|
+
res.end("Not found");
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// --- Static files ---
|
|
120
|
+
const filePath = join(publicDir, url === "/" ? "/index.html" : url);
|
|
121
|
+
if (!filePath.startsWith(publicDir)) {
|
|
122
|
+
res.writeHead(403);
|
|
123
|
+
res.end("Forbidden");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const data = await readFile(filePath);
|
|
128
|
+
const ext = extname(filePath);
|
|
129
|
+
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
130
|
+
res.end(data);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
res.writeHead(404);
|
|
134
|
+
res.end("Not found");
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|