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