@jxsuite/server 0.32.0 → 0.33.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/server",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,7 @@
14
14
  "type": "module",
15
15
  "exports": {
16
16
  ".": "./src/server.ts",
17
- "./claude-session": "./src/claude-session.ts",
17
+ "./ai-api": "./src/ai-api.ts",
18
18
  "./packages": "./src/packages.ts",
19
19
  "./refactor": "./src/refactor/index.ts",
20
20
  "./resolve": "./src/resolve.ts",
@@ -29,14 +29,11 @@
29
29
  "test:coverage": "bun test --isolate --coverage"
30
30
  },
31
31
  "dependencies": {
32
- "@anthropic-ai/claude-agent-sdk": "^0.3.190",
33
- "@anthropic-ai/sdk": "^0.105.0",
34
- "@jxsuite/compiler": "^0.32.0",
35
- "@jxsuite/create": "^0.32.0",
36
- "@jxsuite/parser": "^0.32.0",
37
- "@jxsuite/runtime": "^0.32.0",
38
- "@jxsuite/schema": "^0.32.0",
39
- "@modelcontextprotocol/sdk": "^1.29.0",
32
+ "@jxsuite/compiler": "^0.33.0",
33
+ "@jxsuite/create": "^0.33.0",
34
+ "@jxsuite/parser": "^0.33.0",
35
+ "@jxsuite/runtime": "^0.33.0",
36
+ "@jxsuite/schema": "^0.33.0",
40
37
  "chokidar": "^5.0.0",
41
38
  "zod": "^4.4.3"
42
39
  }
package/src/ai-api.ts ADDED
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Ai-api.js — AI proxy endpoints for Jx Studio
3
+ *
4
+ * Handles /__studio/ai/chat (SSE streaming proxy to OpenAI) and /__studio/ai/models.
5
+ * The server acts as a thin proxy: validates the request shape, forwards to OpenAI,
6
+ * normalizes the SSE stream into StreamEvent-compatible format, and pipes back.
7
+ *
8
+ * API key flow:
9
+ * 1. Request header X-Api-Key or Authorization: Bearer <key>
10
+ * 2. Fallback: OPENAI_API_KEY env var
11
+ * 3. If neither → 401 with error message
12
+ *
13
+ * Base URL flow:
14
+ * 1. Request header X-Api-Base-URL
15
+ * 2. Fallback: OPENAI_BASE_URL env var
16
+ * 3. Default: https://api.openai.com/v1
17
+ *
18
+ * @license MIT
19
+ */
20
+
21
+ // ─── Configuration ───────────────────────────────────────────────────────────
22
+
23
+ const DEFAULT_BASE_URL = "https://api.openai.com/v1";
24
+
25
+ /** A tool-call fragment inside an OpenAI streaming `delta`. */
26
+ interface ToolCallDelta {
27
+ index: number;
28
+ id?: string;
29
+ type?: string;
30
+ function?: { name?: string; arguments?: string };
31
+ }
32
+
33
+ /** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
34
+ interface OpenAIStreamChunk {
35
+ choices?: {
36
+ index?: number;
37
+ delta?: { content?: string; tool_calls?: ToolCallDelta[] };
38
+ finish_reason?: string | null;
39
+ }[];
40
+ }
41
+
42
+ /** A model entry from the upstream `/models` listing. */
43
+ interface ModelEntry {
44
+ id: string;
45
+ context_window?: number;
46
+ owned_by?: string;
47
+ }
48
+
49
+ function getConfig(req: Request): { apiKey: string | null; baseUrl: string; missingKey: boolean } {
50
+ const authHeader = req.headers.get("Authorization") || "";
51
+ const apiKeyHeader = req.headers.get("X-Api-Key") || "";
52
+ const baseUrlHeader = req.headers.get("X-Api-Base-URL") || "";
53
+
54
+ let apiKey = null;
55
+
56
+ // Check Authorization: Bearer <key>
57
+ if (authHeader.startsWith("Bearer ")) {
58
+ apiKey = authHeader.slice(7).trim();
59
+ }
60
+
61
+ // Check X-Api-Key header (overrides Bearer)
62
+ if (apiKeyHeader) {
63
+ apiKey = apiKeyHeader.trim();
64
+ }
65
+
66
+ // Check env var (fallback)
67
+ if (!apiKey && process.env.OPENAI_API_KEY) {
68
+ apiKey = process.env.OPENAI_API_KEY;
69
+ }
70
+
71
+ // Check base URL
72
+ const baseUrl = baseUrlHeader || process.env.OPENAI_BASE_URL || DEFAULT_BASE_URL;
73
+
74
+ return { apiKey, baseUrl, missingKey: !apiKey };
75
+ }
76
+
77
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
78
+
79
+ /** Write an SSE event to the response stream. */
80
+ function writeSSE(controller: ReadableStreamDefaultController, event: unknown): void {
81
+ const data = JSON.stringify(event);
82
+ controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
83
+ }
84
+
85
+ /** Write a JSON error response (for non-streaming endpoints and error cases). */
86
+ function jsonError(status: number, message: string): Response {
87
+ return Response.json(
88
+ { error: message },
89
+ {
90
+ status,
91
+ headers: { "Content-Type": "application/json" },
92
+ },
93
+ );
94
+ }
95
+
96
+ // ─── /__studio/ai/chat — SSE streaming proxy ───────────────────────────────
97
+
98
+ /** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
99
+ export async function handleChat(req: Request): Promise<Response> {
100
+ const { apiKey, baseUrl, missingKey } = getConfig(req);
101
+ if (missingKey) {
102
+ return jsonError(
103
+ 401,
104
+ "No API key configured. Set OPENAI_API_KEY env var or send X-Api-Key header.",
105
+ );
106
+ }
107
+
108
+ // Parse request body
109
+ let body: {
110
+ messages?: unknown[];
111
+ tools?: unknown[];
112
+ systemPrompt?: string;
113
+ model?: string;
114
+ };
115
+ try {
116
+ body = (await req.json()) as {
117
+ messages?: unknown[];
118
+ tools?: unknown[];
119
+ systemPrompt?: string;
120
+ model?: string;
121
+ };
122
+ } catch {
123
+ return jsonError(400, "Invalid JSON body");
124
+ }
125
+
126
+ const { messages = [], tools = [], systemPrompt = "", model = "gpt-4o" } = body;
127
+
128
+ if (!Array.isArray(messages)) {
129
+ return jsonError(400, "messages must be an array");
130
+ }
131
+
132
+ // Build OpenAI request
133
+ const openaiBody: {
134
+ model: string;
135
+ messages: unknown[];
136
+ stream: boolean;
137
+ stream_options: { include_usage: boolean };
138
+ tools?: unknown[];
139
+ tool_choice?: string;
140
+ parallel_tool_calls?: boolean;
141
+ } = {
142
+ model,
143
+ messages: [{ role: "system", content: systemPrompt }, ...messages],
144
+ stream: true,
145
+ stream_options: { include_usage: true },
146
+ };
147
+
148
+ if (tools && tools.length > 0) {
149
+ openaiBody.tools = tools;
150
+ openaiBody.tool_choice = "auto";
151
+ openaiBody.parallel_tool_calls = true;
152
+ }
153
+
154
+ const upstreamUrl = `${baseUrl}/chat/completions`;
155
+
156
+ // Create the SSE stream
157
+ const stream = new ReadableStream({
158
+ async start(controller) {
159
+ let response;
160
+ try {
161
+ response = await fetch(upstreamUrl, {
162
+ method: "POST",
163
+ headers: {
164
+ "Content-Type": "application/json",
165
+ Authorization: `Bearer ${apiKey}`,
166
+ },
167
+ body: JSON.stringify(openaiBody),
168
+ signal: req.signal,
169
+ });
170
+ } catch (error) {
171
+ if ((error as Error).name === "AbortError") {
172
+ writeSSE(controller, { type: "done", stopReason: "cancelled" });
173
+ } else {
174
+ writeSSE(controller, {
175
+ type: "error",
176
+ message: `Network error: ${(error as Error).message}`,
177
+ });
178
+ }
179
+ controller.close();
180
+ return;
181
+ }
182
+
183
+ if (!response.ok) {
184
+ let errorBody = "";
185
+ try {
186
+ errorBody = await response.text();
187
+ } catch {
188
+ /* Ignore */
189
+ }
190
+ // Parse the upstream JSON error body (OpenAI returns { error: { message: "..." } },
191
+ // While some compatible providers return { error: "..." }). Extract a clean message
192
+ // Instead of embedding the raw JSON in the error text.
193
+ let cleanMessage = errorBody || response.statusText;
194
+ try {
195
+ const parsed = JSON.parse(errorBody) as { error?: string | { message?: string } };
196
+ if (typeof parsed.error === "string") {
197
+ cleanMessage = parsed.error;
198
+ } else if (parsed.error?.message) {
199
+ cleanMessage = parsed.error.message;
200
+ }
201
+ } catch {
202
+ /* Not JSON — use the raw body. */
203
+ }
204
+ writeSSE(controller, {
205
+ type: "error",
206
+ message: cleanMessage,
207
+ code: String(response.status),
208
+ });
209
+ controller.close();
210
+ return;
211
+ }
212
+
213
+ const reader = response.body?.getReader();
214
+ if (!reader) {
215
+ writeSSE(controller, { type: "error", message: "No response body from upstream" });
216
+ controller.close();
217
+ return;
218
+ }
219
+
220
+ const decoder = new TextDecoder();
221
+ let buffer = "";
222
+
223
+ const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
224
+
225
+ try {
226
+ while (true) {
227
+ const { done, value } = await reader.read();
228
+ if (done) {
229
+ break;
230
+ }
231
+
232
+ buffer += decoder.decode(value, { stream: true });
233
+ const lines = buffer.split("\n");
234
+ buffer = lines.pop() || "";
235
+
236
+ for (const line of lines) {
237
+ const trimmed = line.trim();
238
+ if (!trimmed || !trimmed.startsWith("data: ")) {
239
+ continue;
240
+ }
241
+
242
+ const dataStr = trimmed.slice(6);
243
+ if (dataStr === "[DONE]") {
244
+ // Emit pending tool call ends
245
+ for (const [, tc] of pendingToolCalls) {
246
+ writeSSE(controller, { type: "tool_call_end", id: tc.id });
247
+ }
248
+ pendingToolCalls.clear();
249
+ writeSSE(controller, { type: "done", stopReason: "stop" });
250
+ controller.close();
251
+ return;
252
+ }
253
+
254
+ let parsed: OpenAIStreamChunk;
255
+ try {
256
+ parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
257
+ } catch {
258
+ continue;
259
+ }
260
+
261
+ const choice = parsed.choices?.[0];
262
+ if (!choice) {
263
+ continue;
264
+ }
265
+
266
+ const { delta } = choice;
267
+ if (!delta) {
268
+ continue;
269
+ }
270
+
271
+ // Text content
272
+ if (delta.content) {
273
+ writeSSE(controller, { type: "delta", content: delta.content });
274
+ }
275
+
276
+ // Tool calls
277
+ if (delta.tool_calls) {
278
+ for (const tc of delta.tool_calls) {
279
+ if (tc.id) {
280
+ // First appearance
281
+ const entry = {
282
+ id: tc.id,
283
+ name: tc.function?.name || "",
284
+ args: tc.function?.arguments || "",
285
+ };
286
+ pendingToolCalls.set(tc.index, entry);
287
+
288
+ writeSSE(controller, { type: "tool_call_start", id: tc.id, name: entry.name });
289
+
290
+ if (entry.args) {
291
+ writeSSE(controller, { type: "tool_call_delta", id: tc.id, args: entry.args });
292
+ }
293
+ } else if (tc.function?.arguments) {
294
+ // Subsequent fragment
295
+ const existing = pendingToolCalls.get(tc.index);
296
+ if (existing) {
297
+ existing.args += tc.function.arguments;
298
+ writeSSE(controller, {
299
+ type: "tool_call_delta",
300
+ id: existing.id,
301
+ args: tc.function.arguments,
302
+ });
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ // Finish reason
309
+ if (choice.finish_reason === "tool_calls") {
310
+ for (const [, tc] of pendingToolCalls) {
311
+ writeSSE(controller, { type: "tool_call_end", id: tc.id });
312
+ }
313
+ pendingToolCalls.clear();
314
+ writeSSE(controller, { type: "done", stopReason: "tool_calls" });
315
+ controller.close();
316
+ return;
317
+ }
318
+
319
+ if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
320
+ for (const [, tc] of pendingToolCalls) {
321
+ writeSSE(controller, { type: "tool_call_end", id: tc.id });
322
+ }
323
+ pendingToolCalls.clear();
324
+ writeSSE(controller, {
325
+ type: "done",
326
+ stopReason: choice.finish_reason === "length" ? "length" : "stop",
327
+ });
328
+ controller.close();
329
+ return;
330
+ }
331
+ }
332
+ }
333
+
334
+ // Stream ended without explicit finish_reason
335
+ for (const [, tc] of pendingToolCalls) {
336
+ writeSSE(controller, { type: "tool_call_end", id: tc.id });
337
+ }
338
+ pendingToolCalls.clear();
339
+ writeSSE(controller, { type: "done", stopReason: "stop" });
340
+ controller.close();
341
+ } catch (error) {
342
+ void reader.cancel();
343
+ if ((error as Error).name === "AbortError") {
344
+ writeSSE(controller, { type: "done", stopReason: "cancelled" });
345
+ controller.close();
346
+ return;
347
+ }
348
+ writeSSE(controller, {
349
+ type: "error",
350
+ message: `Stream error: ${(error as Error).message}`,
351
+ });
352
+ controller.close();
353
+ }
354
+ },
355
+ });
356
+
357
+ return new Response(stream, {
358
+ headers: {
359
+ "Content-Type": "text/event-stream",
360
+ "Cache-Control": "no-cache",
361
+ Connection: "keep-alive",
362
+ },
363
+ });
364
+ }
365
+
366
+ // ─── /__studio/ai/models — model listing ────────────────────────────────────
367
+
368
+ /**
369
+ * Handle GET /__studio/ai/models — return available models.
370
+ *
371
+ * When the caller has configured an API key (header or env), the request is proxied to the upstream
372
+ * provider's /models endpoint so any OpenAI-compatible endpoint (OpenRouter, local LLM, OpenCode
373
+ * Zen, Azure, etc.) returns its actual model list. Falls back to a hardcoded default list when no
374
+ * key is available.
375
+ */
376
+ export async function handleModels(req: Request): Promise<Response> {
377
+ const { apiKey, baseUrl, missingKey } = getConfig(req);
378
+
379
+ // No key available → return hardcoded defaults so the UI can at least render.
380
+ if (missingKey) {
381
+ const defaults = [
382
+ { id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 },
383
+ { id: "gpt-4.1", name: "GPT-4.1", contextWindow: 1_000_000 },
384
+ { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextWindow: 1_000_000 },
385
+ { id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000 },
386
+ ];
387
+ return Response.json(
388
+ { models: defaults, configured: false },
389
+ {
390
+ headers: { "Content-Type": "application/json" },
391
+ },
392
+ );
393
+ }
394
+
395
+ // Key is available — proxy to the upstream /models endpoint.
396
+ try {
397
+ const upstreamUrl = `${baseUrl}/models`;
398
+ const upstreamResp = await fetch(upstreamUrl, {
399
+ headers: { Authorization: `Bearer ${apiKey}` },
400
+ });
401
+
402
+ if (!upstreamResp.ok) {
403
+ // Upstream failed — return defaults with configured flag so user can still try.
404
+ const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
405
+ return Response.json(
406
+ { models: defaults, configured: true, upstreamError: upstreamResp.status },
407
+ {
408
+ headers: { "Content-Type": "application/json" },
409
+ },
410
+ );
411
+ }
412
+
413
+ const data = (await upstreamResp.json()) as { data?: ModelEntry[] } | ModelEntry[];
414
+ // OpenAI /models returns { object: "list", data: [{ id, ... }] }
415
+ // Map to our simpler format.
416
+ const rawModels: ModelEntry[] = Array.isArray(data)
417
+ ? data
418
+ : Array.isArray(data.data)
419
+ ? data.data
420
+ : [];
421
+ const models = rawModels.map(({ id, context_window, owned_by }) => ({
422
+ id,
423
+ name: id,
424
+ contextWindow: context_window || 0,
425
+ ownedBy: owned_by,
426
+ }));
427
+
428
+ return Response.json(
429
+ { models, configured: true },
430
+ {
431
+ headers: { "Content-Type": "application/json" },
432
+ },
433
+ );
434
+ } catch {
435
+ // Network error → return defaults.
436
+ const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
437
+ return Response.json(
438
+ { models: defaults, configured: true, upstreamError: "network" },
439
+ {
440
+ headers: { "Content-Type": "application/json" },
441
+ },
442
+ );
443
+ }
444
+ }
445
+
446
+ // ─── Route handler ───────────────────────────────────────────────────────────
447
+
448
+ /**
449
+ * Main handler for /__studio/ai/* requests.
450
+ *
451
+ * @returns Response if handled, null if route doesn't match
452
+ */
453
+ export async function handleAiApi(req: Request, url: URL): Promise<Response | null> {
454
+ const { pathname } = url;
455
+
456
+ if (pathname === "/__studio/ai/chat" && req.method === "POST") {
457
+ return handleChat(req);
458
+ }
459
+
460
+ if (pathname === "/__studio/ai/models" && req.method === "GET") {
461
+ return handleModels(req);
462
+ }
463
+
464
+ return null;
465
+ }
package/src/server.ts CHANGED
@@ -19,6 +19,7 @@ import { createWatcher, injectSSE } from "./watch.ts";
19
19
  import { handleResolve, handleServerFunction } from "./resolve.ts";
20
20
  import { handleStudioApi } from "./studio-api.ts";
21
21
  import { handleCodeApi } from "./code-api.ts";
22
+ import { handleAiApi } from "./ai-api.ts";
22
23
  import { existsSync, readFileSync } from "node:fs";
23
24
 
24
25
  /**
@@ -252,6 +253,12 @@ export async function createDevServer(options: {
252
253
  return Response.json({ ok: true, root: activeProjectRoot });
253
254
  }
254
255
 
256
+ // AI proxy endpoints (/__studio/ai/chat, /__studio/ai/models)
257
+ const aiRes = await handleAiApi(req, url);
258
+ if (aiRes) {
259
+ return aiRes;
260
+ }
261
+
255
262
  const codeRes = await handleCodeApi(req, url);
256
263
  if (codeRes) {
257
264
  return codeRes;
@@ -332,7 +339,11 @@ export async function createDevServer(options: {
332
339
  return new Response("Not found", { status: 404 });
333
340
  }
334
341
 
335
- if (handleSSE && path.endsWith(".html")) {
342
+ // Inject the live-reload script into served HTML — but NOT into the Studio editor.
343
+ // Studio manages its own state (open tabs, undo history, chat) and refreshes edited
344
+ // Files in-place; a blanket location.reload() would destroy that, e.g. when the AI
345
+ // Assistant writes a file matching a build glob inside the watched root.
346
+ if (handleSSE && path.endsWith(".html") && !path.startsWith("/packages/studio/")) {
336
347
  const html = await file.text();
337
348
  return new Response(injectSSE(html), {
338
349
  headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -343,6 +354,9 @@ export async function createDevServer(options: {
343
354
  },
344
355
 
345
356
  port,
357
+ // Keep SSE connections alive — heartbeats are every 15 s, and AI streaming can take
358
+ // 30+ s. The default 10 s idleTimeout kills them prematurely.
359
+ idleTimeout: 120,
346
360
  });
347
361
 
348
362
  console.log(`\n@jxsuite/server listening on http://localhost:${server.port}`);
package/src/studio-api.ts CHANGED
@@ -21,7 +21,6 @@ import {
21
21
  setPackageVersions,
22
22
  } from "./packages.ts";
23
23
  import type { FormatRegistry } from "@jxsuite/schema/format-registry";
24
- import * as claude from "./claude-session.ts";
25
24
  import type { ClassJsonDef } from "./types.ts";
26
25
  import type { ProjectConfig } from "@jxsuite/schema/types";
27
26
 
@@ -1438,79 +1437,6 @@ export async function handleStudioApi(
1438
1437
  }
1439
1438
  }
1440
1439
 
1441
- // ─── AI Assistant ─────────────────────────────────────────────────────────
1442
-
1443
- if (path === "/__studio/ai/auth-status" && req.method === "GET") {
1444
- const status = await claude.getAuthStatus();
1445
- return Response.json(status);
1446
- }
1447
-
1448
- if (path === "/__studio/ai/session" && req.method === "POST") {
1449
- try {
1450
- const body = (await req.json()) as { message?: string; systemPrompt?: string };
1451
- const projectDir = activeProjectRoot || root;
1452
- const opts: { systemPrompt?: string } = {};
1453
- if (body.systemPrompt !== undefined) {
1454
- opts.systemPrompt = body.systemPrompt;
1455
- }
1456
- const result = claude.createSession(projectDir, body.message!, opts);
1457
- return Response.json(result);
1458
- } catch (error) {
1459
- return Response.json(
1460
- { error: error instanceof Error ? error.message : String(error) },
1461
- { status: 500 },
1462
- );
1463
- }
1464
- }
1465
-
1466
- if (
1467
- path.startsWith("/__studio/ai/session/") &&
1468
- path.endsWith("/stream") &&
1469
- req.method === "GET"
1470
- ) {
1471
- const id = path.split("/").slice(4)[0]!;
1472
- return claude.streamSession(id);
1473
- }
1474
-
1475
- if (
1476
- path.startsWith("/__studio/ai/session/") &&
1477
- path.endsWith("/message") &&
1478
- req.method === "POST"
1479
- ) {
1480
- try {
1481
- const id = path.split("/").slice(4)[0]!;
1482
- const body = (await req.json()) as { message?: string };
1483
- claude.sendMessage(id, body.message!);
1484
- return Response.json({ ok: true });
1485
- } catch (error) {
1486
- return Response.json(
1487
- { error: error instanceof Error ? error.message : String(error) },
1488
- { status: 500 },
1489
- );
1490
- }
1491
- }
1492
-
1493
- if (path.startsWith("/__studio/ai/session/") && path.endsWith("/stop") && req.method === "POST") {
1494
- const id = path.split("/").slice(4)[0]!;
1495
- claude.stopSession(id);
1496
- return Response.json({ ok: true });
1497
- }
1498
-
1499
- if (path.startsWith("/__studio/ai/session/") && req.method === "DELETE") {
1500
- const id = path.split("/").slice(4)[0]!;
1501
- claude.deleteSession(id);
1502
- return Response.json({ ok: true });
1503
- }
1504
-
1505
- if (path.startsWith("/__studio/ai/session/") && req.method === "GET") {
1506
- const id = path.split("/").slice(4)[0]!;
1507
- const info = claude.getSession(id);
1508
- if (!info) {
1509
- return Response.json({ error: "Not found" }, { status: 404 });
1510
- }
1511
- return Response.json(info);
1512
- }
1513
-
1514
1440
  return null;
1515
1441
  }
1516
1442
 
package/src/watch.ts CHANGED
@@ -16,6 +16,9 @@ const DEFAULT_IGNORE = [
16
16
  "**/.direnv/**",
17
17
  "**/bun.lockb",
18
18
  "**/bun.lock",
19
+ // Bun test temporary directories — created/deleted transiently, cause EINVAL on
20
+ // Fs.watch when cleaned up mid-scan.
21
+ "**/__test-*/**",
19
22
  ];
20
23
 
21
24
  /** @param {string} value */
@@ -186,5 +189,14 @@ export function createWatcher(
186
189
  }, debounceMs);
187
190
  });
188
191
 
192
+ // Gracefully handle watch errors (e.g. EINVAL on transient Bun test dirs).
193
+ watcher.on("error", (err) => {
194
+ const error = err as Error;
195
+ if (error.message?.includes("EINVAL")) {
196
+ return;
197
+ }
198
+ console.error("[watch] chokidar error:", error.message ?? error);
199
+ });
200
+
189
201
  return { broadcast, broadcastEvent, handleSSE, watcher };
190
202
  }
@@ -1,338 +0,0 @@
1
- /**
2
- * Claude-session.js — Claude Agent SDK session manager
3
- *
4
- * Manages Claude Code sessions via @anthropic-ai/claude-agent-sdk, providing: - Session creation
5
- * with streaming output - Follow-up messages via session resume - SSE broadcasting to connected
6
- * frontend clients - Session interruption and cleanup
7
- */
8
-
9
- import { query } from "@anthropic-ai/claude-agent-sdk";
10
-
11
- import type { Options as AgentOptions, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
12
-
13
- interface Session {
14
- id: string;
15
- sessionId: string | null;
16
- projectRoot: string;
17
- messages: SDKMessage[];
18
- sseClients: Set<(event: string, data: unknown) => void>;
19
- status: "active" | "idle" | "stopped";
20
- abortController: AbortController | null;
21
- query: AsyncGenerator | null;
22
- }
23
-
24
- /**
25
- * Build a clean env for SDK child processes. Strips session-specific vars from a parent Claude Code
26
- * instance that would override the SDK's own credential resolution (OAuth from ~/.claude/).
27
- */
28
- function buildSdkEnv() {
29
- const env = { ...process.env };
30
- delete env.ANTHROPIC_AUTH_TOKEN;
31
- delete env.ANTHROPIC_BASE_URL;
32
- delete env.CLAUDE_CODE_ENTRYPOINT;
33
- delete env.CLAUDE_CODE_EXECPATH;
34
- delete env.CLAUDE_AGENT_SDK_VERSION;
35
- delete env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
36
- delete env.CLAUDECODE;
37
- env.CLAUDE_AGENT_SDK_CLIENT_APP = "jx-studio/0.17.0";
38
- return env;
39
- }
40
-
41
- const sessions = new Map<string, Session>();
42
-
43
- let idCounter = 0;
44
-
45
- function genId() {
46
- return `ai_${Date.now().toString(36)}_${(idCounter += 1).toString(36)}`;
47
- }
48
-
49
- /**
50
- * Broadcast an SSE event to all connected clients for a session.
51
- *
52
- * @param {Session} session
53
- * @param {string} event
54
- * @param {unknown} data
55
- */
56
- function broadcast(session: Session, event: string, data: unknown) {
57
- session.messages.push(data as SDKMessage);
58
- for (const send of session.sseClients) {
59
- send(event, data);
60
- }
61
- }
62
-
63
- /**
64
- * Process the async message stream from a query.
65
- *
66
- * @param {Session} session
67
- * @param {AsyncGenerator<SDKMessage>} stream
68
- */
69
- async function processStream(session: Session, stream: AsyncGenerator<SDKMessage>) {
70
- session.status = "active";
71
- try {
72
- for await (const message of stream) {
73
- if ((session.status as string) === "stopped") {
74
- break;
75
- }
76
- if (message.session_id && !session.sessionId) {
77
- session.sessionId = message.session_id;
78
- }
79
- broadcast(session, message.type, message);
80
- }
81
- } catch (error) {
82
- if (error instanceof Error && error.name !== "AbortError") {
83
- broadcast(session, "error", { error: String(error), type: "error" });
84
- }
85
- } finally {
86
- session.status = "idle";
87
- session.query = null;
88
- session.abortController = null;
89
- broadcast(session, "done", { type: "done" });
90
- }
91
- }
92
-
93
- /**
94
- * Create a new AI session and begin streaming.
95
- *
96
- * @param {string} projectRoot
97
- * @param {string} message
98
- * @param {{ systemPrompt?: string }} [opts]
99
- * @returns {{ id: string }}
100
- */
101
- export function createSession(
102
- projectRoot: string,
103
- message: string,
104
- opts: { systemPrompt?: string } = {},
105
- ) {
106
- const id = genId();
107
- const abortController = new AbortController();
108
-
109
- const session: Session = {
110
- abortController,
111
- id,
112
- messages: [],
113
- projectRoot,
114
- query: null,
115
- sessionId: null,
116
- sseClients: new Set(),
117
- status: "active",
118
- };
119
-
120
- sessions.set(id, session);
121
-
122
- const queryOpts: AgentOptions = {
123
- abortController,
124
- allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
125
- cwd: projectRoot,
126
- env: buildSdkEnv(),
127
- includePartialMessages: true,
128
- maxTurns: 30,
129
- permissionMode: "acceptEdits",
130
- persistSession: true,
131
- };
132
-
133
- if (opts.systemPrompt) {
134
- queryOpts.systemPrompt = opts.systemPrompt;
135
- }
136
-
137
- const stream = query({ options: queryOpts, prompt: message });
138
- session.query = stream;
139
- void processStream(session, stream);
140
-
141
- return { id };
142
- }
143
-
144
- /**
145
- * Send a follow-up message to an existing session.
146
- *
147
- * @param {string} id
148
- * @param {string} message
149
- */
150
- export function sendMessage(id: string, message: string) {
151
- const session = sessions.get(id);
152
- if (!session) {
153
- throw new Error(`Session not found: ${id}`);
154
- }
155
- if (session.status === "active") {
156
- throw new Error("Session is still processing");
157
- }
158
-
159
- const abortController = new AbortController();
160
- session.abortController = abortController;
161
-
162
- const queryOpts: AgentOptions = {
163
- abortController,
164
- allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
165
- continue: true,
166
- cwd: session.projectRoot,
167
- env: buildSdkEnv(),
168
- includePartialMessages: true,
169
- maxTurns: 30,
170
- permissionMode: "acceptEdits",
171
- };
172
-
173
- if (session.sessionId) {
174
- queryOpts.resume = session.sessionId;
175
- delete queryOpts.continue;
176
- }
177
-
178
- const stream = query({ options: queryOpts, prompt: message });
179
- session.query = stream;
180
- void processStream(session, stream);
181
- }
182
-
183
- /**
184
- * Stop a running session (interrupt the current query).
185
- *
186
- * @param {string} id
187
- */
188
- export function stopSession(id: string) {
189
- const session = sessions.get(id);
190
- if (!session) {
191
- return;
192
- }
193
- session.status = "stopped";
194
- if (session.abortController) {
195
- session.abortController.abort();
196
- }
197
- }
198
-
199
- /**
200
- * Remove a session entirely.
201
- *
202
- * @param {string} id
203
- */
204
- export function deleteSession(id: string) {
205
- stopSession(id);
206
- sessions.delete(id);
207
- }
208
-
209
- /**
210
- * Get an SSE Response stream for a session.
211
- *
212
- * @param {string} id
213
- * @returns {Response}
214
- */
215
- export function streamSession(id: string) {
216
- const session = sessions.get(id);
217
- if (!session) {
218
- return new Response("Session not found", { status: 404 });
219
- }
220
-
221
- const encoder = new TextEncoder();
222
- let sendRef: (event: string, data: unknown) => void = () => {};
223
- const stream = new ReadableStream({
224
- cancel() {
225
- session.sseClients.delete(sendRef);
226
- },
227
- start(controller) {
228
- /** @param {string} event @param {unknown} data */
229
- sendRef = (event: string, data: unknown) => {
230
- try {
231
- controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
232
- } catch {
233
- // Stream closed
234
- }
235
- };
236
-
237
- session.sseClients.add(sendRef);
238
-
239
- for (const msg of session.messages) {
240
- sendRef(((msg as Record<string, unknown>).type as string) || "message", msg);
241
- }
242
-
243
- const heartbeat = setInterval(() => {
244
- try {
245
- controller.enqueue(encoder.encode(": heartbeat\n\n"));
246
- } catch {
247
- clearInterval(heartbeat);
248
- session.sseClients.delete(sendRef);
249
- }
250
- }, 15_000);
251
- },
252
- });
253
-
254
- return new Response(stream, {
255
- headers: {
256
- "Cache-Control": "no-cache",
257
- Connection: "keep-alive",
258
- "Content-Type": "text/event-stream",
259
- },
260
- });
261
- }
262
-
263
- /**
264
- * Check if the claude agent SDK can authenticate.
265
- *
266
- * @returns {Promise<{ authenticated: boolean; error?: string }>}
267
- */
268
- export async function getAuthStatus() {
269
- try {
270
- const ctrl = new AbortController();
271
- const timeout = setTimeout(() => ctrl.abort(), 15_000);
272
- const testStream = query({
273
- options: {
274
- abortController: ctrl,
275
- env: buildSdkEnv(),
276
- includePartialMessages: true,
277
- maxTurns: 1,
278
- persistSession: false,
279
- },
280
- prompt: "Say OK",
281
- });
282
-
283
- for await (const msg of testStream) {
284
- if (msg.type === "assistant") {
285
- const content = (msg as Record<string, unknown>).message as
286
- | { content?: unknown[] }
287
- | undefined;
288
- if (Array.isArray(content?.content)) {
289
- const text = content.content.find(
290
- (b: unknown) => (b as { type?: string }).type === "text",
291
- ) as { text?: string } | undefined;
292
- if (text?.text?.toLowerCase().includes("error")) {
293
- clearTimeout(timeout);
294
- ctrl.abort();
295
- return { authenticated: false, error: text.text };
296
- }
297
- }
298
- clearTimeout(timeout);
299
- ctrl.abort();
300
- return { authenticated: true };
301
- }
302
- if (msg.type === "result") {
303
- const result = msg as Record<string, unknown>;
304
- clearTimeout(timeout);
305
- ctrl.abort();
306
- if (result.is_error) {
307
- return {
308
- authenticated: false,
309
- error: (result.result as string) || "API error",
310
- };
311
- }
312
- return { authenticated: true };
313
- }
314
- }
315
-
316
- clearTimeout(timeout);
317
- return { authenticated: true };
318
- } catch (error) {
319
- return { authenticated: false, error: String(error) };
320
- }
321
- }
322
-
323
- /**
324
- * Get session info.
325
- *
326
- * @param {string} id
327
- */
328
- export function getSession(id: string) {
329
- const session = sessions.get(id);
330
- if (!session) {
331
- return null;
332
- }
333
- return {
334
- id: session.id,
335
- messageCount: session.messages.length,
336
- status: session.status,
337
- };
338
- }