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.
@@ -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 { join } from "node:path";
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 type { ServerWebSocket } from "bun";
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: ReturnType<typeof Bun.serve> | null = null;
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<ServerWebSocket<ConnectionData>>>();
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: ServerWebSocket<ConnectionData> }>();
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: ServerWebSocket<ConnectionData>;
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(handler: MessageHandler): Promise<void> {
172
- this.handler = handler;
172
+ async start(_handler: MessageHandler): Promise<void> {
173
+ const app = new Hono();
173
174
 
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";
175
+ // Create WebSocket adapter
176
+ const { injectWebSocket, upgradeWebSocket: wsUpgrade } = createNodeWebSocket({ app });
183
177
 
184
- // ─── WebSocket upgrade path ───────────────────────────────────────
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
- 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, "");
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
- // 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");
190
+ const token = headerToken || queryToken;
194
191
 
195
- const token = headerToken || queryToken;
192
+ if (token !== this.options.authToken) {
193
+ return c.text("Unauthorized", 401);
194
+ }
196
195
 
197
- if (token !== this.options.authToken) {
198
- return new Response("Unauthorized", { status: 401 });
199
- }
196
+ // ─── Origin validation ────────────────────────────────────────
200
197
 
201
- // ─── Origin validation ────────────────────────────────────────
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
- // 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");
203
+ if (!origin || !host) {
204
+ return c.text("Forbidden - missing headers", 403);
205
+ }
207
206
 
208
- if (!origin || !host) {
209
- return new Response("Forbidden - missing headers", { status: 403 });
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 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 });
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] Invalid Origin header:", err);
221
- return new Response("Forbidden - invalid origin", { status: 403 });
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
- // 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 });
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
- // Upgrade to WebSocket
233
- const upgraded = server.upgrade(req, {
234
- data: { authenticated: true } as ConnectionData,
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
- if (!upgraded) {
238
- return new Response("WebSocket upgrade failed", { status: 400 });
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
- // biome-ignore lint/suspicious/noExplicitAny: Bun requires undefined return after upgrade
242
- return undefined as any; // upgrade successful
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
- // ─── Static file serving path ─────────────────────────────────────
320
+ const contentType = contentTypes[ext || ""] || "application/octet-stream";
246
321
 
247
- if (this.options.staticDir) {
248
- return this.serveStaticFile(req);
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
- // ─── No static dir configured — reject non-WebSocket requests ─────
327
+ return new Response(content, {
328
+ headers: {
329
+ "Content-Type": contentType,
330
+ "Cache-Control": cacheControl,
331
+ },
332
+ });
333
+ }
252
334
 
253
- return new Response("Upgrade Required - this endpoint only accepts WebSocket connections", {
254
- status: 426,
255
- headers: { Upgrade: "websocket" },
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
- this.server.stop();
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: ServerWebSocket<ConnectionData>, inbound: InboundWebMessage): Promise<void> {
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
- 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
- }));
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: ServerWebSocket<ConnectionData>, _loginFlowId: string, event: AuthEvent): void {
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: ServerWebSocket<ConnectionData>, response: RpcResponse): void {
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: ServerWebSocket<ConnectionData>): void {
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: ServerWebSocket<ConnectionData>,
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 bun
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.dir, "..", "package.json");
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.dir → <package>/src/ at runtime
108
- const packageRoot = join(import.meta.dir, "..");
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 = activeSessionNames.get(chatIdentifier) ?? "default";
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, personaName);
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(