clankie 0.2.1 → 0.2.3
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 +29 -13
- package/dist/cli.js +301851 -0
- package/dist/koffi-216xhpes.node +0 -0
- package/dist/koffi-2erktc37.node +0 -0
- package/dist/koffi-2rrez93a.node +0 -0
- package/dist/koffi-2wv0r22g.node +0 -0
- package/dist/koffi-3kae4xj3.node +0 -0
- package/dist/koffi-3rkr2zqv.node +0 -0
- package/dist/koffi-abxfktv9.node +0 -0
- package/dist/koffi-c67c0c5b.node +0 -0
- package/dist/koffi-cnf0q0dx.node +0 -0
- package/dist/koffi-df38sqz5.node +0 -0
- package/dist/koffi-gfbqb3a0.node +0 -0
- package/dist/koffi-kjemmmem.node +0 -0
- package/dist/koffi-kkrfq9yv.node +0 -0
- package/dist/koffi-mzaqwwqy.node +0 -0
- package/dist/koffi-q49fgkeq.node +0 -0
- package/dist/koffi-q54bk8bf.node +0 -0
- package/dist/koffi-x1790w0j.node +0 -0
- package/dist/koffi-yxvjwcj6.node +0 -0
- package/package.json +17 -7
- package/web-ui-dist/_shell.html +2 -2
- package/web-ui-dist/assets/{card-kSKmECr1.js → card-BUP-xovx.js} +1 -1
- package/web-ui-dist/assets/extensions-DC620Nmx.js +1 -0
- package/web-ui-dist/assets/{index-CXJ3n5rE.js → index-DurjG9O_.js} +1 -1
- package/web-ui-dist/assets/{loader-circle-C5ib508E.js → loader-circle-DbOtKfCA.js} +1 -1
- package/web-ui-dist/assets/{main-cBOaKYCP.js → main-B2sRcuyZ.js} +8 -8
- package/web-ui-dist/assets/{sessions._sessionId-BIeINoSQ.js → sessions._sessionId-BJazw9EJ.js} +1 -1
- package/web-ui-dist/assets/{settings-CO37Obvo.js → settings-Bv8oeIho.js} +1 -1
- package/web-ui-dist/assets/styles-D2oHO1JL.css +1 -0
- package/src/agent.ts +0 -107
- package/src/channels/channel.ts +0 -57
- package/src/channels/slack.ts +0 -374
- package/src/channels/web.ts +0 -1362
- package/src/cli.ts +0 -505
- package/src/config.ts +0 -257
- package/src/daemon.ts +0 -380
- package/src/service.ts +0 -372
- package/src/sessions.ts +0 -251
- package/web-ui-dist/assets/extensions-CFPfugfg.js +0 -1
- package/web-ui-dist/assets/styles-BQfA8H-l.css +0 -1
package/src/channels/web.ts
DELETED
|
@@ -1,1362 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WebSocket channel — bridges pi's RPC protocol over WebSocket.
|
|
3
|
-
*
|
|
4
|
-
* Protocol:
|
|
5
|
-
* - Client → Server: { sessionId?: string, command: RpcCommand }
|
|
6
|
-
* - Server → Client: { sessionId: string, event: AgentEvent | RpcResponse | RpcExtensionUIRequest }
|
|
7
|
-
*
|
|
8
|
-
* One WebSocket connection can handle multiple sessions.
|
|
9
|
-
* Sessions are identified by unique sessionId from pi's AgentSession.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import * as crypto from "node:crypto";
|
|
13
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
16
|
-
import type { ImageContent, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
|
|
17
|
-
import { type AgentSession, type AgentSessionEvent, AuthStorage } from "@mariozechner/pi-coding-agent";
|
|
18
|
-
import type { ServerWebSocket } from "bun";
|
|
19
|
-
import { getAppDir, getAuthPath, loadConfig } from "../config.ts";
|
|
20
|
-
import { getOrCreateSession } from "../sessions.ts";
|
|
21
|
-
import type { Channel, MessageHandler } from "./channel.ts";
|
|
22
|
-
|
|
23
|
-
// ─── Types ─────────────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
export interface WebChannelOptions {
|
|
26
|
-
/** Port to listen on (default: 3100) */
|
|
27
|
-
port: number;
|
|
28
|
-
/** Required shared secret for authentication */
|
|
29
|
-
authToken: string;
|
|
30
|
-
/** Allowed origins for CORS-like validation (empty = allow all) */
|
|
31
|
-
allowedOrigins?: string[];
|
|
32
|
-
/** Path to built web-ui static files (enables same-origin serving) */
|
|
33
|
-
staticDir?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Inbound message from client */
|
|
37
|
-
interface InboundWebMessage {
|
|
38
|
-
sessionId?: string;
|
|
39
|
-
command: RpcCommand;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Outbound message to client */
|
|
43
|
-
interface OutboundWebMessage {
|
|
44
|
-
sessionId: string; // "_auth" for auth events
|
|
45
|
-
event: AgentSessionEvent | RpcResponse | RpcExtensionUIRequest | AuthEvent;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** RPC command types from pi */
|
|
49
|
-
type RpcCommand =
|
|
50
|
-
| { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" }
|
|
51
|
-
| { id?: string; type: "steer"; message: string; images?: ImageContent[] }
|
|
52
|
-
| { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
|
|
53
|
-
| { id?: string; type: "abort" }
|
|
54
|
-
| { id?: string; type: "upload_attachment"; fileName: string; data: string; mimeType: string }
|
|
55
|
-
| { id?: string; type: "new_session"; parentSession?: string }
|
|
56
|
-
| { id?: string; type: "list_sessions" }
|
|
57
|
-
| { id?: string; type: "get_state" }
|
|
58
|
-
| { id?: string; type: "set_model"; provider: string; modelId: string }
|
|
59
|
-
| { id?: string; type: "cycle_model" }
|
|
60
|
-
| { id?: string; type: "get_available_models" }
|
|
61
|
-
| { id?: string; type: "set_thinking_level"; level: ThinkingLevel }
|
|
62
|
-
| { id?: string; type: "cycle_thinking_level" }
|
|
63
|
-
| { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" }
|
|
64
|
-
| { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" }
|
|
65
|
-
| { id?: string; type: "compact"; customInstructions?: string }
|
|
66
|
-
| { id?: string; type: "set_auto_compaction"; enabled: boolean }
|
|
67
|
-
| { id?: string; type: "set_auto_retry"; enabled: boolean }
|
|
68
|
-
| { id?: string; type: "abort_retry" }
|
|
69
|
-
| { id?: string; type: "bash"; command: string }
|
|
70
|
-
| { id?: string; type: "abort_bash" }
|
|
71
|
-
| { id?: string; type: "get_session_stats" }
|
|
72
|
-
| { id?: string; type: "export_html"; outputPath?: string }
|
|
73
|
-
| { id?: string; type: "switch_session"; sessionPath: string }
|
|
74
|
-
| { id?: string; type: "fork"; entryId: string }
|
|
75
|
-
| { id?: string; type: "get_fork_messages" }
|
|
76
|
-
| { id?: string; type: "get_last_assistant_text" }
|
|
77
|
-
| { id?: string; type: "set_session_name"; name: string }
|
|
78
|
-
| { id?: string; type: "get_messages" }
|
|
79
|
-
| { id?: string; type: "get_commands" }
|
|
80
|
-
| { id?: string; type: "get_extensions" }
|
|
81
|
-
| { id?: string; type: "get_skills" }
|
|
82
|
-
| { id?: string; type: "install_package"; source: string; local?: boolean }
|
|
83
|
-
| { id?: string; type: "get_auth_providers" }
|
|
84
|
-
| { id?: string; type: "auth_login"; providerId: string }
|
|
85
|
-
| { id?: string; type: "auth_set_api_key"; providerId: string; apiKey: string }
|
|
86
|
-
| { id?: string; type: "auth_login_input"; loginFlowId: string; value: string }
|
|
87
|
-
| { id?: string; type: "auth_login_cancel"; loginFlowId: string }
|
|
88
|
-
| { id?: string; type: "auth_logout"; providerId: string };
|
|
89
|
-
|
|
90
|
-
/** RPC response types from pi */
|
|
91
|
-
type RpcResponse =
|
|
92
|
-
| { id?: string; type: "response"; command: string; success: true; data?: unknown }
|
|
93
|
-
| { id?: string; type: "response"; command: string; success: false; error: string };
|
|
94
|
-
|
|
95
|
-
/** Auth event types (sent during login flows) */
|
|
96
|
-
type AuthEvent =
|
|
97
|
-
| { type: "auth_event"; loginFlowId: string; event: "url"; url: string; instructions?: string }
|
|
98
|
-
| { type: "auth_event"; loginFlowId: string; event: "prompt"; message: string; placeholder?: string }
|
|
99
|
-
| { type: "auth_event"; loginFlowId: string; event: "manual_input" }
|
|
100
|
-
| { type: "auth_event"; loginFlowId: string; event: "progress"; message: string }
|
|
101
|
-
| { type: "auth_event"; loginFlowId: string; event: "complete"; success: boolean; error?: string };
|
|
102
|
-
|
|
103
|
-
/** Extension UI request types from pi */
|
|
104
|
-
type RpcExtensionUIRequest =
|
|
105
|
-
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
|
|
106
|
-
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
|
|
107
|
-
| { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number }
|
|
108
|
-
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
|
|
109
|
-
| {
|
|
110
|
-
type: "extension_ui_request";
|
|
111
|
-
id: string;
|
|
112
|
-
method: "notify";
|
|
113
|
-
message: string;
|
|
114
|
-
notifyType?: "info" | "warning" | "error";
|
|
115
|
-
}
|
|
116
|
-
| { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined }
|
|
117
|
-
| {
|
|
118
|
-
type: "extension_ui_request";
|
|
119
|
-
id: string;
|
|
120
|
-
method: "setWidget";
|
|
121
|
-
widgetKey: string;
|
|
122
|
-
widgetLines: string[] | undefined;
|
|
123
|
-
widgetPlacement?: "aboveEditor" | "belowEditor";
|
|
124
|
-
}
|
|
125
|
-
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
|
|
126
|
-
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
|
|
127
|
-
|
|
128
|
-
/** Extension UI response from client */
|
|
129
|
-
type RpcExtensionUIResponse =
|
|
130
|
-
| { type: "extension_ui_response"; id: string; value: string }
|
|
131
|
-
| { type: "extension_ui_response"; id: string; confirmed: boolean }
|
|
132
|
-
| { type: "extension_ui_response"; id: string; cancelled: true };
|
|
133
|
-
|
|
134
|
-
interface ConnectionData {
|
|
135
|
-
authenticated: boolean;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ─── WebChannel ────────────────────────────────────────────────────────────────
|
|
139
|
-
|
|
140
|
-
export class WebChannel implements Channel {
|
|
141
|
-
readonly name = "web";
|
|
142
|
-
private options: WebChannelOptions;
|
|
143
|
-
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
144
|
-
|
|
145
|
-
/** Map of sessionId → Set of WebSocket connections subscribed to that session */
|
|
146
|
-
private sessionSubscriptions = new Map<string, Set<ServerWebSocket<ConnectionData>>>();
|
|
147
|
-
|
|
148
|
-
/** Map of sessionId → AgentSession */
|
|
149
|
-
private sessions = new Map<string, AgentSession>();
|
|
150
|
-
|
|
151
|
-
/** Map of sessionId → unsubscribe function for session event listener */
|
|
152
|
-
private sessionUnsubscribers = new Map<string, () => void>();
|
|
153
|
-
|
|
154
|
-
/** Pending extension UI requests: Map<requestId, { sessionId, ws }> */
|
|
155
|
-
private pendingExtensionRequests = new Map<string, { sessionId: string; ws: ServerWebSocket<ConnectionData> }>();
|
|
156
|
-
|
|
157
|
-
/** Pending auth login flows: Map<loginFlowId, { ws, inputResolver, abortController }> */
|
|
158
|
-
private pendingLoginFlows = new Map<
|
|
159
|
-
string,
|
|
160
|
-
{
|
|
161
|
-
ws: ServerWebSocket<ConnectionData>;
|
|
162
|
-
inputResolver: ((value: string) => void) | null;
|
|
163
|
-
abortController: AbortController;
|
|
164
|
-
}
|
|
165
|
-
>();
|
|
166
|
-
|
|
167
|
-
constructor(options: WebChannelOptions) {
|
|
168
|
-
this.options = options;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async start(handler: MessageHandler): Promise<void> {
|
|
172
|
-
this.handler = handler;
|
|
173
|
-
|
|
174
|
-
this.server = Bun.serve({
|
|
175
|
-
port: this.options.port,
|
|
176
|
-
websocket: {
|
|
177
|
-
open: (ws) => this.handleOpen(ws),
|
|
178
|
-
message: (ws, message) => this.handleMessage(ws, message),
|
|
179
|
-
close: (ws) => this.handleClose(ws),
|
|
180
|
-
},
|
|
181
|
-
fetch: (req, server) => {
|
|
182
|
-
const isWebSocket = req.headers.get("Upgrade")?.toLowerCase() === "websocket";
|
|
183
|
-
|
|
184
|
-
// ─── WebSocket upgrade path ───────────────────────────────────────
|
|
185
|
-
|
|
186
|
-
if (isWebSocket) {
|
|
187
|
-
// Validate auth token from Authorization header or URL query param
|
|
188
|
-
const authHeader = req.headers.get("Authorization");
|
|
189
|
-
const headerToken = authHeader?.replace(/^Bearer\s+/i, "");
|
|
190
|
-
|
|
191
|
-
// Also check URL query param (for browser WebSocket clients that can't send headers)
|
|
192
|
-
const url = new URL(req.url, `http://${req.headers.get("host")}`);
|
|
193
|
-
const queryToken = url.searchParams.get("token");
|
|
194
|
-
|
|
195
|
-
const token = headerToken || queryToken;
|
|
196
|
-
|
|
197
|
-
if (token !== this.options.authToken) {
|
|
198
|
-
return new Response("Unauthorized", { status: 401 });
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ─── Origin validation ────────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
// When staticDir is set, enforce same-origin by comparing Origin vs Host
|
|
204
|
-
if (this.options.staticDir) {
|
|
205
|
-
const origin = req.headers.get("Origin");
|
|
206
|
-
const host = req.headers.get("Host");
|
|
207
|
-
|
|
208
|
-
if (!origin || !host) {
|
|
209
|
-
return new Response("Forbidden - missing headers", { status: 403 });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
const originHost = new URL(origin).host;
|
|
214
|
-
// Compare hostnames (ignoring scheme — reverse proxy handles TLS)
|
|
215
|
-
if (originHost !== host) {
|
|
216
|
-
console.warn(`[web] Blocked cross-origin WebSocket: origin=${origin}, host=${host}`);
|
|
217
|
-
return new Response("Forbidden - cross-origin not allowed", { status: 403 });
|
|
218
|
-
}
|
|
219
|
-
} catch (err) {
|
|
220
|
-
console.error("[web] Invalid Origin header:", err);
|
|
221
|
-
return new Response("Forbidden - invalid origin", { status: 403 });
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// Legacy allowedOrigins check (still works as override when staticDir is not set)
|
|
225
|
-
else if (this.options.allowedOrigins && this.options.allowedOrigins.length > 0) {
|
|
226
|
-
const origin = req.headers.get("Origin");
|
|
227
|
-
if (!origin || !this.options.allowedOrigins.includes(origin)) {
|
|
228
|
-
return new Response("Forbidden", { status: 403 });
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Upgrade to WebSocket
|
|
233
|
-
const upgraded = server.upgrade(req, {
|
|
234
|
-
data: { authenticated: true } as ConnectionData,
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
if (!upgraded) {
|
|
238
|
-
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// biome-ignore lint/suspicious/noExplicitAny: Bun requires undefined return after upgrade
|
|
242
|
-
return undefined as any; // upgrade successful
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// ─── Static file serving path ─────────────────────────────────────
|
|
246
|
-
|
|
247
|
-
if (this.options.staticDir) {
|
|
248
|
-
return this.serveStaticFile(req);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ─── No static dir configured — reject non-WebSocket requests ─────
|
|
252
|
-
|
|
253
|
-
return new Response("Upgrade Required - this endpoint only accepts WebSocket connections", {
|
|
254
|
-
status: 426,
|
|
255
|
-
headers: { Upgrade: "websocket" },
|
|
256
|
-
});
|
|
257
|
-
},
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
console.log(`[web] WebSocket server listening on port ${this.options.port}`);
|
|
261
|
-
console.log(`[web] Open in browser: http://localhost:${this.options.port}?token=${this.options.authToken}`);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async send(_chatId: string, _text: string, _options?: { threadId?: string }): Promise<void> {
|
|
265
|
-
// No-op — WebChannel uses direct session streaming, not channel.send()
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
async stop(): Promise<void> {
|
|
269
|
-
if (this.server) {
|
|
270
|
-
this.server.stop();
|
|
271
|
-
this.server = null;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Unsubscribe from all sessions
|
|
275
|
-
for (const unsubscribe of this.sessionUnsubscribers.values()) {
|
|
276
|
-
unsubscribe();
|
|
277
|
-
}
|
|
278
|
-
this.sessionUnsubscribers.clear();
|
|
279
|
-
this.sessionSubscriptions.clear();
|
|
280
|
-
|
|
281
|
-
console.log("[web] WebSocket server stopped");
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// ─── WebSocket handlers ────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
private handleOpen(_ws: ServerWebSocket<ConnectionData>): void {
|
|
287
|
-
console.log("[web] Client connected");
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
private async handleMessage(ws: ServerWebSocket<ConnectionData>, message: string | Buffer): Promise<void> {
|
|
291
|
-
try {
|
|
292
|
-
const text = typeof message === "string" ? message : message.toString("utf-8");
|
|
293
|
-
const parsed = JSON.parse(text);
|
|
294
|
-
|
|
295
|
-
// Handle extension UI responses
|
|
296
|
-
if (parsed.type === "extension_ui_response") {
|
|
297
|
-
this.handleExtensionUIResponse(parsed as RpcExtensionUIResponse);
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Handle RPC commands
|
|
302
|
-
const inbound = parsed as InboundWebMessage;
|
|
303
|
-
await this.handleCommand(ws, inbound);
|
|
304
|
-
} catch (err) {
|
|
305
|
-
console.error("[web] Error handling message:", err);
|
|
306
|
-
this.sendError(
|
|
307
|
-
ws,
|
|
308
|
-
undefined,
|
|
309
|
-
"parse",
|
|
310
|
-
`Failed to parse message: ${err instanceof Error ? err.message : String(err)}`,
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
private handleClose(ws: ServerWebSocket<ConnectionData>): void {
|
|
316
|
-
console.log("[web] Client disconnected");
|
|
317
|
-
|
|
318
|
-
// Remove this connection from all session subscriptions
|
|
319
|
-
for (const [sessionId, subscribers] of this.sessionSubscriptions.entries()) {
|
|
320
|
-
subscribers.delete(ws);
|
|
321
|
-
if (subscribers.size === 0) {
|
|
322
|
-
this.sessionSubscriptions.delete(sessionId);
|
|
323
|
-
// Optionally unsubscribe from session events if no one is listening
|
|
324
|
-
// But keep the session alive for reconnection
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ─── Command handling ──────────────────────────────────────────────────────
|
|
330
|
-
|
|
331
|
-
private async handleCommand(ws: ServerWebSocket<ConnectionData>, inbound: InboundWebMessage): Promise<void> {
|
|
332
|
-
const command = inbound.command;
|
|
333
|
-
const commandId = command.id;
|
|
334
|
-
|
|
335
|
-
try {
|
|
336
|
-
// Special case: new_session doesn't need a sessionId
|
|
337
|
-
if (command.type === "new_session") {
|
|
338
|
-
const config = loadConfig();
|
|
339
|
-
const chatKey = `web_${crypto.randomUUID()}`;
|
|
340
|
-
console.log(`[web] Creating new session with chatKey: ${chatKey}`);
|
|
341
|
-
const session = await getOrCreateSession(chatKey, config);
|
|
342
|
-
console.log(
|
|
343
|
-
`[web] After getOrCreateSession - session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
|
|
344
|
-
);
|
|
345
|
-
|
|
346
|
-
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
|
|
347
|
-
const cancelled = !(await session.newSession(options));
|
|
348
|
-
console.log(
|
|
349
|
-
`[web] After session.newSession() - session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
// Subscribe using the chatKey (not session.sessionId) for consistency
|
|
353
|
-
this.subscribeToSessionWithKey(chatKey, session, ws);
|
|
354
|
-
|
|
355
|
-
// Return the chatKey as sessionId so client uses it for future commands
|
|
356
|
-
console.log(`[web] Returning sessionId to client: ${chatKey}, cancelled: ${cancelled}`);
|
|
357
|
-
this.sendResponse(ws, chatKey, {
|
|
358
|
-
id: commandId,
|
|
359
|
-
type: "response",
|
|
360
|
-
command: "new_session",
|
|
361
|
-
success: true,
|
|
362
|
-
data: { sessionId: chatKey, cancelled },
|
|
363
|
-
});
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Auth commands don't need a sessionId
|
|
368
|
-
if (
|
|
369
|
-
command.type === "get_auth_providers" ||
|
|
370
|
-
command.type === "auth_login" ||
|
|
371
|
-
command.type === "auth_set_api_key" ||
|
|
372
|
-
command.type === "auth_login_input" ||
|
|
373
|
-
command.type === "auth_login_cancel" ||
|
|
374
|
-
command.type === "auth_logout"
|
|
375
|
-
) {
|
|
376
|
-
await this.handleAuthCommand(ws, command, commandId);
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Special case: list_sessions doesn't need a sessionId
|
|
381
|
-
if (command.type === "list_sessions") {
|
|
382
|
-
const sessions = await this.listAllSessions();
|
|
383
|
-
|
|
384
|
-
this.sendResponse(ws, undefined, {
|
|
385
|
-
id: commandId,
|
|
386
|
-
type: "response",
|
|
387
|
-
command: "list_sessions",
|
|
388
|
-
success: true,
|
|
389
|
-
data: { sessions },
|
|
390
|
-
});
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// All other commands require sessionId
|
|
395
|
-
if (!inbound.sessionId) {
|
|
396
|
-
this.sendError(ws, undefined, command.type, "sessionId is required", commandId);
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const sessionId = inbound.sessionId;
|
|
401
|
-
|
|
402
|
-
// Get existing session or try to restore from disk
|
|
403
|
-
// Note: sessionId here is the chatKey (web_xxx), not the internal session ID
|
|
404
|
-
let session = this.sessions.get(sessionId);
|
|
405
|
-
if (!session) {
|
|
406
|
-
// Try to restore session from disk
|
|
407
|
-
try {
|
|
408
|
-
const config = loadConfig();
|
|
409
|
-
console.log(`[web] Restoring session from disk - chatKey: ${sessionId}`);
|
|
410
|
-
session = await getOrCreateSession(sessionId, config);
|
|
411
|
-
console.log(
|
|
412
|
-
`[web] After restore - chatKey: ${sessionId}, session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
|
|
413
|
-
);
|
|
414
|
-
this.subscribeToSessionWithKey(sessionId, session, ws);
|
|
415
|
-
console.log(`[web] Restored session from disk: ${sessionId}`);
|
|
416
|
-
} catch (_err) {
|
|
417
|
-
this.sendError(ws, sessionId, command.type, `Session not found: ${sessionId}`, commandId);
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
} else {
|
|
421
|
-
// Ensure this ws is subscribed (handles reconnection with new ws)
|
|
422
|
-
this.subscribeToSessionWithKey(sessionId, session, ws);
|
|
423
|
-
console.log(
|
|
424
|
-
`[web] Using cached session - chatKey: ${sessionId}, session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Execute command (mirrors rpc-mode.ts logic)
|
|
429
|
-
const response = await this.executeCommand(sessionId, session, command);
|
|
430
|
-
this.sendResponse(ws, sessionId, response);
|
|
431
|
-
} catch (err) {
|
|
432
|
-
console.error("[web] Command error:", err);
|
|
433
|
-
this.sendError(ws, inbound.sessionId, command.type, err instanceof Error ? err.message : String(err), commandId);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
private async executeCommand(sessionId: string, session: AgentSession, command: RpcCommand): Promise<RpcResponse> {
|
|
438
|
-
const id = command.id;
|
|
439
|
-
|
|
440
|
-
switch (command.type) {
|
|
441
|
-
case "prompt": {
|
|
442
|
-
console.log(
|
|
443
|
-
`[web] Executing prompt - session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
|
|
444
|
-
);
|
|
445
|
-
// Don't await - events will stream
|
|
446
|
-
session
|
|
447
|
-
.prompt(command.message, {
|
|
448
|
-
images: command.images,
|
|
449
|
-
streamingBehavior: command.streamingBehavior,
|
|
450
|
-
source: "rpc",
|
|
451
|
-
})
|
|
452
|
-
.catch((e) => {
|
|
453
|
-
console.error("[web] Prompt error:", e);
|
|
454
|
-
});
|
|
455
|
-
console.log(
|
|
456
|
-
`[web] After prompt - session.sessionId: ${session.sessionId}, sessionFile: ${session.sessionFile}`,
|
|
457
|
-
);
|
|
458
|
-
return { id, type: "response", command: "prompt", success: true };
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
case "steer": {
|
|
462
|
-
await session.steer(command.message, command.images);
|
|
463
|
-
return { id, type: "response", command: "steer", success: true };
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
case "follow_up": {
|
|
467
|
-
await session.followUp(command.message, command.images);
|
|
468
|
-
return { id, type: "response", command: "follow_up", success: true };
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
case "abort": {
|
|
472
|
-
await session.abort();
|
|
473
|
-
return { id, type: "response", command: "abort", success: true };
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
case "upload_attachment": {
|
|
477
|
-
const { fileName, data, mimeType } = command;
|
|
478
|
-
|
|
479
|
-
// Save attachment to disk
|
|
480
|
-
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
481
|
-
const { join } = await import("node:path");
|
|
482
|
-
|
|
483
|
-
// Use sessionId (which is the chatKey like web_xxx) to organize attachments
|
|
484
|
-
const dir = join(getAppDir(), "attachments", sessionId);
|
|
485
|
-
mkdirSync(dir, { recursive: true });
|
|
486
|
-
|
|
487
|
-
// Create a unique filename with timestamp
|
|
488
|
-
const timestamp = Date.now();
|
|
489
|
-
const sanitizedName = fileName.replace(/[^a-zA-Z0-9.-]/g, "_");
|
|
490
|
-
const uniqueFileName = `${timestamp}_${sanitizedName}`;
|
|
491
|
-
const filePath = join(dir, uniqueFileName);
|
|
492
|
-
|
|
493
|
-
// Write the base64 data to disk
|
|
494
|
-
writeFileSync(filePath, Buffer.from(data, "base64"));
|
|
495
|
-
|
|
496
|
-
console.log(`[web] Saved attachment: ${filePath} (${mimeType})`);
|
|
497
|
-
|
|
498
|
-
return {
|
|
499
|
-
id,
|
|
500
|
-
type: "response",
|
|
501
|
-
command: "upload_attachment",
|
|
502
|
-
success: true,
|
|
503
|
-
data: { path: filePath, fileName: uniqueFileName },
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
case "get_state": {
|
|
508
|
-
const state = {
|
|
509
|
-
model: session.model,
|
|
510
|
-
thinkingLevel: session.thinkingLevel,
|
|
511
|
-
isStreaming: session.isStreaming,
|
|
512
|
-
isCompacting: session.isCompacting,
|
|
513
|
-
steeringMode: session.steeringMode,
|
|
514
|
-
followUpMode: session.followUpMode,
|
|
515
|
-
sessionFile: session.sessionFile,
|
|
516
|
-
sessionId: session.sessionId,
|
|
517
|
-
sessionName: session.sessionName,
|
|
518
|
-
autoCompactionEnabled: session.autoCompactionEnabled,
|
|
519
|
-
messageCount: session.messages.length,
|
|
520
|
-
pendingMessageCount: session.pendingMessageCount,
|
|
521
|
-
};
|
|
522
|
-
return { id, type: "response", command: "get_state", success: true, data: state };
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
case "set_model": {
|
|
526
|
-
const models = await session.modelRegistry.getAvailable();
|
|
527
|
-
const model = models.find((m) => m.provider === command.provider && m.id === command.modelId);
|
|
528
|
-
if (!model) {
|
|
529
|
-
return {
|
|
530
|
-
id,
|
|
531
|
-
type: "response",
|
|
532
|
-
command: "set_model",
|
|
533
|
-
success: false,
|
|
534
|
-
error: `Model not found: ${command.provider}/${command.modelId}`,
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
console.log(`[web] Setting model for session ${sessionId}:`, model);
|
|
538
|
-
await session.setModel(model);
|
|
539
|
-
console.log(`[web] Model set successfully for session ${sessionId}`);
|
|
540
|
-
|
|
541
|
-
// Manually broadcast model_changed event (pi SDK may not emit it automatically)
|
|
542
|
-
this.broadcastEvent(sessionId, {
|
|
543
|
-
type: "model_changed",
|
|
544
|
-
model: model,
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
return { id, type: "response", command: "set_model", success: true, data: model };
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
case "cycle_model": {
|
|
551
|
-
const result = await session.cycleModel();
|
|
552
|
-
return { id, type: "response", command: "cycle_model", success: true, data: result ?? null };
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
case "get_available_models": {
|
|
556
|
-
const models = await session.modelRegistry.getAvailable();
|
|
557
|
-
return { id, type: "response", command: "get_available_models", success: true, data: { models } };
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
case "set_thinking_level": {
|
|
561
|
-
session.setThinkingLevel(command.level);
|
|
562
|
-
|
|
563
|
-
// Manually broadcast thinking_level_changed event
|
|
564
|
-
this.broadcastEvent(sessionId, {
|
|
565
|
-
type: "thinking_level_changed",
|
|
566
|
-
level: command.level,
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
return { id, type: "response", command: "set_thinking_level", success: true };
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
case "cycle_thinking_level": {
|
|
573
|
-
const level = session.cycleThinkingLevel();
|
|
574
|
-
return { id, type: "response", command: "cycle_thinking_level", success: true, data: level ? { level } : null };
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
case "set_steering_mode": {
|
|
578
|
-
session.setSteeringMode(command.mode);
|
|
579
|
-
return { id, type: "response", command: "set_steering_mode", success: true };
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
case "set_follow_up_mode": {
|
|
583
|
-
session.setFollowUpMode(command.mode);
|
|
584
|
-
return { id, type: "response", command: "set_follow_up_mode", success: true };
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
case "compact": {
|
|
588
|
-
const result = await session.compact(command.customInstructions);
|
|
589
|
-
return { id, type: "response", command: "compact", success: true, data: result };
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
case "set_auto_compaction": {
|
|
593
|
-
session.setAutoCompactionEnabled(command.enabled);
|
|
594
|
-
return { id, type: "response", command: "set_auto_compaction", success: true };
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
case "set_auto_retry": {
|
|
598
|
-
session.setAutoRetryEnabled(command.enabled);
|
|
599
|
-
return { id, type: "response", command: "set_auto_retry", success: true };
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
case "abort_retry": {
|
|
603
|
-
session.abortRetry();
|
|
604
|
-
return { id, type: "response", command: "abort_retry", success: true };
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
case "bash": {
|
|
608
|
-
const result = await session.executeBash(command.command);
|
|
609
|
-
return { id, type: "response", command: "bash", success: true, data: result };
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
case "abort_bash": {
|
|
613
|
-
session.abortBash();
|
|
614
|
-
return { id, type: "response", command: "abort_bash", success: true };
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
case "get_session_stats": {
|
|
618
|
-
const stats = session.getSessionStats();
|
|
619
|
-
return { id, type: "response", command: "get_session_stats", success: true, data: stats };
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
case "export_html": {
|
|
623
|
-
const path = await session.exportToHtml(command.outputPath);
|
|
624
|
-
return { id, type: "response", command: "export_html", success: true, data: { path } };
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
case "switch_session": {
|
|
628
|
-
const cancelled = !(await session.switchSession(command.sessionPath));
|
|
629
|
-
return { id, type: "response", command: "switch_session", success: true, data: { cancelled } };
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
case "fork": {
|
|
633
|
-
const result = await session.fork(command.entryId);
|
|
634
|
-
return {
|
|
635
|
-
id,
|
|
636
|
-
type: "response",
|
|
637
|
-
command: "fork",
|
|
638
|
-
success: true,
|
|
639
|
-
data: { text: result.selectedText, cancelled: result.cancelled },
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
case "get_fork_messages": {
|
|
644
|
-
const messages = session.getUserMessagesForForking();
|
|
645
|
-
return { id, type: "response", command: "get_fork_messages", success: true, data: { messages } };
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
case "get_last_assistant_text": {
|
|
649
|
-
const text = session.getLastAssistantText();
|
|
650
|
-
return { id, type: "response", command: "get_last_assistant_text", success: true, data: { text } };
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
case "set_session_name": {
|
|
654
|
-
const name = command.name.trim();
|
|
655
|
-
if (!name) {
|
|
656
|
-
return {
|
|
657
|
-
id,
|
|
658
|
-
type: "response",
|
|
659
|
-
command: "set_session_name",
|
|
660
|
-
success: false,
|
|
661
|
-
error: "Session name cannot be empty",
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
session.setSessionName(name);
|
|
665
|
-
return { id, type: "response", command: "set_session_name", success: true };
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
case "get_messages": {
|
|
669
|
-
return { id, type: "response", command: "get_messages", success: true, data: { messages: session.messages } };
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
case "get_commands": {
|
|
673
|
-
const commands: Array<{
|
|
674
|
-
name: string;
|
|
675
|
-
description?: string;
|
|
676
|
-
source: string;
|
|
677
|
-
location?: string;
|
|
678
|
-
path?: string;
|
|
679
|
-
}> = [];
|
|
680
|
-
|
|
681
|
-
// Extension commands
|
|
682
|
-
for (const { command: cmd, extensionPath } of session.extensionRunner?.getRegisteredCommandsWithPaths() ?? []) {
|
|
683
|
-
commands.push({
|
|
684
|
-
name: cmd.name,
|
|
685
|
-
description: cmd.description,
|
|
686
|
-
source: "extension",
|
|
687
|
-
path: extensionPath,
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// Prompt templates
|
|
692
|
-
for (const template of session.promptTemplates) {
|
|
693
|
-
commands.push({
|
|
694
|
-
name: template.name,
|
|
695
|
-
description: template.description,
|
|
696
|
-
source: "prompt",
|
|
697
|
-
location: template.source,
|
|
698
|
-
path: template.filePath,
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// Skills
|
|
703
|
-
for (const skill of session.resourceLoader.getSkills().skills) {
|
|
704
|
-
commands.push({
|
|
705
|
-
name: `skill:${skill.name}`,
|
|
706
|
-
description: skill.description,
|
|
707
|
-
source: "skill",
|
|
708
|
-
location: skill.source,
|
|
709
|
-
path: skill.filePath,
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
return { id, type: "response", command: "get_commands", success: true, data: { commands } };
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
case "get_extensions": {
|
|
717
|
-
const extensionsResult = session.resourceLoader.getExtensions();
|
|
718
|
-
const extensions = extensionsResult.extensions.map((ext) => ({
|
|
719
|
-
path: ext.path,
|
|
720
|
-
resolvedPath: ext.resolvedPath,
|
|
721
|
-
tools: Array.from(ext.tools.keys()),
|
|
722
|
-
commands: Array.from(ext.commands.keys()),
|
|
723
|
-
flags: Array.from(ext.flags.keys()),
|
|
724
|
-
shortcuts: Array.from(ext.shortcuts.keys()),
|
|
725
|
-
}));
|
|
726
|
-
|
|
727
|
-
return {
|
|
728
|
-
id,
|
|
729
|
-
type: "response",
|
|
730
|
-
command: "get_extensions",
|
|
731
|
-
success: true,
|
|
732
|
-
data: {
|
|
733
|
-
extensions,
|
|
734
|
-
errors: extensionsResult.errors,
|
|
735
|
-
},
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
case "get_skills": {
|
|
740
|
-
const skillsResult = session.resourceLoader.getSkills();
|
|
741
|
-
const skills = skillsResult.skills.map((skill) => ({
|
|
742
|
-
name: skill.name,
|
|
743
|
-
description: skill.description,
|
|
744
|
-
filePath: skill.filePath,
|
|
745
|
-
baseDir: skill.baseDir,
|
|
746
|
-
source: skill.source,
|
|
747
|
-
disableModelInvocation: skill.disableModelInvocation,
|
|
748
|
-
}));
|
|
749
|
-
|
|
750
|
-
return {
|
|
751
|
-
id,
|
|
752
|
-
type: "response",
|
|
753
|
-
command: "get_skills",
|
|
754
|
-
success: true,
|
|
755
|
-
data: {
|
|
756
|
-
skills,
|
|
757
|
-
diagnostics: skillsResult.diagnostics,
|
|
758
|
-
},
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
case "install_package": {
|
|
763
|
-
const { source, local } = command;
|
|
764
|
-
const installCommand = `pi install ${local ? "-l " : ""}${source}`;
|
|
765
|
-
|
|
766
|
-
try {
|
|
767
|
-
// Run pi install via bash
|
|
768
|
-
const result = await session.executeBash(installCommand);
|
|
769
|
-
|
|
770
|
-
if (result.exitCode === 0) {
|
|
771
|
-
// Successful install - reload the session to pick up new extensions/skills
|
|
772
|
-
await session.reload();
|
|
773
|
-
|
|
774
|
-
return {
|
|
775
|
-
id,
|
|
776
|
-
type: "response",
|
|
777
|
-
command: "install_package",
|
|
778
|
-
success: true,
|
|
779
|
-
data: {
|
|
780
|
-
output: result.output,
|
|
781
|
-
exitCode: result.exitCode,
|
|
782
|
-
},
|
|
783
|
-
};
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// Non-zero exit code - return as success but with exitCode info
|
|
787
|
-
return {
|
|
788
|
-
id,
|
|
789
|
-
type: "response",
|
|
790
|
-
command: "install_package",
|
|
791
|
-
success: true,
|
|
792
|
-
data: {
|
|
793
|
-
output: result.output,
|
|
794
|
-
exitCode: result.exitCode,
|
|
795
|
-
},
|
|
796
|
-
};
|
|
797
|
-
} catch (err) {
|
|
798
|
-
return {
|
|
799
|
-
id,
|
|
800
|
-
type: "response",
|
|
801
|
-
command: "install_package",
|
|
802
|
-
success: false,
|
|
803
|
-
error: err instanceof Error ? err.message : String(err),
|
|
804
|
-
};
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
default: {
|
|
809
|
-
// biome-ignore lint/suspicious/noExplicitAny: Need to access .type property on unknown command
|
|
810
|
-
const unknownCommand = command as any;
|
|
811
|
-
return {
|
|
812
|
-
id,
|
|
813
|
-
type: "response",
|
|
814
|
-
command: unknownCommand.type,
|
|
815
|
-
success: false,
|
|
816
|
-
error: `Unknown command: ${unknownCommand.type}`,
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// ─── Auth command handling ─────────────────────────────────────────────────
|
|
823
|
-
|
|
824
|
-
private async handleAuthCommand(
|
|
825
|
-
ws: ServerWebSocket<ConnectionData>,
|
|
826
|
-
command: RpcCommand,
|
|
827
|
-
commandId?: string,
|
|
828
|
-
): Promise<void> {
|
|
829
|
-
const authStorage = AuthStorage.create(getAuthPath());
|
|
830
|
-
|
|
831
|
-
try {
|
|
832
|
-
switch (command.type) {
|
|
833
|
-
case "get_auth_providers": {
|
|
834
|
-
// Get OAuth providers from pi SDK
|
|
835
|
-
const oauthProviders = authStorage.getOAuthProviders();
|
|
836
|
-
const oauthIds = new Set(oauthProviders.map((p) => p.id));
|
|
837
|
-
|
|
838
|
-
// List of API key providers (filter out those that have OAuth)
|
|
839
|
-
const apiKeyProviders = [
|
|
840
|
-
{ id: "anthropic", name: "Anthropic" },
|
|
841
|
-
{ id: "openai", name: "OpenAI" },
|
|
842
|
-
{ id: "google", name: "Google (Gemini)" },
|
|
843
|
-
{ id: "xai", name: "xAI (Grok)" },
|
|
844
|
-
{ id: "groq", name: "Groq" },
|
|
845
|
-
{ id: "openrouter", name: "OpenRouter" },
|
|
846
|
-
{ id: "mistral", name: "Mistral" },
|
|
847
|
-
].filter((p) => !oauthIds.has(p.id));
|
|
848
|
-
|
|
849
|
-
// Combine both lists
|
|
850
|
-
const providers = [
|
|
851
|
-
...oauthProviders.map((p) => ({
|
|
852
|
-
id: p.id,
|
|
853
|
-
name: p.name,
|
|
854
|
-
type: "oauth" as const,
|
|
855
|
-
hasAuth: authStorage.hasAuth(p.id),
|
|
856
|
-
usesCallbackServer: p.usesCallbackServer ?? false,
|
|
857
|
-
})),
|
|
858
|
-
...apiKeyProviders.map((p) => ({
|
|
859
|
-
id: p.id,
|
|
860
|
-
name: p.name,
|
|
861
|
-
type: "apikey" as const,
|
|
862
|
-
hasAuth: authStorage.hasAuth(p.id),
|
|
863
|
-
usesCallbackServer: false,
|
|
864
|
-
})),
|
|
865
|
-
];
|
|
866
|
-
|
|
867
|
-
this.sendAuthResponse(ws, {
|
|
868
|
-
id: commandId,
|
|
869
|
-
type: "response",
|
|
870
|
-
command: "get_auth_providers",
|
|
871
|
-
success: true,
|
|
872
|
-
data: { providers },
|
|
873
|
-
});
|
|
874
|
-
break;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
case "auth_login": {
|
|
878
|
-
const { providerId } = command;
|
|
879
|
-
|
|
880
|
-
// Check if there's already an active login flow for this connection
|
|
881
|
-
for (const [_flowId, flow] of this.pendingLoginFlows.entries()) {
|
|
882
|
-
if (flow.ws === ws) {
|
|
883
|
-
this.sendAuthResponse(ws, {
|
|
884
|
-
id: commandId,
|
|
885
|
-
type: "response",
|
|
886
|
-
command: "auth_login",
|
|
887
|
-
success: false,
|
|
888
|
-
error: "Another login flow is already in progress",
|
|
889
|
-
});
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const loginFlowId = crypto.randomUUID();
|
|
895
|
-
const abortController = new AbortController();
|
|
896
|
-
|
|
897
|
-
// Store the flow
|
|
898
|
-
this.pendingLoginFlows.set(loginFlowId, {
|
|
899
|
-
ws,
|
|
900
|
-
inputResolver: null,
|
|
901
|
-
abortController,
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
// Send initial response with flow ID
|
|
905
|
-
this.sendAuthResponse(ws, {
|
|
906
|
-
id: commandId,
|
|
907
|
-
type: "response",
|
|
908
|
-
command: "auth_login",
|
|
909
|
-
success: true,
|
|
910
|
-
data: { loginFlowId },
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
// Start the OAuth/login flow
|
|
914
|
-
try {
|
|
915
|
-
const callbacks: OAuthLoginCallbacks = {
|
|
916
|
-
onAuth: (info) => {
|
|
917
|
-
this.sendAuthEvent(ws, loginFlowId, {
|
|
918
|
-
type: "auth_event",
|
|
919
|
-
loginFlowId,
|
|
920
|
-
event: "url",
|
|
921
|
-
url: info.url,
|
|
922
|
-
instructions: info.instructions,
|
|
923
|
-
});
|
|
924
|
-
},
|
|
925
|
-
onPrompt: async (prompt) => {
|
|
926
|
-
// Send prompt event and wait for client response
|
|
927
|
-
return new Promise<string>((resolve) => {
|
|
928
|
-
const flow = this.pendingLoginFlows.get(loginFlowId);
|
|
929
|
-
if (flow) {
|
|
930
|
-
flow.inputResolver = resolve;
|
|
931
|
-
this.sendAuthEvent(ws, loginFlowId, {
|
|
932
|
-
type: "auth_event",
|
|
933
|
-
loginFlowId,
|
|
934
|
-
event: "prompt",
|
|
935
|
-
message: prompt.message,
|
|
936
|
-
placeholder: prompt.placeholder,
|
|
937
|
-
});
|
|
938
|
-
} else {
|
|
939
|
-
resolve(""); // Flow was cancelled
|
|
940
|
-
}
|
|
941
|
-
});
|
|
942
|
-
},
|
|
943
|
-
onProgress: (message) => {
|
|
944
|
-
this.sendAuthEvent(ws, loginFlowId, {
|
|
945
|
-
type: "auth_event",
|
|
946
|
-
loginFlowId,
|
|
947
|
-
event: "progress",
|
|
948
|
-
message,
|
|
949
|
-
});
|
|
950
|
-
},
|
|
951
|
-
onManualCodeInput: async () => {
|
|
952
|
-
// Show manual input UI and wait for client response
|
|
953
|
-
this.sendAuthEvent(ws, loginFlowId, {
|
|
954
|
-
type: "auth_event",
|
|
955
|
-
loginFlowId,
|
|
956
|
-
event: "manual_input",
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
return new Promise<string>((resolve) => {
|
|
960
|
-
const flow = this.pendingLoginFlows.get(loginFlowId);
|
|
961
|
-
if (flow) {
|
|
962
|
-
flow.inputResolver = resolve;
|
|
963
|
-
} else {
|
|
964
|
-
resolve(""); // Flow was cancelled
|
|
965
|
-
}
|
|
966
|
-
});
|
|
967
|
-
},
|
|
968
|
-
signal: abortController.signal,
|
|
969
|
-
};
|
|
970
|
-
|
|
971
|
-
await authStorage.login(providerId, callbacks);
|
|
972
|
-
|
|
973
|
-
// Success
|
|
974
|
-
this.sendAuthEvent(ws, loginFlowId, {
|
|
975
|
-
type: "auth_event",
|
|
976
|
-
loginFlowId,
|
|
977
|
-
event: "complete",
|
|
978
|
-
success: true,
|
|
979
|
-
});
|
|
980
|
-
} catch (err) {
|
|
981
|
-
// Error or cancelled
|
|
982
|
-
const isAborted = err instanceof Error && err.name === "AbortError";
|
|
983
|
-
this.sendAuthEvent(ws, loginFlowId, {
|
|
984
|
-
type: "auth_event",
|
|
985
|
-
loginFlowId,
|
|
986
|
-
event: "complete",
|
|
987
|
-
success: false,
|
|
988
|
-
error: isAborted ? "Login cancelled" : err instanceof Error ? err.message : String(err),
|
|
989
|
-
});
|
|
990
|
-
} finally {
|
|
991
|
-
// Clean up
|
|
992
|
-
this.pendingLoginFlows.delete(loginFlowId);
|
|
993
|
-
}
|
|
994
|
-
break;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
case "auth_set_api_key": {
|
|
998
|
-
const { providerId, apiKey } = command;
|
|
999
|
-
authStorage.set(providerId, { type: "api_key", key: apiKey });
|
|
1000
|
-
|
|
1001
|
-
this.sendAuthResponse(ws, {
|
|
1002
|
-
id: commandId,
|
|
1003
|
-
type: "response",
|
|
1004
|
-
command: "auth_set_api_key",
|
|
1005
|
-
success: true,
|
|
1006
|
-
});
|
|
1007
|
-
break;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
case "auth_login_input": {
|
|
1011
|
-
const { loginFlowId, value } = command;
|
|
1012
|
-
const flow = this.pendingLoginFlows.get(loginFlowId);
|
|
1013
|
-
|
|
1014
|
-
if (flow?.inputResolver) {
|
|
1015
|
-
flow.inputResolver(value);
|
|
1016
|
-
flow.inputResolver = null;
|
|
1017
|
-
}
|
|
1018
|
-
// No response needed — this is fire-and-forget
|
|
1019
|
-
break;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
case "auth_login_cancel": {
|
|
1023
|
-
const { loginFlowId } = command;
|
|
1024
|
-
const flow = this.pendingLoginFlows.get(loginFlowId);
|
|
1025
|
-
|
|
1026
|
-
if (flow) {
|
|
1027
|
-
flow.abortController.abort();
|
|
1028
|
-
this.pendingLoginFlows.delete(loginFlowId);
|
|
1029
|
-
}
|
|
1030
|
-
// No response needed
|
|
1031
|
-
break;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
case "auth_logout": {
|
|
1035
|
-
const { providerId } = command;
|
|
1036
|
-
authStorage.logout(providerId);
|
|
1037
|
-
|
|
1038
|
-
this.sendAuthResponse(ws, {
|
|
1039
|
-
id: commandId,
|
|
1040
|
-
type: "response",
|
|
1041
|
-
command: "auth_logout",
|
|
1042
|
-
success: true,
|
|
1043
|
-
});
|
|
1044
|
-
break;
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
} catch (err) {
|
|
1048
|
-
this.sendAuthResponse(ws, {
|
|
1049
|
-
id: commandId,
|
|
1050
|
-
type: "response",
|
|
1051
|
-
command: command.type,
|
|
1052
|
-
success: false,
|
|
1053
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1054
|
-
});
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
private sendAuthEvent(ws: ServerWebSocket<ConnectionData>, _loginFlowId: string, event: AuthEvent): void {
|
|
1059
|
-
const message: OutboundWebMessage = {
|
|
1060
|
-
sessionId: "_auth",
|
|
1061
|
-
event,
|
|
1062
|
-
};
|
|
1063
|
-
ws.send(JSON.stringify(message));
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
private sendAuthResponse(ws: ServerWebSocket<ConnectionData>, response: RpcResponse): void {
|
|
1067
|
-
const message: OutboundWebMessage = {
|
|
1068
|
-
sessionId: "_auth",
|
|
1069
|
-
event: response,
|
|
1070
|
-
};
|
|
1071
|
-
ws.send(JSON.stringify(message));
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// ─── Session subscription ──────────────────────────────────────────────────
|
|
1075
|
-
|
|
1076
|
-
private subscribeToSessionWithKey(chatKey: string, session: AgentSession, ws: ServerWebSocket<ConnectionData>): void {
|
|
1077
|
-
// Track session with the chatKey (web_xxx)
|
|
1078
|
-
this.sessions.set(chatKey, session);
|
|
1079
|
-
|
|
1080
|
-
// Add connection to subscription set
|
|
1081
|
-
let subscribers = this.sessionSubscriptions.get(chatKey);
|
|
1082
|
-
if (!subscribers) {
|
|
1083
|
-
subscribers = new Set();
|
|
1084
|
-
this.sessionSubscriptions.set(chatKey, subscribers);
|
|
1085
|
-
}
|
|
1086
|
-
subscribers.add(ws);
|
|
1087
|
-
|
|
1088
|
-
// Subscribe to session events if not already subscribed
|
|
1089
|
-
if (!this.sessionUnsubscribers.has(chatKey)) {
|
|
1090
|
-
const unsubscribe = session.subscribe((event) => {
|
|
1091
|
-
this.broadcastEvent(chatKey, event);
|
|
1092
|
-
});
|
|
1093
|
-
this.sessionUnsubscribers.set(chatKey, unsubscribe);
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
private broadcastEvent(sessionId: string, event: AgentSessionEvent): void {
|
|
1098
|
-
const subscribers = this.sessionSubscriptions.get(sessionId);
|
|
1099
|
-
if (!subscribers) {
|
|
1100
|
-
console.log(`[web] No subscribers for session ${sessionId}, event ${event.type}`);
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
console.log(`[web] Broadcasting event ${event.type} to ${subscribers.size} subscribers for session ${sessionId}`);
|
|
1105
|
-
const message: OutboundWebMessage = { sessionId, event };
|
|
1106
|
-
const json = JSON.stringify(message);
|
|
1107
|
-
|
|
1108
|
-
for (const ws of subscribers) {
|
|
1109
|
-
try {
|
|
1110
|
-
ws.send(json);
|
|
1111
|
-
} catch (err) {
|
|
1112
|
-
console.error("[web] Failed to send event:", err);
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
// ─── Extension UI handling ─────────────────────────────────────────────────
|
|
1118
|
-
|
|
1119
|
-
private handleExtensionUIResponse(response: RpcExtensionUIResponse): void {
|
|
1120
|
-
const pending = this.pendingExtensionRequests.get(response.id);
|
|
1121
|
-
if (!pending) {
|
|
1122
|
-
console.warn(`[web] Received extension UI response for unknown request: ${response.id}`);
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
this.pendingExtensionRequests.delete(response.id);
|
|
1127
|
-
|
|
1128
|
-
// Forward response to the session's extension runtime
|
|
1129
|
-
// This is handled by the extension runtime's pending request map
|
|
1130
|
-
// We just need to route it back through the session
|
|
1131
|
-
|
|
1132
|
-
// For now, log a warning - full extension UI support needs more plumbing
|
|
1133
|
-
console.warn("[web] Extension UI responses not yet fully implemented");
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
1137
|
-
|
|
1138
|
-
/**
|
|
1139
|
-
* Extract title from a session directory by reading the last user message from JSONL files
|
|
1140
|
-
*/
|
|
1141
|
-
private getSessionTitleFromDisk(sessionPath: string): string | undefined {
|
|
1142
|
-
try {
|
|
1143
|
-
// Find the most recent .jsonl file
|
|
1144
|
-
const files = readdirSync(sessionPath)
|
|
1145
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
1146
|
-
.map((f) => ({
|
|
1147
|
-
name: f,
|
|
1148
|
-
path: join(sessionPath, f),
|
|
1149
|
-
mtime: statSync(join(sessionPath, f)).mtime.getTime(),
|
|
1150
|
-
}))
|
|
1151
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
1152
|
-
|
|
1153
|
-
if (files.length === 0) return undefined;
|
|
1154
|
-
|
|
1155
|
-
// Read the most recent file and parse JSONL
|
|
1156
|
-
const content = readFileSync(files[0].path, "utf-8");
|
|
1157
|
-
const lines = content.trim().split("\n");
|
|
1158
|
-
|
|
1159
|
-
// Find the last user message
|
|
1160
|
-
let lastUserMessage: string | undefined;
|
|
1161
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1162
|
-
try {
|
|
1163
|
-
const entry = JSON.parse(lines[i]);
|
|
1164
|
-
if (entry.type === "message" && entry.message?.role === "user") {
|
|
1165
|
-
// Extract text content
|
|
1166
|
-
const textContent = entry.message.content
|
|
1167
|
-
?.filter((c: any) => c.type === "text")
|
|
1168
|
-
.map((c: any) => c.text)
|
|
1169
|
-
.join(" ");
|
|
1170
|
-
if (textContent) {
|
|
1171
|
-
lastUserMessage = textContent.substring(0, 100);
|
|
1172
|
-
break;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
} catch {}
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
return lastUserMessage;
|
|
1179
|
-
} catch (_err) {
|
|
1180
|
-
return undefined;
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
private async listAllSessions(): Promise<Array<{ sessionId: string; title?: string; messageCount: number }>> {
|
|
1185
|
-
const sessions: Array<{ sessionId: string; title?: string; messageCount: number }> = [];
|
|
1186
|
-
const sessionsDir = join(getAppDir(), "sessions");
|
|
1187
|
-
|
|
1188
|
-
if (!existsSync(sessionsDir)) {
|
|
1189
|
-
return sessions;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
try {
|
|
1193
|
-
// Get all web_* session directories
|
|
1194
|
-
const dirs = readdirSync(sessionsDir);
|
|
1195
|
-
const webSessions = dirs
|
|
1196
|
-
.filter((dir) => dir.startsWith("web_"))
|
|
1197
|
-
.map((dir) => ({ sessionId: dir, path: join(sessionsDir, dir) }))
|
|
1198
|
-
.filter(({ path }) => {
|
|
1199
|
-
try {
|
|
1200
|
-
return statSync(path).isDirectory();
|
|
1201
|
-
} catch {
|
|
1202
|
-
return false;
|
|
1203
|
-
}
|
|
1204
|
-
})
|
|
1205
|
-
// Sort by modification time, newest first
|
|
1206
|
-
.sort((a, b) => {
|
|
1207
|
-
try {
|
|
1208
|
-
const aMtime = statSync(a.path).mtime.getTime();
|
|
1209
|
-
const bMtime = statSync(b.path).mtime.getTime();
|
|
1210
|
-
return bMtime - aMtime;
|
|
1211
|
-
} catch {
|
|
1212
|
-
return 0;
|
|
1213
|
-
}
|
|
1214
|
-
});
|
|
1215
|
-
|
|
1216
|
-
// For each session directory, check if it's in memory or read from disk
|
|
1217
|
-
for (const { sessionId, path } of webSessions) {
|
|
1218
|
-
const inMemorySession = this.sessions.get(sessionId);
|
|
1219
|
-
|
|
1220
|
-
if (inMemorySession) {
|
|
1221
|
-
// Use in-memory session data
|
|
1222
|
-
// Get the last user message as the title (like pi's /resume command)
|
|
1223
|
-
const lastUserMessage = [...inMemorySession.messages].reverse().find((msg) => msg.role === "user");
|
|
1224
|
-
|
|
1225
|
-
let title: string | undefined;
|
|
1226
|
-
if (lastUserMessage) {
|
|
1227
|
-
// Extract text from content
|
|
1228
|
-
if (typeof lastUserMessage.content === "string") {
|
|
1229
|
-
title = lastUserMessage.content.substring(0, 100);
|
|
1230
|
-
} else if (Array.isArray(lastUserMessage.content)) {
|
|
1231
|
-
const textContent = lastUserMessage.content
|
|
1232
|
-
.filter((c: any) => c.type === "text")
|
|
1233
|
-
.map((c: any) => c.text)
|
|
1234
|
-
.join(" ");
|
|
1235
|
-
title = textContent?.substring(0, 100) || inMemorySession.sessionName;
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
if (!title) {
|
|
1240
|
-
title = inMemorySession.sessionName;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
sessions.push({
|
|
1244
|
-
sessionId,
|
|
1245
|
-
title,
|
|
1246
|
-
messageCount: inMemorySession.messages.length,
|
|
1247
|
-
});
|
|
1248
|
-
} else {
|
|
1249
|
-
// For sessions not in memory, read title from disk
|
|
1250
|
-
const title = this.getSessionTitleFromDisk(path);
|
|
1251
|
-
|
|
1252
|
-
sessions.push({
|
|
1253
|
-
sessionId,
|
|
1254
|
-
title,
|
|
1255
|
-
messageCount: 0, // We don't count messages for sessions not in memory
|
|
1256
|
-
});
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
} catch (err) {
|
|
1260
|
-
console.error("[web] Failed to list sessions:", err);
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
return sessions;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
private sendResponse(
|
|
1267
|
-
ws: ServerWebSocket<ConnectionData>,
|
|
1268
|
-
sessionId: string | undefined,
|
|
1269
|
-
response: RpcResponse,
|
|
1270
|
-
): void {
|
|
1271
|
-
if (!sessionId) {
|
|
1272
|
-
// Special case for responses without session context
|
|
1273
|
-
ws.send(JSON.stringify(response));
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
const message: OutboundWebMessage = { sessionId, event: response };
|
|
1278
|
-
ws.send(JSON.stringify(message));
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
private sendError(
|
|
1282
|
-
ws: ServerWebSocket<ConnectionData>,
|
|
1283
|
-
sessionId: string | undefined,
|
|
1284
|
-
command: string,
|
|
1285
|
-
error: string,
|
|
1286
|
-
commandId?: string,
|
|
1287
|
-
): void {
|
|
1288
|
-
const response: RpcResponse = {
|
|
1289
|
-
id: commandId,
|
|
1290
|
-
type: "response",
|
|
1291
|
-
command,
|
|
1292
|
-
success: false,
|
|
1293
|
-
error,
|
|
1294
|
-
};
|
|
1295
|
-
this.sendResponse(ws, sessionId, response);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
// ─── Static file serving ───────────────────────────────────────────────────
|
|
1299
|
-
|
|
1300
|
-
private async serveStaticFile(req: Request): Promise<Response> {
|
|
1301
|
-
if (!this.options.staticDir) {
|
|
1302
|
-
return new Response("Not Found", { status: 404 });
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
try {
|
|
1306
|
-
const url = new URL(req.url);
|
|
1307
|
-
let pathname = url.pathname;
|
|
1308
|
-
|
|
1309
|
-
// Remove leading slash
|
|
1310
|
-
if (pathname.startsWith("/")) {
|
|
1311
|
-
pathname = pathname.substring(1);
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
// Default to index for root
|
|
1315
|
-
if (pathname === "" || pathname === "/") {
|
|
1316
|
-
pathname = "_shell.html";
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
// Try to serve the requested file
|
|
1320
|
-
const filePath = join(this.options.staticDir, pathname);
|
|
1321
|
-
|
|
1322
|
-
// Security: ensure the resolved path is within staticDir (prevent directory traversal)
|
|
1323
|
-
const { resolve } = await import("node:path");
|
|
1324
|
-
const resolvedPath = resolve(this.options.staticDir, pathname);
|
|
1325
|
-
if (!resolvedPath.startsWith(resolve(this.options.staticDir))) {
|
|
1326
|
-
return new Response("Forbidden", { status: 403 });
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
// Check if file exists
|
|
1330
|
-
if (existsSync(resolvedPath) && statSync(resolvedPath).isFile()) {
|
|
1331
|
-
const file = Bun.file(resolvedPath);
|
|
1332
|
-
|
|
1333
|
-
// Set caching headers for hashed assets
|
|
1334
|
-
const headers = new Headers();
|
|
1335
|
-
if (pathname.startsWith("assets/")) {
|
|
1336
|
-
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
1337
|
-
} else {
|
|
1338
|
-
headers.set("Cache-Control", "public, max-age=3600");
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
return new Response(file, { headers });
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
// SPA fallback: serve _shell.html for non-file routes
|
|
1345
|
-
const shellPath = join(this.options.staticDir, "_shell.html");
|
|
1346
|
-
if (existsSync(shellPath)) {
|
|
1347
|
-
const file = Bun.file(shellPath);
|
|
1348
|
-
return new Response(file, {
|
|
1349
|
-
headers: {
|
|
1350
|
-
"Content-Type": "text/html",
|
|
1351
|
-
"Cache-Control": "no-cache",
|
|
1352
|
-
},
|
|
1353
|
-
});
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
return new Response("Not Found", { status: 404 });
|
|
1357
|
-
} catch (err) {
|
|
1358
|
-
console.error("[web] Error serving static file:", err);
|
|
1359
|
-
return new Response("Internal Server Error", { status: 500 });
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
}
|