clankie 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -17
- package/package.json +14 -5
- package/src/agent.ts +11 -0
- package/src/channels/slack.ts +3 -1
- package/src/channels/web.ts +218 -204
- package/src/cli.ts +2 -2
- package/src/config.ts +6 -2
- package/src/daemon.ts +2 -2
- package/src/extensions/workspace-jail.ts +171 -0
- package/src/service.ts +3 -1
- package/src/sessions.ts +11 -0
- 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/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
CHANGED
|
@@ -11,11 +11,15 @@
|
|
|
11
11
|
|
|
12
12
|
import * as crypto from "node:crypto";
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
|
-
import {
|
|
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";
|
|
15
18
|
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
|
16
19
|
import type { ImageContent, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
|
|
17
20
|
import { type AgentSession, type AgentSessionEvent, AuthStorage } from "@mariozechner/pi-coding-agent";
|
|
18
|
-
import
|
|
21
|
+
import { Hono } from "hono";
|
|
22
|
+
import type { WSContext } from "hono/ws";
|
|
19
23
|
import { getAppDir, getAuthPath, loadConfig } from "../config.ts";
|
|
20
24
|
import { getOrCreateSession } from "../sessions.ts";
|
|
21
25
|
import type { Channel, MessageHandler } from "./channel.ts";
|
|
@@ -80,6 +84,7 @@ type RpcCommand =
|
|
|
80
84
|
| { id?: string; type: "get_extensions" }
|
|
81
85
|
| { id?: string; type: "get_skills" }
|
|
82
86
|
| { id?: string; type: "install_package"; source: string; local?: boolean }
|
|
87
|
+
| { id?: string; type: "reload" }
|
|
83
88
|
| { id?: string; type: "get_auth_providers" }
|
|
84
89
|
| { id?: string; type: "auth_login"; providerId: string }
|
|
85
90
|
| { id?: string; type: "auth_set_api_key"; providerId: string; apiKey: string }
|
|
@@ -131,19 +136,15 @@ type RpcExtensionUIResponse =
|
|
|
131
136
|
| { type: "extension_ui_response"; id: string; confirmed: boolean }
|
|
132
137
|
| { type: "extension_ui_response"; id: string; cancelled: true };
|
|
133
138
|
|
|
134
|
-
interface ConnectionData {
|
|
135
|
-
authenticated: boolean;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
139
|
// ─── WebChannel ────────────────────────────────────────────────────────────────
|
|
139
140
|
|
|
140
141
|
export class WebChannel implements Channel {
|
|
141
142
|
readonly name = "web";
|
|
142
143
|
private options: WebChannelOptions;
|
|
143
|
-
private server:
|
|
144
|
+
private server: Server | null = null;
|
|
144
145
|
|
|
145
146
|
/** Map of sessionId → Set of WebSocket connections subscribed to that session */
|
|
146
|
-
private sessionSubscriptions = new Map<string, Set<
|
|
147
|
+
private sessionSubscriptions = new Map<string, Set<WSContext>>();
|
|
147
148
|
|
|
148
149
|
/** Map of sessionId → AgentSession */
|
|
149
150
|
private sessions = new Map<string, AgentSession>();
|
|
@@ -152,13 +153,13 @@ export class WebChannel implements Channel {
|
|
|
152
153
|
private sessionUnsubscribers = new Map<string, () => void>();
|
|
153
154
|
|
|
154
155
|
/** Pending extension UI requests: Map<requestId, { sessionId, ws }> */
|
|
155
|
-
private pendingExtensionRequests = new Map<string, { sessionId: string; ws:
|
|
156
|
+
private pendingExtensionRequests = new Map<string, { sessionId: string; ws: WSContext }>();
|
|
156
157
|
|
|
157
158
|
/** Pending auth login flows: Map<loginFlowId, { ws, inputResolver, abortController }> */
|
|
158
159
|
private pendingLoginFlows = new Map<
|
|
159
160
|
string,
|
|
160
161
|
{
|
|
161
|
-
ws:
|
|
162
|
+
ws: WSContext;
|
|
162
163
|
inputResolver: ((value: string) => void) | null;
|
|
163
164
|
abortController: AbortController;
|
|
164
165
|
}
|
|
@@ -168,94 +169,209 @@ export class WebChannel implements Channel {
|
|
|
168
169
|
this.options = options;
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
async start(
|
|
172
|
-
|
|
172
|
+
async start(_handler: MessageHandler): Promise<void> {
|
|
173
|
+
const app = new Hono();
|
|
173
174
|
|
|
174
|
-
|
|
175
|
-
|
|
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";
|
|
175
|
+
// Create WebSocket adapter
|
|
176
|
+
const { injectWebSocket, upgradeWebSocket: wsUpgrade } = createNodeWebSocket({ app });
|
|
183
177
|
|
|
184
|
-
|
|
178
|
+
// ─── WebSocket route ──────────────────────────────────────────────────
|
|
179
|
+
// Note: upgradeWebSocket() handles WebSocket upgrade requests at the root path
|
|
180
|
+
// Regular HTTP requests fall through to static file serving
|
|
185
181
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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");
|
|
190
189
|
|
|
191
|
-
|
|
192
|
-
const url = new URL(req.url, `http://${req.headers.get("host")}`);
|
|
193
|
-
const queryToken = url.searchParams.get("token");
|
|
190
|
+
const token = headerToken || queryToken;
|
|
194
191
|
|
|
195
|
-
|
|
192
|
+
if (token !== this.options.authToken) {
|
|
193
|
+
return c.text("Unauthorized", 401);
|
|
194
|
+
}
|
|
196
195
|
|
|
197
|
-
|
|
198
|
-
return new Response("Unauthorized", { status: 401 });
|
|
199
|
-
}
|
|
196
|
+
// ─── Origin validation ────────────────────────────────────────
|
|
200
197
|
|
|
201
|
-
|
|
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
202
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const host = req.headers.get("Host");
|
|
203
|
+
if (!origin || !host) {
|
|
204
|
+
return c.text("Forbidden - missing headers", 403);
|
|
205
|
+
}
|
|
207
206
|
|
|
208
|
-
|
|
209
|
-
|
|
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);
|
|
210
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
|
+
}
|
|
211
226
|
|
|
227
|
+
// Return WebSocket handlers
|
|
228
|
+
return {
|
|
229
|
+
onOpen: (_evt, _ws) => {
|
|
230
|
+
console.log("[web] Client connected");
|
|
231
|
+
},
|
|
232
|
+
onMessage: async (evt, ws) => {
|
|
212
233
|
try {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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;
|
|
218
241
|
}
|
|
242
|
+
|
|
243
|
+
// Handle RPC commands
|
|
244
|
+
const inbound = parsed as InboundWebMessage;
|
|
245
|
+
await this.handleCommand(ws, inbound);
|
|
219
246
|
} catch (err) {
|
|
220
|
-
console.error("[web]
|
|
221
|
-
|
|
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
|
+
);
|
|
222
254
|
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
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);
|
|
230
281
|
}
|
|
231
282
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
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);
|
|
236
290
|
|
|
237
|
-
|
|
238
|
-
|
|
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);
|
|
239
295
|
}
|
|
240
296
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
};
|
|
244
319
|
|
|
245
|
-
|
|
320
|
+
const contentType = contentTypes[ext || ""] || "application/octet-stream";
|
|
246
321
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
322
|
+
// Set caching headers for hashed assets
|
|
323
|
+
const cacheControl = pathname.startsWith("assets/")
|
|
324
|
+
? "public, max-age=31536000, immutable"
|
|
325
|
+
: "public, max-age=3600";
|
|
250
326
|
|
|
251
|
-
|
|
327
|
+
return new Response(content, {
|
|
328
|
+
headers: {
|
|
329
|
+
"Content-Type": contentType,
|
|
330
|
+
"Cache-Control": cacheControl,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
252
334
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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",
|
|
256
358
|
});
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── Start server ─────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
this.server = serve(
|
|
365
|
+
{
|
|
366
|
+
fetch: app.fetch,
|
|
367
|
+
port: this.options.port,
|
|
257
368
|
},
|
|
258
|
-
|
|
369
|
+
(info) => {
|
|
370
|
+
console.log(`[web] Server started on ${info.address}:${info.port}`);
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
injectWebSocket(this.server);
|
|
259
375
|
|
|
260
376
|
console.log(`[web] WebSocket server listening on port ${this.options.port}`);
|
|
261
377
|
console.log(`[web] Open in browser: http://localhost:${this.options.port}?token=${this.options.authToken}`);
|
|
@@ -267,7 +383,9 @@ export class WebChannel implements Channel {
|
|
|
267
383
|
|
|
268
384
|
async stop(): Promise<void> {
|
|
269
385
|
if (this.server) {
|
|
270
|
-
|
|
386
|
+
await new Promise<void>((resolve) => {
|
|
387
|
+
this.server?.close(() => resolve());
|
|
388
|
+
});
|
|
271
389
|
this.server = null;
|
|
272
390
|
}
|
|
273
391
|
|
|
@@ -281,54 +399,9 @@ export class WebChannel implements Channel {
|
|
|
281
399
|
console.log("[web] WebSocket server stopped");
|
|
282
400
|
}
|
|
283
401
|
|
|
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
402
|
// ─── Command handling ──────────────────────────────────────────────────────
|
|
330
403
|
|
|
331
|
-
private async handleCommand(ws:
|
|
404
|
+
private async handleCommand(ws: WSContext, inbound: InboundWebMessage): Promise<void> {
|
|
332
405
|
const command = inbound.command;
|
|
333
406
|
const commandId = command.id;
|
|
334
407
|
|
|
@@ -715,14 +788,18 @@ export class WebChannel implements Channel {
|
|
|
715
788
|
|
|
716
789
|
case "get_extensions": {
|
|
717
790
|
const extensionsResult = session.resourceLoader.getExtensions();
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
+
}));
|
|
726
803
|
|
|
727
804
|
return {
|
|
728
805
|
id,
|
|
@@ -805,6 +882,11 @@ export class WebChannel implements Channel {
|
|
|
805
882
|
}
|
|
806
883
|
}
|
|
807
884
|
|
|
885
|
+
case "reload": {
|
|
886
|
+
await session.reload();
|
|
887
|
+
return { id, type: "response", command: "reload", success: true };
|
|
888
|
+
}
|
|
889
|
+
|
|
808
890
|
default: {
|
|
809
891
|
// biome-ignore lint/suspicious/noExplicitAny: Need to access .type property on unknown command
|
|
810
892
|
const unknownCommand = command as any;
|
|
@@ -821,11 +903,7 @@ export class WebChannel implements Channel {
|
|
|
821
903
|
|
|
822
904
|
// ─── Auth command handling ─────────────────────────────────────────────────
|
|
823
905
|
|
|
824
|
-
private async handleAuthCommand(
|
|
825
|
-
ws: ServerWebSocket<ConnectionData>,
|
|
826
|
-
command: RpcCommand,
|
|
827
|
-
commandId?: string,
|
|
828
|
-
): Promise<void> {
|
|
906
|
+
private async handleAuthCommand(ws: WSContext, command: RpcCommand, commandId?: string): Promise<void> {
|
|
829
907
|
const authStorage = AuthStorage.create(getAuthPath());
|
|
830
908
|
|
|
831
909
|
try {
|
|
@@ -1055,7 +1133,7 @@ export class WebChannel implements Channel {
|
|
|
1055
1133
|
}
|
|
1056
1134
|
}
|
|
1057
1135
|
|
|
1058
|
-
private sendAuthEvent(ws:
|
|
1136
|
+
private sendAuthEvent(ws: WSContext, _loginFlowId: string, event: AuthEvent): void {
|
|
1059
1137
|
const message: OutboundWebMessage = {
|
|
1060
1138
|
sessionId: "_auth",
|
|
1061
1139
|
event,
|
|
@@ -1063,7 +1141,7 @@ export class WebChannel implements Channel {
|
|
|
1063
1141
|
ws.send(JSON.stringify(message));
|
|
1064
1142
|
}
|
|
1065
1143
|
|
|
1066
|
-
private sendAuthResponse(ws:
|
|
1144
|
+
private sendAuthResponse(ws: WSContext, response: RpcResponse): void {
|
|
1067
1145
|
const message: OutboundWebMessage = {
|
|
1068
1146
|
sessionId: "_auth",
|
|
1069
1147
|
event: response,
|
|
@@ -1073,7 +1151,7 @@ export class WebChannel implements Channel {
|
|
|
1073
1151
|
|
|
1074
1152
|
// ─── Session subscription ──────────────────────────────────────────────────
|
|
1075
1153
|
|
|
1076
|
-
private subscribeToSessionWithKey(chatKey: string, session: AgentSession, ws:
|
|
1154
|
+
private subscribeToSessionWithKey(chatKey: string, session: AgentSession, ws: WSContext): void {
|
|
1077
1155
|
// Track session with the chatKey (web_xxx)
|
|
1078
1156
|
this.sessions.set(chatKey, session);
|
|
1079
1157
|
|
|
@@ -1163,8 +1241,10 @@ export class WebChannel implements Channel {
|
|
|
1163
1241
|
const entry = JSON.parse(lines[i]);
|
|
1164
1242
|
if (entry.type === "message" && entry.message?.role === "user") {
|
|
1165
1243
|
// Extract text content
|
|
1244
|
+
// biome-ignore lint/suspicious/noExplicitAny: Parsing opaque JSONL session data
|
|
1166
1245
|
const textContent = entry.message.content
|
|
1167
1246
|
?.filter((c: any) => c.type === "text")
|
|
1247
|
+
// biome-ignore lint/suspicious/noExplicitAny: Parsing opaque JSONL session data
|
|
1168
1248
|
.map((c: any) => c.text)
|
|
1169
1249
|
.join(" ");
|
|
1170
1250
|
if (textContent) {
|
|
@@ -1228,8 +1308,10 @@ export class WebChannel implements Channel {
|
|
|
1228
1308
|
if (typeof lastUserMessage.content === "string") {
|
|
1229
1309
|
title = lastUserMessage.content.substring(0, 100);
|
|
1230
1310
|
} else if (Array.isArray(lastUserMessage.content)) {
|
|
1311
|
+
// biome-ignore lint/suspicious/noExplicitAny: Filtering message content union type
|
|
1231
1312
|
const textContent = lastUserMessage.content
|
|
1232
1313
|
.filter((c: any) => c.type === "text")
|
|
1314
|
+
// biome-ignore lint/suspicious/noExplicitAny: Filtering message content union type
|
|
1233
1315
|
.map((c: any) => c.text)
|
|
1234
1316
|
.join(" ");
|
|
1235
1317
|
title = textContent?.substring(0, 100) || inMemorySession.sessionName;
|
|
@@ -1263,11 +1345,7 @@ export class WebChannel implements Channel {
|
|
|
1263
1345
|
return sessions;
|
|
1264
1346
|
}
|
|
1265
1347
|
|
|
1266
|
-
private sendResponse(
|
|
1267
|
-
ws: ServerWebSocket<ConnectionData>,
|
|
1268
|
-
sessionId: string | undefined,
|
|
1269
|
-
response: RpcResponse,
|
|
1270
|
-
): void {
|
|
1348
|
+
private sendResponse(ws: WSContext, sessionId: string | undefined, response: RpcResponse): void {
|
|
1271
1349
|
if (!sessionId) {
|
|
1272
1350
|
// Special case for responses without session context
|
|
1273
1351
|
ws.send(JSON.stringify(response));
|
|
@@ -1279,7 +1357,7 @@ export class WebChannel implements Channel {
|
|
|
1279
1357
|
}
|
|
1280
1358
|
|
|
1281
1359
|
private sendError(
|
|
1282
|
-
ws:
|
|
1360
|
+
ws: WSContext,
|
|
1283
1361
|
sessionId: string | undefined,
|
|
1284
1362
|
command: string,
|
|
1285
1363
|
error: string,
|
|
@@ -1294,68 +1372,4 @@ export class WebChannel implements Channel {
|
|
|
1294
1372
|
};
|
|
1295
1373
|
this.sendResponse(ws, sessionId, response);
|
|
1296
1374
|
}
|
|
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 resolvedPath = Bun.resolveSync(filePath, this.options.staticDir);
|
|
1324
|
-
if (!resolvedPath.startsWith(this.options.staticDir)) {
|
|
1325
|
-
return new Response("Forbidden", { status: 403 });
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// Check if file exists
|
|
1329
|
-
if (existsSync(resolvedPath) && statSync(resolvedPath).isFile()) {
|
|
1330
|
-
const file = Bun.file(resolvedPath);
|
|
1331
|
-
|
|
1332
|
-
// Set caching headers for hashed assets
|
|
1333
|
-
const headers = new Headers();
|
|
1334
|
-
if (pathname.startsWith("assets/")) {
|
|
1335
|
-
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
1336
|
-
} else {
|
|
1337
|
-
headers.set("Cache-Control", "public, max-age=3600");
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
return new Response(file, { headers });
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
// SPA fallback: serve _shell.html for non-file routes
|
|
1344
|
-
const shellPath = join(this.options.staticDir, "_shell.html");
|
|
1345
|
-
if (existsSync(shellPath)) {
|
|
1346
|
-
const file = Bun.file(shellPath);
|
|
1347
|
-
return new Response(file, {
|
|
1348
|
-
headers: {
|
|
1349
|
-
"Content-Type": "text/html",
|
|
1350
|
-
"Cache-Control": "no-cache",
|
|
1351
|
-
},
|
|
1352
|
-
});
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
return new Response("Not Found", { status: 404 });
|
|
1356
|
-
} catch (err) {
|
|
1357
|
-
console.error("[web] Error serving static file:", err);
|
|
1358
|
-
return new Response("Internal Server Error", { status: 500 });
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
1375
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* clankie — CLI entry point
|
|
@@ -105,7 +105,7 @@ Credentials are stored at ~/.clankie/auth.json (separate from pi's auth).
|
|
|
105
105
|
|
|
106
106
|
function printVersion(): void {
|
|
107
107
|
// Read version from package.json at repo root (../ from src/)
|
|
108
|
-
const packagePath = join(import.meta.
|
|
108
|
+
const packagePath = join(import.meta.dirname, "..", "package.json");
|
|
109
109
|
try {
|
|
110
110
|
const pkg = JSON.parse(readFileSync(packagePath, "utf-8"));
|
|
111
111
|
console.log(`clankie ${pkg.version}`);
|
package/src/config.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface AppConfig {
|
|
|
22
22
|
workspace?: string;
|
|
23
23
|
/** Override for pi's agent dir (default: ~/.clankie) */
|
|
24
24
|
agentDir?: string;
|
|
25
|
+
/** Restrict agent to workspace directory (default: true) */
|
|
26
|
+
restrictToWorkspace?: boolean;
|
|
27
|
+
/** Additional paths outside workspace that are allowed (e.g. ["/tmp"]) */
|
|
28
|
+
allowedPaths?: string[];
|
|
25
29
|
/** Model configuration */
|
|
26
30
|
model?: {
|
|
27
31
|
/** Primary model in provider/model format (e.g. "anthropic/claude-sonnet-4-5") */
|
|
@@ -104,8 +108,8 @@ export function getConfigPath(): string {
|
|
|
104
108
|
* Returns the path if found, undefined otherwise.
|
|
105
109
|
*/
|
|
106
110
|
export function getBundledWebUiDir(): string | undefined {
|
|
107
|
-
// import.meta.
|
|
108
|
-
const packageRoot = join(import.meta.
|
|
111
|
+
// import.meta.dirname → <package>/src/ at runtime (Node 21+)
|
|
112
|
+
const packageRoot = join(import.meta.dirname, "..");
|
|
109
113
|
const bundledDir = join(packageRoot, "web-ui-dist");
|
|
110
114
|
if (existsSync(bundledDir) && existsSync(join(bundledDir, "_shell.html"))) {
|
|
111
115
|
return bundledDir;
|
package/src/daemon.ts
CHANGED
|
@@ -134,7 +134,7 @@ async function processMessage(
|
|
|
134
134
|
return;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
const currentSession =
|
|
137
|
+
const currentSession = getActiveSessionName(chatIdentifier);
|
|
138
138
|
const sessionList = chatSessions
|
|
139
139
|
.map((name) => (name === currentSession ? `• ${name} ✓ (active)` : `• ${name}`))
|
|
140
140
|
.join("\n");
|
|
@@ -149,7 +149,7 @@ async function processMessage(
|
|
|
149
149
|
|
|
150
150
|
// Handle /new command — reset current session
|
|
151
151
|
if (trimmed === "/new") {
|
|
152
|
-
const session = await getOrCreateSession(chatKey, config
|
|
152
|
+
const session = await getOrCreateSession(chatKey, config);
|
|
153
153
|
await session.newSession();
|
|
154
154
|
console.log(`[daemon] Session reset for ${chatKey}`);
|
|
155
155
|
await channel.send(
|