@lelouchhe/webagent 0.1.9 → 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/lib/routes.js DELETED
@@ -1,929 +0,0 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
2
- import { spawn } from "node:child_process";
3
- import { join, extname } from "node:path";
4
- import { gzipSync } from "node:zlib";
5
- import { Store } from "./store.js";
6
- import { errorMessage } from "./types.js";
7
- import { interruptBashProc } from "./session-manager.js";
8
- const IS_WIN = process.platform === "win32";
9
- const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
10
- const MIME = {
11
- ".html": "text/html; charset=utf-8",
12
- ".js": "application/javascript; charset=utf-8",
13
- ".css": "text/css; charset=utf-8",
14
- ".json": "application/json; charset=utf-8",
15
- ".svg": "image/svg+xml; charset=utf-8",
16
- ".png": "image/png",
17
- ".jpg": "image/jpeg",
18
- ".jpeg": "image/jpeg",
19
- ".gif": "image/gif",
20
- ".webp": "image/webp",
21
- };
22
- /** Read the full request body as a string. */
23
- function readBody(req) {
24
- return new Promise((resolve, reject) => {
25
- const chunks = [];
26
- req.on("data", (chunk) => chunks.push(chunk));
27
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
28
- req.on("error", reject);
29
- });
30
- }
31
- /** Send a JSON response, gzip-compressed when the client supports it. */
32
- function json(res, status, data, req) {
33
- const body = JSON.stringify(data);
34
- if (req && body.length > 1024 && (req.headers["accept-encoding"] || "").includes("gzip")) {
35
- const compressed = gzipSync(body);
36
- res.writeHead(status, {
37
- "Content-Type": "application/json",
38
- "Content-Encoding": "gzip",
39
- "Content-Length": compressed.length,
40
- });
41
- res.end(compressed);
42
- }
43
- else {
44
- res.writeHead(status, { "Content-Type": "application/json" });
45
- res.end(body);
46
- }
47
- }
48
- export function createRequestHandler(storeOrDeps, publicDir, dataDir, limits, pushService) {
49
- // Normalize to deps object (support legacy positional args)
50
- const deps = (storeOrDeps instanceof Store)
51
- ? { store: storeOrDeps, publicDir: publicDir, dataDir: dataDir, limits: limits, pushService }
52
- : storeOrDeps;
53
- const { store, sessions, getBridge, sseManager, titleService } = deps;
54
- return async (req, res) => {
55
- const url = req.url ?? "/";
56
- // --- API routes ---
57
- if (url === "/api/v1" || url.startsWith("/api/v1/")) {
58
- res.setHeader("Content-Type", "application/json");
59
- // GET /api/v1 — discovery endpoint
60
- if (url === "/api/v1" && req.method === "GET") {
61
- json(res, 200, {
62
- version: "v1",
63
- endpoints: {
64
- sessions: "/api/v1/sessions",
65
- config: "/api/v1/config",
66
- events_stream: "/api/v1/events/stream",
67
- prompt: "/api/v1/prompt",
68
- push: "/api/v1/push",
69
- clients: "/api/v1/clients",
70
- },
71
- });
72
- return;
73
- }
74
- // GET /api/v1/sessions
75
- if (url.startsWith("/api/v1/sessions") && !url.slice("/api/v1/sessions".length).match(/^\//) && req.method === "GET") {
76
- const params = new URLSearchParams(url.split("?")[1] ?? "");
77
- const source = params.get("source") ?? undefined;
78
- res.end(JSON.stringify(store.listSessions(source ? { source } : undefined)));
79
- return;
80
- }
81
- // --- GET /api/v1/config ---
82
- if (url === "/api/v1/config" && req.method === "GET") {
83
- json(res, 200, {
84
- configOptions: sessions?.cachedConfigOptions ?? [],
85
- cancelTimeout: deps.limits.cancel_timeout ?? 0,
86
- });
87
- return;
88
- }
89
- // --- Permissions (session-scoped) ---
90
- // GET /api/v1/sessions/:id/permissions
91
- const permListMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/permissions\/?(\?.*)?$/);
92
- if (permListMatch && req.method === "GET") {
93
- const sessionId = decodeURIComponent(permListMatch[1]);
94
- const perms = sessions?.getPendingPermissions(sessionId) ?? [];
95
- json(res, 200, perms);
96
- return;
97
- }
98
- // POST /api/v1/sessions/:id/permissions/:reqId
99
- const permActionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/permissions\/([^/?]+)\/?$/);
100
- if (permActionMatch && req.method === "POST") {
101
- const sessionId = decodeURIComponent(permActionMatch[1]);
102
- const requestId = decodeURIComponent(permActionMatch[2]);
103
- const perm = sessions?.pendingPermissions.get(requestId);
104
- if (!perm) {
105
- json(res, 404, { error: "Permission not found" });
106
- return;
107
- }
108
- if (perm.sessionId !== sessionId) {
109
- json(res, 400, { error: "Session ID mismatch" });
110
- return;
111
- }
112
- const bridge = getBridge?.();
113
- if (!bridge) {
114
- json(res, 503, { error: "Agent not ready yet" });
115
- return;
116
- }
117
- let body;
118
- try {
119
- body = JSON.parse(await readBody(req));
120
- }
121
- catch {
122
- json(res, 400, { error: "Invalid JSON" });
123
- return;
124
- }
125
- if (!body.optionId && !body.denied) {
126
- json(res, 400, { error: "Provide optionId or denied:true" });
127
- return;
128
- }
129
- const denied = !!body.denied;
130
- const optionId = body.optionId ?? "deny";
131
- const optionName = perm.options.find(o => o.optionId === optionId)?.label ?? optionId;
132
- if (denied) {
133
- await bridge.denyPermission(requestId);
134
- }
135
- else {
136
- await bridge.resolvePermission(requestId, optionId);
137
- }
138
- sessions.pendingPermissions.delete(requestId);
139
- // Store event and broadcast
140
- store.saveEvent(perm.sessionId, "permission_response", {
141
- requestId, optionId, optionName, denied,
142
- });
143
- const permEvent = {
144
- type: "permission_resolved",
145
- sessionId: perm.sessionId,
146
- requestId,
147
- optionName,
148
- denied,
149
- };
150
- sseManager.broadcast(permEvent);
151
- json(res, 200, { ok: true });
152
- return;
153
- }
154
- // --- POST /api/v1/sessions/:id/cancel ---
155
- const cancelMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/cancel\/?$/);
156
- if (cancelMatch && req.method === "POST") {
157
- const sessionId = decodeURIComponent(cancelMatch[1]);
158
- const session = store.getSession(sessionId);
159
- if (!session) {
160
- json(res, 404, { error: "Session not found" });
161
- return;
162
- }
163
- const bridge = getBridge?.();
164
- if (!bridge) {
165
- json(res, 503, { error: "Agent not ready yet" });
166
- return;
167
- }
168
- // Kill running bash process if any
169
- const proc = sessions?.runningBashProcs.get(sessionId);
170
- if (proc) {
171
- interruptBashProc(proc);
172
- sessions.runningBashProcs.delete(sessionId);
173
- }
174
- // Cancel agent prompt
175
- if (sessions?.activePrompts.has(sessionId)) {
176
- await bridge.cancel(sessionId);
177
- sessions.activePrompts.delete(sessionId);
178
- }
179
- json(res, 200, { ok: true });
180
- return;
181
- }
182
- // --- GET /api/v1/sessions/:id/status ---
183
- const statusMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/status\/?$/);
184
- if (statusMatch && req.method === "GET") {
185
- const sessionId = decodeURIComponent(statusMatch[1]);
186
- const session = store.getSession(sessionId);
187
- if (!session) {
188
- json(res, 404, { error: "Session not found" });
189
- return;
190
- }
191
- const busyKind = sessions?.getBusyKind(sessionId) ?? null;
192
- const pendingPerms = sessions?.getPendingPermissions(sessionId) ?? [];
193
- json(res, 200, {
194
- busy: busyKind != null,
195
- busyKind,
196
- pendingPermissions: pendingPerms,
197
- });
198
- return;
199
- }
200
- // --- POST /api/v1/sessions/:id/prompt ---
201
- const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
202
- if (promptMatch && req.method === "POST") {
203
- const sessionId = decodeURIComponent(promptMatch[1]);
204
- const session = store.getSession(sessionId);
205
- if (!session) {
206
- json(res, 404, { error: "Session not found" });
207
- return;
208
- }
209
- const bridge = getBridge?.();
210
- if (!bridge) {
211
- json(res, 503, { error: "Agent not ready yet" });
212
- return;
213
- }
214
- if (!sessions) {
215
- json(res, 503, { error: "Session manager not available" });
216
- return;
217
- }
218
- // Ensure session is live in ACP before prompting (awaits in-flight resume)
219
- try {
220
- await sessions.ensureResumed(bridge, sessionId);
221
- }
222
- catch (err) {
223
- json(res, 500, { error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}` });
224
- return;
225
- }
226
- // Check if session is busy
227
- const busyKind = sessions.getBusyKind(sessionId);
228
- if (busyKind) {
229
- json(res, 409, { error: "Session is busy", busyKind });
230
- return;
231
- }
232
- let body;
233
- try {
234
- body = JSON.parse(await readBody(req));
235
- }
236
- catch {
237
- json(res, 400, { error: "Invalid JSON" });
238
- return;
239
- }
240
- if (!body.text) {
241
- json(res, 400, { error: "Missing required field: text" });
242
- return;
243
- }
244
- // Store user_message event and update last_active_at
245
- store.saveEvent(sessionId, "user_message", { text: body.text, images: body.images });
246
- store.updateSessionLastActive(sessionId);
247
- const userMsgEvent = { type: "user_message", sessionId, text: body.text, images: body.images };
248
- sseManager.broadcast(userMsgEvent);
249
- // Generate title (fire-and-forget)
250
- if (titleService && sessions && !sessions.sessionHasTitle.has(sessionId)) {
251
- titleService.generate(bridge, body.text, sessionId, (title) => {
252
- const titleEvent = { type: "session_title_updated", sessionId, title };
253
- sseManager.broadcast(titleEvent);
254
- });
255
- }
256
- // Fire prompt asynchronously (don't await — response is 202)
257
- sessions.activePrompts.add(sessionId);
258
- bridge.prompt(sessionId, body.text, body.images).catch((err) => {
259
- console.error(`[prompt] error for ${sessionId}:`, err);
260
- }).finally(() => {
261
- sessions.activePrompts.delete(sessionId);
262
- });
263
- json(res, 202, { status: "accepted" });
264
- return;
265
- }
266
- // --- POST /api/v1/sessions/:id/bash ---
267
- const bashMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/bash\/?$/);
268
- if (bashMatch && req.method === "POST") {
269
- const sessionId = decodeURIComponent(bashMatch[1]);
270
- const session = store.getSession(sessionId);
271
- if (!session) {
272
- json(res, 404, { error: "Session not found" });
273
- return;
274
- }
275
- if (!sessions) {
276
- json(res, 503, { error: "Session manager not available" });
277
- return;
278
- }
279
- if (sessions.runningBashProcs.has(sessionId)) {
280
- json(res, 409, { error: "A bash command is already running in this session" });
281
- return;
282
- }
283
- let body;
284
- try {
285
- body = JSON.parse(await readBody(req));
286
- }
287
- catch {
288
- json(res, 400, { error: "Invalid JSON" });
289
- return;
290
- }
291
- if (!body.command) {
292
- json(res, 400, { error: "Missing required field: command" });
293
- return;
294
- }
295
- const cwd = sessions.getSessionCwd(sessionId);
296
- store.saveEvent(sessionId, "bash_command", { command: body.command });
297
- const bashCmdEvent = { type: "bash_command", sessionId, command: body.command };
298
- sseManager.broadcast(bashCmdEvent);
299
- const shell = IS_WIN ? (process.env.COMSPEC || "cmd.exe") : (process.env.SHELL || "bash");
300
- const shellArgs = IS_WIN ? ["/s", "/c", body.command] : ["-c", body.command];
301
- const child = spawn(shell, shellArgs, {
302
- cwd,
303
- detached: !IS_WIN,
304
- env: { ...process.env, TERM: "dumb" },
305
- stdio: ["ignore", "pipe", "pipe"],
306
- });
307
- sessions.runningBashProcs.set(sessionId, child);
308
- let output = "";
309
- let outputTruncated = false;
310
- const limit = deps.limits.bash_output;
311
- const onData = (stream) => (chunk) => {
312
- const text = chunk.toString();
313
- if (!outputTruncated) {
314
- output += text;
315
- if (output.length > limit) {
316
- output = output.slice(-limit);
317
- outputTruncated = true;
318
- }
319
- }
320
- else {
321
- output = (output + text).slice(-limit);
322
- }
323
- const bashOutEvent = { type: "bash_output", sessionId, text, stream };
324
- sseManager.broadcast(bashOutEvent);
325
- };
326
- child.stdout.on("data", onData("stdout"));
327
- child.stderr.on("data", onData("stderr"));
328
- child.on("close", (code, signal) => {
329
- sessions.runningBashProcs.delete(sessionId);
330
- const stored = outputTruncated ? "[truncated]\n" + output : output;
331
- store.saveEvent(sessionId, "bash_result", { output: stored, code, signal });
332
- const bashDoneEvent = { type: "bash_done", sessionId, code, signal };
333
- sseManager.broadcast(bashDoneEvent);
334
- });
335
- child.on("error", (err) => {
336
- sessions.runningBashProcs.delete(sessionId);
337
- const errMsg = errorMessage(err);
338
- store.saveEvent(sessionId, "bash_result", { output: errMsg, code: -1, signal: null });
339
- const bashErrEvent = { type: "bash_done", sessionId, code: -1, signal: null, error: errMsg };
340
- sseManager.broadcast(bashErrEvent);
341
- });
342
- json(res, 202, { status: "accepted" });
343
- return;
344
- }
345
- // --- POST /api/v1/sessions/:id/bash/cancel ---
346
- const bashCancelMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/bash\/cancel\/?$/);
347
- if (bashCancelMatch && req.method === "POST") {
348
- const sessionId = decodeURIComponent(bashCancelMatch[1]);
349
- const session = store.getSession(sessionId);
350
- if (!session) {
351
- json(res, 404, { error: "Session not found" });
352
- return;
353
- }
354
- interruptBashProc(sessions?.runningBashProcs.get(sessionId));
355
- json(res, 200, { ok: true });
356
- return;
357
- }
358
- // --- PUT /api/v1/sessions/:id/{model,mode,reasoning-effort} ---
359
- const configPutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/(model|mode|reasoning-effort)\/?$/);
360
- if (configPutMatch && req.method === "PUT") {
361
- const sessionId = decodeURIComponent(configPutMatch[1]);
362
- const configPath = configPutMatch[2];
363
- const configId = configPath === "reasoning-effort" ? "reasoning_effort" : configPath;
364
- const session = store.getSession(sessionId);
365
- if (!session) {
366
- json(res, 404, { error: "Session not found" });
367
- return;
368
- }
369
- const bridge = getBridge?.();
370
- if (!bridge) {
371
- json(res, 503, { error: "Agent not ready yet" });
372
- return;
373
- }
374
- let body;
375
- try {
376
- body = JSON.parse(await readBody(req));
377
- }
378
- catch {
379
- json(res, 400, { error: "Invalid JSON" });
380
- return;
381
- }
382
- if (!body.value) {
383
- json(res, 400, { error: "Missing required field: value" });
384
- return;
385
- }
386
- try {
387
- const configOptions = await bridge.setConfigOption(sessionId, configId, body.value);
388
- for (const opt of configOptions) {
389
- store.updateSessionConfig(sessionId, opt.id, opt.currentValue);
390
- }
391
- sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
392
- sseManager.broadcast({ type: "config_set", sessionId, configId, value: body.value });
393
- json(res, 200, { configOptions });
394
- }
395
- catch (err) {
396
- json(res, 500, { error: `Failed to set ${configId}: ${err instanceof Error ? err.message : String(err)}` });
397
- }
398
- return;
399
- }
400
- // --- PUT /api/v1/sessions/:id/title ---
401
- const titlePutMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/title\/?$/);
402
- if (titlePutMatch && req.method === "PUT") {
403
- const sessionId = decodeURIComponent(titlePutMatch[1]);
404
- const session = store.getSession(sessionId);
405
- if (!session) {
406
- json(res, 404, { error: "Session not found" });
407
- return;
408
- }
409
- let body;
410
- try {
411
- body = JSON.parse(await readBody(req));
412
- }
413
- catch {
414
- json(res, 400, { error: "Invalid JSON" });
415
- return;
416
- }
417
- if (!body.value) {
418
- json(res, 400, { error: "Missing required field: value" });
419
- return;
420
- }
421
- store.updateSessionTitle(sessionId, body.value);
422
- const titleEvent = { type: "session_title_updated", sessionId, title: body.value };
423
- sseManager.broadcast(titleEvent);
424
- json(res, 200, { title: body.value });
425
- return;
426
- }
427
- // --- Session CRUD: /api/v1/sessions/:id ---
428
- const sessionIdMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/?(\?.*)?$/);
429
- if (sessionIdMatch) {
430
- const sessionId = decodeURIComponent(sessionIdMatch[1]);
431
- // POST /api/v1/sessions (create) — handled below since :id would match "sessions" literally
432
- // This match is for /api/v1/sessions/:id only (not /api/sessions)
433
- // GET /api/v1/sessions/:id
434
- if (req.method === "GET") {
435
- const session = store.getSession(sessionId);
436
- if (!session) {
437
- json(res, 404, { error: "Session not found" });
438
- return;
439
- }
440
- // Start resume in background (non-blocking) so the client gets metadata fast
441
- const wasLive = sessions?.liveSessions.has(sessionId) ?? true;
442
- if (sessions && getBridge && !wasLive) {
443
- const bridge = getBridge();
444
- if (bridge) {
445
- const resumePromise = sessions.ensureResumed(bridge, sessionId);
446
- // Auto-retry if the last turn was interrupted (must wait for resume)
447
- const hasInterrupted = store.hasInterruptedTurn(sessionId);
448
- if (hasInterrupted) {
449
- // Optimistically mark busy so concurrent POST sees the session as active
450
- sessions.activePrompts.add(sessionId);
451
- resumePromise.then(() => {
452
- if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
453
- // Retry not needed after all — release the optimistic lock
454
- sessions.activePrompts.delete(sessionId);
455
- }
456
- }).catch(() => {
457
- sessions.activePrompts.delete(sessionId);
458
- });
459
- }
460
- else {
461
- resumePromise.catch((err) => {
462
- console.error(`[session] background resume failed for ${sessionId.slice(0, 8)}…:`, err);
463
- });
464
- }
465
- }
466
- }
467
- const configOptions = sessions ? (() => {
468
- // Build configOptions from cached + stored overrides
469
- const opts = sessions.cachedConfigOptions.map(opt => {
470
- const stored = { model: session.model, mode: session.mode, reasoning_effort: session.reasoning_effort };
471
- const override = stored[opt.id];
472
- return override ? { ...opt, currentValue: override } : opt;
473
- });
474
- return opts;
475
- })() : [];
476
- const busyKind = sessions?.getBusyKind(sessionId) ?? null;
477
- json(res, 200, {
478
- id: session.id,
479
- cwd: session.cwd,
480
- title: session.title,
481
- source: session.source,
482
- model: session.model,
483
- mode: session.mode,
484
- configOptions,
485
- busy: busyKind != null,
486
- busyKind,
487
- }, req);
488
- return;
489
- }
490
- // DELETE /api/v1/sessions/:id
491
- if (req.method === "DELETE") {
492
- const session = store.getSession(sessionId);
493
- if (!session) {
494
- json(res, 404, { error: "Session not found" });
495
- return;
496
- }
497
- if (sessions) {
498
- sessions.deleteSession(sessionId);
499
- }
500
- else {
501
- store.deleteSession(sessionId);
502
- }
503
- sseManager.broadcast({ type: "session_deleted", sessionId });
504
- res.writeHead(204);
505
- res.end();
506
- return;
507
- }
508
- }
509
- // POST /api/v1/sessions (create new session)
510
- if (url === "/api/v1/sessions" && req.method === "POST") {
511
- const bridge = getBridge?.();
512
- if (!bridge) {
513
- json(res, 503, { error: "Agent not ready yet" });
514
- return;
515
- }
516
- if (!sessions) {
517
- json(res, 503, { error: "Session manager not available" });
518
- return;
519
- }
520
- let body;
521
- try {
522
- body = JSON.parse(await readBody(req));
523
- }
524
- catch {
525
- json(res, 400, { error: "Invalid JSON" });
526
- return;
527
- }
528
- const source = body.source ?? "auto";
529
- try {
530
- const { sessionId, configOptions } = await sessions.createSession(bridge, body.cwd, body.inheritFromSessionId, source);
531
- const session = store.getSession(sessionId);
532
- const sessionCreatedEvent = {
533
- type: "session_created",
534
- sessionId,
535
- cwd: session?.cwd,
536
- title: session?.title,
537
- configOptions,
538
- };
539
- sseManager.broadcast(sessionCreatedEvent);
540
- // ACP's session_created event fires before inheritance runs, so
541
- // broadcast final configOptions so SSE clients get the inherited values.
542
- if (configOptions.length) {
543
- sseManager.broadcast({ type: "config_option_update", sessionId, configOptions });
544
- }
545
- json(res, 201, {
546
- id: sessionId,
547
- cwd: session?.cwd ?? body.cwd,
548
- title: session?.title ?? null,
549
- source: session?.source ?? source,
550
- configOptions,
551
- });
552
- }
553
- catch (err) {
554
- const msg = err instanceof Error ? err.message : String(err);
555
- if (msg.includes("does not exist")) {
556
- json(res, 400, { error: msg });
557
- }
558
- else {
559
- json(res, 500, { error: msg });
560
- }
561
- }
562
- return;
563
- }
564
- // GET /api/v1/sessions/:id/events?thinking=0|1&limit=N&before=SEQ&after=SEQ
565
- const eventsMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events(\?.*)?$/);
566
- if (eventsMatch && req.method === "GET") {
567
- const sessionId = decodeURIComponent(eventsMatch[1]);
568
- const params = new URLSearchParams(eventsMatch[2]?.slice(1) ?? "");
569
- const excludeThinking = params.get("thinking") === "0";
570
- const afterRaw = params.get("after");
571
- const afterSeq = afterRaw != null ? Number(afterRaw) : undefined;
572
- const beforeRaw = params.get("before");
573
- const beforeSeq = beforeRaw != null ? Number(beforeRaw) : undefined;
574
- const limitRaw = params.get("limit");
575
- const limit = limitRaw != null ? Math.max(1, Math.min(10000, Number(limitRaw))) : undefined;
576
- const session = store.getSession(sessionId);
577
- if (!session) {
578
- json(res, 404, { error: "Session not found" });
579
- return;
580
- }
581
- // Flush pending buffers so their content becomes part of the event list.
582
- // Track whether each buffer was non-empty so the frontend can keep the
583
- // last thinking/assistant element "open" for continued live streaming.
584
- let streamingThinking = false;
585
- let streamingAssistant = false;
586
- if (sessions) {
587
- if (sessions.thinkingBuffers.has(sessionId)) {
588
- streamingThinking = true;
589
- sessions.flushThinkingBuffer(sessionId);
590
- }
591
- if (sessions.assistantBuffers.has(sessionId)) {
592
- streamingAssistant = true;
593
- sessions.flushAssistantBuffer(sessionId);
594
- }
595
- }
596
- const events = store.getEvents(sessionId, { excludeThinking, afterSeq, beforeSeq, limit });
597
- const envelope = {
598
- events,
599
- streaming: { thinking: streamingThinking, assistant: streamingAssistant },
600
- };
601
- if (limit != null) {
602
- const total = store.getEventCount(sessionId, { excludeThinking });
603
- const hasMore = events.length > 0
604
- ? (store.getEvents(sessionId, { excludeThinking, beforeSeq: events[0].seq, limit: 1 }).length > 0)
605
- : false;
606
- envelope.total = total;
607
- envelope.hasMore = hasMore;
608
- }
609
- json(res, 200, envelope, req);
610
- return;
611
- }
612
- // POST /api/v1/prompt — quick one-shot prompt (create temp session + send)
613
- if (url === "/api/v1/prompt" && req.method === "POST") {
614
- if (!sessions || !getBridge) {
615
- json(res, 503, { error: "Agent not available" });
616
- return;
617
- }
618
- const bridge = getBridge();
619
- if (!bridge) {
620
- json(res, 503, { error: "Agent not available" });
621
- return;
622
- }
623
- let body;
624
- try {
625
- body = JSON.parse(await readBody(req));
626
- }
627
- catch {
628
- json(res, 400, { error: "Invalid JSON" });
629
- return;
630
- }
631
- const text = body.text;
632
- if (!text || typeof text !== "string") {
633
- json(res, 400, { error: "Missing required field: text" });
634
- return;
635
- }
636
- const cwd = body.cwd || undefined;
637
- const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
638
- const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
639
- json(res, 202, { sessionId, streamUrl });
640
- // Fire-and-forget: send the prompt asynchronously, tracking busy state
641
- sessions.activePrompts.add(sessionId);
642
- // Generate title (fire-and-forget)
643
- if (titleService && !sessions.sessionHasTitle.has(sessionId)) {
644
- titleService.generate(bridge, text, sessionId, (title) => {
645
- const titleEvent = { type: "session_title_updated", sessionId, title };
646
- sseManager.broadcast(titleEvent);
647
- });
648
- }
649
- bridge.prompt(sessionId, text)
650
- .catch(() => { })
651
- .finally(() => sessions.activePrompts.delete(sessionId));
652
- return;
653
- }
654
- // --- SSE stream endpoints ---
655
- // GET /api/v1/events/stream — global SSE stream
656
- if (url.startsWith("/api/v1/events/stream") && req.method === "GET") {
657
- if (!deps.sseManager) {
658
- json(res, 501, { error: "SSE not available" });
659
- return;
660
- }
661
- const sseManager = deps.sseManager;
662
- const clientId = sseManager.generateClientId();
663
- res.writeHead(200, {
664
- "Content-Type": "text/event-stream",
665
- "Cache-Control": "no-cache",
666
- "Connection": "keep-alive",
667
- });
668
- const client = { id: clientId, res };
669
- sseManager.add(client);
670
- // Send connected event
671
- sseManager.sendEvent(client, { type: "connected", clientId });
672
- return;
673
- }
674
- // GET /api/v1/sessions/:id/events/stream — per-session SSE stream
675
- const sseSessionMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/events\/stream(\?.*)?$/);
676
- if (sseSessionMatch && req.method === "GET") {
677
- if (!deps.sseManager) {
678
- json(res, 501, { error: "SSE not available" });
679
- return;
680
- }
681
- const sseManager = deps.sseManager;
682
- const sessionId = decodeURIComponent(sseSessionMatch[1]);
683
- const session = store.getSession(sessionId);
684
- if (!session) {
685
- json(res, 404, { error: "Session not found" });
686
- return;
687
- }
688
- const clientId = sseManager.generateClientId();
689
- res.writeHead(200, {
690
- "Content-Type": "text/event-stream",
691
- "Cache-Control": "no-cache",
692
- "Connection": "keep-alive",
693
- });
694
- const client = { id: clientId, res, sessionId };
695
- sseManager.add(client);
696
- // Send connected event
697
- sseManager.sendEvent(client, { type: "connected", clientId });
698
- // Replay events from Last-Event-ID if provided
699
- const lastEventId = req.headers["last-event-id"];
700
- if (lastEventId) {
701
- const afterSeq = parseInt(lastEventId, 10);
702
- if (!isNaN(afterSeq)) {
703
- const events = store.getEvents(sessionId, { afterSeq });
704
- for (const evt of events) {
705
- try {
706
- sseManager.sendEvent(client, { type: evt.type, ...JSON.parse(evt.data) }, evt.seq);
707
- }
708
- catch {
709
- // Skip malformed event data
710
- }
711
- }
712
- }
713
- }
714
- return;
715
- }
716
- // POST /api/v1/clients/:clientId/visibility
717
- const visMatch = url.match(/^\/api\/v1\/clients\/([^/]+)\/visibility$/);
718
- if (visMatch && req.method === "POST") {
719
- if (!deps.sseManager) {
720
- json(res, 501, { error: "SSE not available" });
721
- return;
722
- }
723
- const sseManager = deps.sseManager;
724
- const clientId = decodeURIComponent(visMatch[1]);
725
- if (!sseManager.clients.has(clientId)) {
726
- json(res, 404, { error: "Client not found" });
727
- return;
728
- }
729
- let body;
730
- try {
731
- body = JSON.parse(await readBody(req));
732
- }
733
- catch {
734
- json(res, 400, { error: "Invalid JSON" });
735
- return;
736
- }
737
- if (typeof body.visible !== "boolean") {
738
- json(res, 400, { error: "Missing or invalid 'visible' field" });
739
- return;
740
- }
741
- // If push service is available, update visibility and session
742
- if (deps.pushService) {
743
- deps.pushService.setClientVisibility(clientId, body.visible);
744
- if (typeof body.sessionId === "string" && body.sessionId) {
745
- deps.pushService.setClientSession(clientId, body.sessionId);
746
- }
747
- }
748
- json(res, 200, { ok: true });
749
- return;
750
- }
751
- // --- Images (session-scoped) ---
752
- // POST /api/v1/sessions/:id/images
753
- const imgUploadMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/images\/?$/);
754
- if (imgUploadMatch && req.method === "POST") {
755
- const sessionId = decodeURIComponent(imgUploadMatch[1]);
756
- if (!SAFE_ID.test(sessionId)) {
757
- json(res, 400, { error: "Invalid session ID" });
758
- return;
759
- }
760
- // Enforce upload size limit
761
- const contentLength = parseInt(req.headers["content-length"] ?? "0", 10);
762
- if (contentLength > deps.limits.image_upload) {
763
- json(res, 413, { error: "Upload too large" });
764
- return;
765
- }
766
- const chunks = [];
767
- let totalSize = 0;
768
- for await (const chunk of req) {
769
- totalSize += chunk.length;
770
- if (totalSize > deps.limits.image_upload) {
771
- json(res, 413, { error: "Upload too large" });
772
- return;
773
- }
774
- chunks.push(chunk);
775
- }
776
- let body;
777
- try {
778
- body = JSON.parse(Buffer.concat(chunks).toString());
779
- }
780
- catch {
781
- json(res, 400, { error: "Invalid JSON" });
782
- return;
783
- }
784
- const { data, mimeType } = body;
785
- const ext = mimeType.split("/")[1]?.replace("jpeg", "jpg") ?? "png";
786
- const seq = Date.now();
787
- const fileName = `${seq}.${ext}`;
788
- const relPath = `images/${sessionId}/${fileName}`;
789
- const absPath = join(deps.dataDir, relPath);
790
- await mkdir(join(deps.dataDir, "images", sessionId), { recursive: true });
791
- await writeFile(absPath, Buffer.from(data, "base64"));
792
- const imgUrl = `/api/v1/sessions/${sessionId}/images/${fileName}`;
793
- json(res, 200, { path: relPath, url: imgUrl });
794
- return;
795
- }
796
- // GET /api/v1/sessions/:id/images/:file
797
- const imgGetMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/images\/([^/?]+)\/?$/);
798
- if (imgGetMatch && req.method === "GET") {
799
- const sessionId = decodeURIComponent(imgGetMatch[1]);
800
- const file = decodeURIComponent(imgGetMatch[2]);
801
- const filePath = join(deps.dataDir, "images", sessionId, file);
802
- if (!filePath.startsWith(join(deps.dataDir, "images"))) {
803
- res.writeHead(403);
804
- res.end("Forbidden");
805
- return;
806
- }
807
- try {
808
- const fileData = await readFile(filePath);
809
- const ext = extname(filePath);
810
- res.writeHead(200, {
811
- "Content-Type": MIME[ext] ?? "application/octet-stream",
812
- "Cache-Control": "public, max-age=31536000, immutable",
813
- });
814
- res.end(fileData);
815
- }
816
- catch {
817
- res.writeHead(404);
818
- res.end("Not found");
819
- }
820
- return;
821
- }
822
- // --- Push notification routes ---
823
- // GET /api/v1/push/vapid-key
824
- if (url === "/api/v1/push/vapid-key" && req.method === "GET") {
825
- if (!deps.pushService) {
826
- json(res, 404, { error: "Push not configured" });
827
- return;
828
- }
829
- json(res, 200, { publicKey: deps.pushService.getPublicKey() });
830
- return;
831
- }
832
- // POST /api/v1/push/subscribe
833
- if (url === "/api/v1/push/subscribe" && req.method === "POST") {
834
- if (!deps.pushService) {
835
- json(res, 404, { error: "Push not configured" });
836
- return;
837
- }
838
- const chunks = [];
839
- for await (const chunk of req)
840
- chunks.push(chunk);
841
- let body;
842
- try {
843
- body = JSON.parse(Buffer.concat(chunks).toString());
844
- }
845
- catch {
846
- json(res, 400, { error: "Invalid JSON" });
847
- return;
848
- }
849
- if (!body.endpoint || !body.keys?.auth || !body.keys?.p256dh) {
850
- json(res, 400, { error: "Missing endpoint or keys (auth, p256dh)" });
851
- return;
852
- }
853
- store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
854
- if (body.clientId && deps.pushService) {
855
- deps.pushService.registerClient(body.clientId, body.endpoint);
856
- }
857
- json(res, 201, { ok: true });
858
- return;
859
- }
860
- // POST /api/v1/push/register-client — associate clientId with push endpoint
861
- if (url === "/api/v1/push/register-client" && req.method === "POST") {
862
- if (!deps.pushService) {
863
- json(res, 404, { error: "Push not configured" });
864
- return;
865
- }
866
- const chunks = [];
867
- for await (const chunk of req)
868
- chunks.push(chunk);
869
- let body;
870
- try {
871
- body = JSON.parse(Buffer.concat(chunks).toString());
872
- }
873
- catch {
874
- json(res, 400, { error: "Invalid JSON" });
875
- return;
876
- }
877
- if (!body.clientId || !body.endpoint) {
878
- json(res, 400, { error: "Missing clientId or endpoint" });
879
- return;
880
- }
881
- deps.pushService.registerClient(body.clientId, body.endpoint);
882
- json(res, 200, { ok: true });
883
- return;
884
- }
885
- // POST /api/v1/push/unsubscribe
886
- if (url === "/api/v1/push/unsubscribe" && req.method === "POST") {
887
- if (!deps.pushService) {
888
- json(res, 404, { error: "Push not configured" });
889
- return;
890
- }
891
- const chunks = [];
892
- for await (const chunk of req)
893
- chunks.push(chunk);
894
- let body;
895
- try {
896
- body = JSON.parse(Buffer.concat(chunks).toString());
897
- }
898
- catch {
899
- json(res, 400, { error: "Invalid JSON" });
900
- return;
901
- }
902
- if (body.endpoint) {
903
- store.removeSubscription(body.endpoint);
904
- }
905
- json(res, 200, { ok: true });
906
- return;
907
- }
908
- json(res, 404, { error: "Not found" });
909
- return;
910
- }
911
- // --- Static files ---
912
- const filePath = join(deps.publicDir, url === "/" ? "/index.html" : url);
913
- if (!filePath.startsWith(deps.publicDir)) {
914
- res.writeHead(403);
915
- res.end("Forbidden");
916
- return;
917
- }
918
- try {
919
- const data = await readFile(filePath);
920
- const ext = extname(filePath);
921
- res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
922
- res.end(data);
923
- }
924
- catch {
925
- res.writeHead(404);
926
- res.end("Not found");
927
- }
928
- };
929
- }