@jxsuite/server 0.19.0 → 0.21.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.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Jx development server with live reload, server-side proxy, and studio integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,15 +13,25 @@
13
13
  ],
14
14
  "type": "module",
15
15
  "exports": {
16
- ".": "./src/server.js"
16
+ ".": "./src/server.js",
17
+ "./claude-session": "./src/claude-session.js"
17
18
  },
18
19
  "publishConfig": {
19
20
  "provenance": true
20
21
  },
21
22
  "scripts": {
22
- "upgrade": "bunx npm-check-updates -u && bun install"
23
+ "upgrade": "bunx npm-check-updates -u && bun install",
24
+ "test": "bun test --isolate",
25
+ "test:coverage": "bun test --isolate --coverage"
23
26
  },
24
27
  "dependencies": {
25
- "chokidar": "^5.0.0"
28
+ "@anthropic-ai/claude-agent-sdk": "^0.3.153",
29
+ "@anthropic-ai/sdk": "^0.99.0",
30
+ "@jxsuite/compiler": "^0.21.0",
31
+ "@jxsuite/create": "^0.19.0",
32
+ "@jxsuite/parser": "^0.21.0",
33
+ "@modelcontextprotocol/sdk": "^1.29.0",
34
+ "chokidar": "^5.0.0",
35
+ "zod": "^4.4.3"
26
36
  }
27
37
  }
@@ -0,0 +1,321 @@
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
+ /** @typedef {import("@anthropic-ai/claude-agent-sdk").SDKMessage} SDKMessage */
12
+ /** @typedef {import("@anthropic-ai/claude-agent-sdk").Options} AgentOptions */
13
+
14
+ /**
15
+ * Build a clean env for SDK child processes. Strips session-specific vars from a parent Claude Code
16
+ * instance that would override the SDK's own credential resolution (OAuth from ~/.claude/).
17
+ */
18
+ function buildSdkEnv() {
19
+ const env = { ...process.env };
20
+ delete env.ANTHROPIC_AUTH_TOKEN;
21
+ delete env.ANTHROPIC_BASE_URL;
22
+ delete env.CLAUDE_CODE_ENTRYPOINT;
23
+ delete env.CLAUDE_CODE_EXECPATH;
24
+ delete env.CLAUDE_AGENT_SDK_VERSION;
25
+ delete env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
26
+ delete env.CLAUDECODE;
27
+ env.CLAUDE_AGENT_SDK_CLIENT_APP = "jx-studio/0.17.0";
28
+ return env;
29
+ }
30
+
31
+ /**
32
+ * @typedef {{
33
+ * id: string;
34
+ * sessionId: string | null;
35
+ * projectRoot: string;
36
+ * messages: SDKMessage[];
37
+ * sseClients: Set<(event: string, data: unknown) => void>;
38
+ * status: "active" | "idle" | "stopped";
39
+ * abortController: AbortController | null;
40
+ * query: AsyncGenerator | null;
41
+ * }} Session
42
+ */
43
+
44
+ /** @type {Map<string, Session>} */
45
+ const sessions = new Map();
46
+
47
+ let idCounter = 0;
48
+
49
+ function genId() {
50
+ return `ai_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
51
+ }
52
+
53
+ /**
54
+ * Broadcast an SSE event to all connected clients for a session.
55
+ *
56
+ * @param {Session} session
57
+ * @param {string} event
58
+ * @param {unknown} data
59
+ */
60
+ function broadcast(session, event, data) {
61
+ session.messages.push(/** @type {any} */ (data));
62
+ for (const send of session.sseClients) {
63
+ send(event, data);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Process the async message stream from a query.
69
+ *
70
+ * @param {Session} session
71
+ * @param {AsyncGenerator<SDKMessage>} stream
72
+ */
73
+ async function processStream(session, stream) {
74
+ session.status = "active";
75
+ try {
76
+ for await (const message of stream) {
77
+ if (/** @type {string} */ (session.status) === "stopped") break;
78
+ if (message.session_id && !session.sessionId) {
79
+ session.sessionId = message.session_id;
80
+ }
81
+ broadcast(session, message.type, message);
82
+ }
83
+ } catch (err) {
84
+ if (/** @type {any} */ (err).name !== "AbortError") {
85
+ broadcast(session, "error", { type: "error", error: String(err) });
86
+ }
87
+ } finally {
88
+ session.status = "idle";
89
+ session.query = null;
90
+ session.abortController = null;
91
+ broadcast(session, "done", { type: "done" });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Create a new AI session and begin streaming.
97
+ *
98
+ * @param {string} projectRoot
99
+ * @param {string} message
100
+ * @param {{ systemPrompt?: string }} [opts]
101
+ * @returns {{ id: string }}
102
+ */
103
+ export function createSession(projectRoot, message, opts = {}) {
104
+ const id = genId();
105
+ const abortController = new AbortController();
106
+
107
+ /** @type {Session} */
108
+ const session = {
109
+ id,
110
+ sessionId: null,
111
+ projectRoot,
112
+ messages: [],
113
+ sseClients: new Set(),
114
+ status: "active",
115
+ abortController,
116
+ query: null,
117
+ };
118
+
119
+ sessions.set(id, session);
120
+
121
+ /** @type {AgentOptions} */
122
+ const queryOpts = {
123
+ cwd: projectRoot,
124
+ abortController,
125
+ permissionMode: "acceptEdits",
126
+ allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
127
+ maxTurns: 30,
128
+ includePartialMessages: true,
129
+ persistSession: true,
130
+ env: buildSdkEnv(),
131
+ };
132
+
133
+ if (opts.systemPrompt) {
134
+ queryOpts.systemPrompt = opts.systemPrompt;
135
+ }
136
+
137
+ const stream = query({ prompt: message, options: queryOpts });
138
+ session.query = stream;
139
+ 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, message) {
151
+ const session = sessions.get(id);
152
+ if (!session) throw new Error(`Session not found: ${id}`);
153
+ if (session.status === "active") throw new Error("Session is still processing");
154
+
155
+ const abortController = new AbortController();
156
+ session.abortController = abortController;
157
+
158
+ /** @type {AgentOptions} */
159
+ const queryOpts = {
160
+ cwd: session.projectRoot,
161
+ abortController,
162
+ permissionMode: "acceptEdits",
163
+ allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
164
+ maxTurns: 30,
165
+ includePartialMessages: true,
166
+ continue: true,
167
+ env: buildSdkEnv(),
168
+ };
169
+
170
+ if (session.sessionId) {
171
+ queryOpts.resume = session.sessionId;
172
+ delete queryOpts.continue;
173
+ }
174
+
175
+ const stream = query({ prompt: message, options: queryOpts });
176
+ session.query = stream;
177
+ processStream(session, stream);
178
+ }
179
+
180
+ /**
181
+ * Stop a running session (interrupt the current query).
182
+ *
183
+ * @param {string} id
184
+ */
185
+ export function stopSession(id) {
186
+ const session = sessions.get(id);
187
+ if (!session) return;
188
+ session.status = "stopped";
189
+ if (session.abortController) {
190
+ session.abortController.abort();
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Remove a session entirely.
196
+ *
197
+ * @param {string} id
198
+ */
199
+ export function deleteSession(id) {
200
+ stopSession(id);
201
+ sessions.delete(id);
202
+ }
203
+
204
+ /**
205
+ * Get an SSE Response stream for a session.
206
+ *
207
+ * @param {string} id
208
+ * @returns {Response}
209
+ */
210
+ export function streamSession(id) {
211
+ const session = sessions.get(id);
212
+ if (!session) {
213
+ return new Response("Session not found", { status: 404 });
214
+ }
215
+
216
+ const encoder = new TextEncoder();
217
+ /** @type {(event: string, data: unknown) => void} */
218
+ let sendRef = () => {};
219
+ const stream = new ReadableStream({
220
+ start(controller) {
221
+ /** @param {string} event @param {unknown} data */
222
+ sendRef = (event, data) => {
223
+ try {
224
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
225
+ } catch {
226
+ // Stream closed
227
+ }
228
+ };
229
+
230
+ session.sseClients.add(sendRef);
231
+
232
+ for (const msg of session.messages) {
233
+ sendRef(/** @type {any} */ (msg).type || "message", msg);
234
+ }
235
+
236
+ const heartbeat = setInterval(() => {
237
+ try {
238
+ controller.enqueue(encoder.encode(": heartbeat\n\n"));
239
+ } catch {
240
+ clearInterval(heartbeat);
241
+ session.sseClients.delete(sendRef);
242
+ }
243
+ }, 15000);
244
+ },
245
+ cancel() {
246
+ session.sseClients.delete(sendRef);
247
+ },
248
+ });
249
+
250
+ return new Response(stream, {
251
+ headers: {
252
+ "Content-Type": "text/event-stream",
253
+ "Cache-Control": "no-cache",
254
+ Connection: "keep-alive",
255
+ },
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Check if the claude agent SDK can authenticate.
261
+ *
262
+ * @returns {Promise<{ authenticated: boolean; error?: string }>}
263
+ */
264
+ export async function getAuthStatus() {
265
+ try {
266
+ const ctrl = new AbortController();
267
+ const timeout = setTimeout(() => ctrl.abort(), 15000);
268
+ const testStream = query({
269
+ prompt: "Say OK",
270
+ options: {
271
+ abortController: ctrl,
272
+ maxTurns: 1,
273
+ persistSession: false,
274
+ includePartialMessages: true,
275
+ env: buildSdkEnv(),
276
+ },
277
+ });
278
+
279
+ for await (const msg of testStream) {
280
+ if (msg.type === "assistant") {
281
+ const content = /** @type {any} */ (msg).message?.content;
282
+ if (Array.isArray(content)) {
283
+ const text = content.find((b) => b.type === "text");
284
+ if (text?.text?.toLowerCase().includes("error")) {
285
+ clearTimeout(timeout);
286
+ ctrl.abort();
287
+ return { authenticated: false, error: text.text };
288
+ }
289
+ }
290
+ clearTimeout(timeout);
291
+ ctrl.abort();
292
+ return { authenticated: true };
293
+ }
294
+ if (msg.type === "result") {
295
+ const result = /** @type {any} */ (msg);
296
+ clearTimeout(timeout);
297
+ ctrl.abort();
298
+ if (result.is_error) {
299
+ return { authenticated: false, error: result.result || "API error" };
300
+ }
301
+ return { authenticated: true };
302
+ }
303
+ }
304
+
305
+ clearTimeout(timeout);
306
+ return { authenticated: true };
307
+ } catch (err) {
308
+ return { authenticated: false, error: String(err) };
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Get session info.
314
+ *
315
+ * @param {string} id
316
+ */
317
+ export function getSession(id) {
318
+ const session = sessions.get(id);
319
+ if (!session) return null;
320
+ return { id: session.id, status: session.status, messageCount: session.messages.length };
321
+ }
package/src/code-api.js CHANGED
@@ -10,6 +10,15 @@ import { join, resolve } from "node:path";
10
10
  import { unlink } from "node:fs/promises";
11
11
  import { format } from "oxfmt";
12
12
 
13
+ /**
14
+ * @typedef {{
15
+ * span: { offset: number; line: number; [key: string]: unknown };
16
+ * [key: string]: unknown;
17
+ * }} OxcLabel
18
+ *
19
+ * @typedef {{ labels?: OxcLabel[]; [key: string]: unknown }} OxcDiagnostic
20
+ */
21
+
13
22
  const OXLINT_BIN = resolve(
14
23
  import.meta.dir,
15
24
  "../../node_modules/.bin",
@@ -40,7 +49,7 @@ function unwrapFormatted(formatted) {
40
49
  }
41
50
 
42
51
  /**
43
- * @param {any[]} diagnostics
52
+ * @param {OxcDiagnostic[]} diagnostics
44
53
  * @param {number} headerLen
45
54
  */
46
55
  function adjustDiagnostics(diagnostics, headerLen) {
@@ -51,7 +60,7 @@ function adjustDiagnostics(diagnostics, headerLen) {
51
60
  })
52
61
  .map((d) => ({
53
62
  ...d,
54
- labels: d.labels.map((/** @type {any} */ label) => ({
63
+ labels: d.labels?.map((label) => ({
55
64
  ...label,
56
65
  span: {
57
66
  ...label.span,
@@ -98,8 +107,11 @@ export async function handleCodeApi(req, url) {
98
107
  code: unwrapFormatted(result.code),
99
108
  errors: result.errors,
100
109
  });
101
- } catch (/** @type {any} */ e) {
102
- return Response.json({ code, errors: [{ message: e.message }] });
110
+ } catch (/** @type {unknown} */ e) {
111
+ return Response.json({
112
+ code,
113
+ errors: [{ message: /** @type {{ message?: string }} */ (e).message }],
114
+ });
103
115
  }
104
116
  }
105
117
 
@@ -112,8 +124,8 @@ export async function handleCodeApi(req, url) {
112
124
  try {
113
125
  const minified = minifier.transformSync(code).trim();
114
126
  return Response.json({ code: minified });
115
- } catch (/** @type {any} */ e) {
116
- return Response.json({ code, error: e.message });
127
+ } catch (/** @type {unknown} */ e) {
128
+ return Response.json({ code, error: /** @type {{ message?: string }} */ (e).message });
117
129
  }
118
130
  }
119
131
 
@@ -142,8 +154,11 @@ export async function handleCodeApi(req, url) {
142
154
  const parsed = JSON.parse(output);
143
155
  const adjusted = adjustDiagnostics(parsed.diagnostics || [], headerLen);
144
156
  return Response.json({ diagnostics: adjusted });
145
- } catch (/** @type {any} */ e) {
146
- return Response.json({ diagnostics: [], error: e.message });
157
+ } catch (/** @type {unknown} */ e) {
158
+ return Response.json({
159
+ diagnostics: [],
160
+ error: /** @type {{ message?: string }} */ (e).message,
161
+ });
147
162
  } finally {
148
163
  try {
149
164
  await unlink(tmpFile);
package/src/resolve.js CHANGED
@@ -29,8 +29,11 @@ export async function handleResolve(req, root) {
29
29
  } else {
30
30
  moduleAbsPath = resolve(root, $src);
31
31
  }
32
- } catch (/** @type {any} */ e) {
33
- return new Response(`Cannot resolve $src "${$src}": ${e.message}`, { status: 400 });
32
+ } catch (/** @type {unknown} */ e) {
33
+ return new Response(
34
+ `Cannot resolve $src "${$src}": ${/** @type {{ message?: string }} */ (e).message}`,
35
+ { status: 400 },
36
+ );
34
37
  }
35
38
 
36
39
  // Rebase relative config paths from doc-relative to CWD-relative
@@ -74,7 +77,9 @@ export async function handleResolve(req, root) {
74
77
 
75
78
  // Self-contained: construct class from schema
76
79
  const DynClass = classFromSchema(classDef);
77
- const instance = /** @type {any} */ (new DynClass(config));
80
+ const instance = /** @type {{ resolve?: () => unknown; value?: unknown }} */ (
81
+ new DynClass(config)
82
+ );
78
83
  const value =
79
84
  typeof instance.resolve === "function"
80
85
  ? await instance.resolve()
@@ -82,8 +87,11 @@ export async function handleResolve(req, root) {
82
87
  ? instance.value
83
88
  : instance;
84
89
  return Response.json(value);
85
- } catch (/** @type {any} */ e) {
86
- return Response.json({ error: e.message }, { status: 500 });
90
+ } catch (/** @type {unknown} */ e) {
91
+ return Response.json(
92
+ { error: /** @type {{ message?: string }} */ (e).message },
93
+ { status: 500 },
94
+ );
87
95
  }
88
96
  }
89
97
 
@@ -122,15 +130,20 @@ export async function handleServerFunction(req, root) {
122
130
  } else {
123
131
  moduleAbsPath = resolve(root, $src);
124
132
  }
125
- } catch (/** @type {any} */ e) {
126
- return new Response(`Cannot resolve $src: ${e.message}`, { status: 400 });
133
+ } catch (/** @type {unknown} */ e) {
134
+ return new Response(`Cannot resolve $src: ${/** @type {{ message?: string }} */ (e).message}`, {
135
+ status: 400,
136
+ });
127
137
  }
128
138
 
129
139
  let mod;
130
140
  try {
131
141
  mod = await import(moduleAbsPath);
132
- } catch (/** @type {any} */ e) {
133
- return new Response(`Failed to import "${$src}": ${e.message}`, { status: 500 });
142
+ } catch (/** @type {unknown} */ e) {
143
+ return new Response(
144
+ `Failed to import "${$src}": ${/** @type {{ message?: string }} */ (e).message}`,
145
+ { status: 500 },
146
+ );
134
147
  }
135
148
 
136
149
  const fn = mod[xport] ?? mod.default?.[xport];
@@ -141,8 +154,11 @@ export async function handleServerFunction(req, root) {
141
154
  try {
142
155
  const result = await fn(args);
143
156
  return Response.json(result ?? null);
144
- } catch (/** @type {any} */ e) {
145
- return Response.json({ error: e.message }, { status: 500 });
157
+ } catch (/** @type {unknown} */ e) {
158
+ return Response.json(
159
+ { error: /** @type {{ message?: string }} */ (e).message },
160
+ { status: 500 },
161
+ );
146
162
  }
147
163
  }
148
164
 
@@ -150,7 +166,7 @@ export async function handleServerFunction(req, root) {
150
166
  * Dynamically construct a class from a .class.json schema definition. Server-side variant — no
151
167
  * private field limitations.
152
168
  *
153
- * @param {any} classDef
169
+ * @param {ClassJsonDef} classDef
154
170
  */
155
171
  function classFromSchema(classDef) {
156
172
  const fields = classDef.$defs?.fields ?? {};
@@ -159,8 +175,8 @@ function classFromSchema(classDef) {
159
175
 
160
176
  class DynClass {
161
177
  constructor(config = {}) {
162
- const self = /** @type {any} */ (this);
163
- const cfg = /** @type {Record<string, any>} */ (config);
178
+ const self = /** @type {Record<string, unknown>} */ (this);
179
+ const cfg = /** @type {Record<string, unknown>} */ (config);
164
180
  for (const [key, field] of Object.entries(fields)) {
165
181
  const id = field.identifier ?? key;
166
182
  if (cfg[id] !== undefined) self[id] = cfg[id];
@@ -177,27 +193,28 @@ function classFromSchema(classDef) {
177
193
 
178
194
  for (const [key, method] of Object.entries(methods)) {
179
195
  const name = method.identifier ?? key;
180
- const params = (method.parameters ?? []).map((/** @type {any} */ p) => {
181
- if (p.$ref) return p.$ref.split("/").pop();
196
+ const params = (method.parameters ?? []).map((p) => {
197
+ if (p.$ref) return p.$ref.split("/").pop() ?? "arg";
182
198
  return p.identifier ?? p.name ?? "arg";
183
199
  });
184
200
  const bodyStr = Array.isArray(method.body) ? method.body.join("\n") : (method.body ?? "");
185
201
 
186
202
  if (method.role === "accessor") {
187
- /** @type {any} */
203
+ /** @type {PropertyDescriptor} */
188
204
  const descriptor = {};
189
- if (method.getter) descriptor.get = new Function(method.getter.body);
205
+ if (method.getter)
206
+ descriptor.get = /** @type {() => unknown} */ (new Function(method.getter.body));
190
207
  if (method.setter) {
191
- const sp = (method.setter.parameters ?? []).map(
192
- (/** @type {any} */ p) => p.$ref?.split("/").pop() ?? "v",
208
+ const sp = (method.setter.parameters ?? []).map((p) => p.$ref?.split("/").pop() ?? "v");
209
+ descriptor.set = /** @type {(v: unknown) => void} */ (
210
+ new Function(...sp, method.setter.body)
193
211
  );
194
- descriptor.set = new Function(...sp, method.setter.body);
195
212
  }
196
213
  Object.defineProperty(DynClass.prototype, name, { ...descriptor, configurable: true });
197
214
  } else if (method.scope === "static") {
198
- /** @type {any} */ (DynClass)[name] = new Function(...params, bodyStr);
215
+ /** @type {DynamicClass} */ (DynClass)[name] = new Function(...params, bodyStr);
199
216
  } else {
200
- /** @type {any} */ (DynClass.prototype)[name] = new Function(...params, bodyStr);
217
+ /** @type {DynamicClass} */ (DynClass).prototype[name] = new Function(...params, bodyStr);
201
218
  }
202
219
  }
203
220
 
package/src/server.js CHANGED
@@ -270,7 +270,7 @@ export async function createDevServer(options) {
270
270
  if (result.success && result.outputs.length > 0) {
271
271
  bundleCache.set(cacheKey, await result.outputs[0].text());
272
272
  }
273
- } catch (/** @type {any} */ e) {
273
+ } catch (/** @type {unknown} */ e) {
274
274
  console.error("Bundle failed for", resolved, e);
275
275
  }
276
276
  }
package/src/studio-api.js CHANGED
@@ -11,6 +11,8 @@ import { resolve, relative, basename, dirname, isAbsolute } from "node:path";
11
11
  import { readdir, stat, readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
12
12
  import { readFileSync, existsSync } from "node:fs";
13
13
  import { transpileJxMarkdown } from "@jxsuite/parser/transpile";
14
+ import { loadContentTypes, queryContentType, findEntry } from "@jxsuite/compiler/content-loader";
15
+ import * as claude from "./claude-session.js";
14
16
 
15
17
  /** Normalise a path to forward slashes (Windows `path` module returns backslashes). */
16
18
  const fwd = (/** @type {string} */ p) => p.replaceAll("\\", "/");
@@ -40,6 +42,14 @@ const statusMap = { M: "M", T: "T", A: "A", D: "D", R: "R", C: "C", U: "U" };
40
42
  * Parse raw `git status --porcelain=v2 --branch` output into structured data.
41
43
  *
42
44
  * @param {string} out
45
+ * @returns {{
46
+ * branch: string;
47
+ * ahead: number;
48
+ * behind: number;
49
+ * files: { path: string; status: string; staged: boolean }[];
50
+ * isRepo?: boolean;
51
+ * remotes?: string[];
52
+ * }}
43
53
  */
44
54
  export function parseGitStatus(out) {
45
55
  let branch = "";
@@ -97,6 +107,7 @@ export function parseGitStatus(out) {
97
107
  * @param {URL} url
98
108
  * @param {string} root
99
109
  * @param {string | null} [activeProjectRoot]
110
+ * @returns {Promise<Response | null>}
100
111
  */
101
112
  export async function handleStudioApi(req, url, root, activeProjectRoot = null) {
102
113
  const path = url.pathname;
@@ -121,8 +132,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
121
132
  const absDir = isAbsolute(dir) ? dir : resolve(root, dir);
122
133
  try {
123
134
  assertAccessible(absDir, root, activeProjectRoot);
124
- } catch (/** @type {any} */ e) {
125
- return Response.json({ error: e.message }, { status: 400 });
135
+ } catch (/** @type {unknown} */ e) {
136
+ return Response.json(
137
+ { error: /** @type {{ message?: string }} */ (e).message },
138
+ { status: 400 },
139
+ );
126
140
  }
127
141
  try {
128
142
  const projectRoot = fwd(absDir);
@@ -154,8 +168,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
154
168
  } catch {}
155
169
 
156
170
  return Response.json({ isSiteProject, projectConfig, directories, projectRoot });
157
- } catch (/** @type {any} */ e) {
158
- return Response.json({ error: e.message }, { status: 500 });
171
+ } catch (/** @type {unknown} */ e) {
172
+ return Response.json(
173
+ { error: /** @type {{ message?: string }} */ (e).message },
174
+ { status: 500 },
175
+ );
159
176
  }
160
177
  }
161
178
 
@@ -189,8 +206,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
189
206
  dir = parent;
190
207
  }
191
208
  return Response.json({ sitePath: null });
192
- } catch (/** @type {any} */ e) {
193
- return Response.json({ error: e.message }, { status: 500 });
209
+ } catch (/** @type {unknown} */ e) {
210
+ return Response.json(
211
+ { error: /** @type {{ message?: string }} */ (e).message },
212
+ { status: 500 },
213
+ );
194
214
  }
195
215
  }
196
216
 
@@ -211,8 +231,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
211
231
  }
212
232
  } catch {}
213
233
  return Response.json({ path: null });
214
- } catch (/** @type {any} */ e) {
215
- return Response.json({ error: e.message }, { status: 500 });
234
+ } catch (/** @type {unknown} */ e) {
235
+ return Response.json(
236
+ { error: /** @type {{ message?: string }} */ (e).message },
237
+ { status: 500 },
238
+ );
216
239
  }
217
240
  }
218
241
 
@@ -238,8 +261,36 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
238
261
  } catch {}
239
262
  }
240
263
  return Response.json(sites);
241
- } catch (/** @type {any} */ e) {
242
- return Response.json({ error: e.message }, { status: 500 });
264
+ } catch (/** @type {unknown} */ e) {
265
+ return Response.json(
266
+ { error: /** @type {{ message?: string }} */ (e).message },
267
+ { status: 500 },
268
+ );
269
+ }
270
+ }
271
+
272
+ // Create a new project
273
+ if (path === "/__studio/create-project" && req.method === "POST") {
274
+ try {
275
+ const body = await req.json();
276
+ const { name, description, url: siteUrl, adapter, directory } = body;
277
+ if (!name || !directory) {
278
+ return Response.json({ error: "name and directory are required" }, { status: 400 });
279
+ }
280
+ const destPath = resolve(root, directory);
281
+ assertAccessible(destPath, root, activeProjectRoot);
282
+
283
+ const { generateProject } = await import("@jxsuite/create/generate");
284
+ await generateProject(destPath, { name, description, url: siteUrl, adapter });
285
+
286
+ const config = JSON.parse(await readFile(resolve(destPath, "project.json"), "utf8"));
287
+ const projectRoot = fwd(relative(root, destPath));
288
+ return Response.json({ root: projectRoot, config });
289
+ } catch (/** @type {unknown} */ e) {
290
+ return Response.json(
291
+ { error: /** @type {{ message?: string }} */ (e).message },
292
+ { status: 500 },
293
+ );
243
294
  }
244
295
  }
245
296
 
@@ -250,8 +301,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
250
301
  const absDir = isAbsolute(dir) ? dir : resolve(root, dir);
251
302
  try {
252
303
  assertAccessible(absDir, root, activeProjectRoot);
253
- } catch (/** @type {any} */ e) {
254
- return Response.json({ error: e.message }, { status: 400 });
304
+ } catch (/** @type {unknown} */ e) {
305
+ return Response.json(
306
+ { error: /** @type {{ message?: string }} */ (e).message },
307
+ { status: 400 },
308
+ );
255
309
  }
256
310
 
257
311
  /** Report a path relative to the active project root (or server root as fallback). */
@@ -299,8 +353,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
299
353
  });
300
354
  }
301
355
  return Response.json(files);
302
- } catch (/** @type {any} */ e) {
303
- return Response.json({ error: e.message }, { status: 500 });
356
+ } catch (/** @type {unknown} */ e) {
357
+ return Response.json(
358
+ { error: /** @type {{ message?: string }} */ (e).message },
359
+ { status: 500 },
360
+ );
304
361
  }
305
362
  }
306
363
 
@@ -310,8 +367,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
310
367
  const scanRoot = isAbsolute(dir) ? dir : resolve(root, dir);
311
368
  try {
312
369
  assertAccessible(scanRoot, root, activeProjectRoot);
313
- } catch (/** @type {any} */ e) {
314
- return Response.json({ error: e.message }, { status: 400 });
370
+ } catch (/** @type {unknown} */ e) {
371
+ return Response.json(
372
+ { error: /** @type {{ message?: string }} */ (e).message },
373
+ { status: 400 },
374
+ );
315
375
  }
316
376
  try {
317
377
  const glob = new Bun.Glob("**/*.{json,md}");
@@ -325,7 +385,6 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
325
385
  continue;
326
386
  const fp = resolve(scanRoot, match);
327
387
  try {
328
- /** @type {any} */
329
388
  let content;
330
389
  if (match.endsWith(".md")) {
331
390
  const source = await readFile(fp, "utf8");
@@ -406,14 +465,17 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
406
465
  source: "npm",
407
466
  package: name,
408
467
  description: decl.description || null,
409
- props: (decl.attributes || []).map((/** @type {any} */ a) => ({
410
- name: a.name,
411
- type: a.type?.text,
412
- default: a.default,
413
- description: a.description || null,
414
- })),
468
+ props: (decl.attributes || []).map(
469
+ (/** @type {Record<string, unknown>} */ a) => ({
470
+ name: a.name,
471
+ type: /** @type {Record<string, unknown> | undefined} */ (a.type)?.text,
472
+ default: a.default,
473
+ description: a.description || null,
474
+ }),
475
+ ),
415
476
  members: (decl.members || []).filter(
416
- (/** @type {any} */ m) => m.kind === "field" && m.privacy !== "private",
477
+ (/** @type {Record<string, unknown>} */ m) =>
478
+ m.kind === "field" && m.privacy !== "private",
417
479
  ),
418
480
  slots: decl.slots || [],
419
481
  events: decl.events || [],
@@ -429,8 +491,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
429
491
  } catch {} // skip if no project package.json
430
492
 
431
493
  return Response.json(components);
432
- } catch (/** @type {any} */ e) {
433
- return Response.json({ error: e.message }, { status: 500 });
494
+ } catch (/** @type {unknown} */ e) {
495
+ return Response.json(
496
+ { error: /** @type {{ message?: string }} */ (e).message },
497
+ { status: 500 },
498
+ );
434
499
  }
435
500
  }
436
501
 
@@ -445,7 +510,14 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
445
510
  if (!existsSync(pkgPath)) return Response.json([]);
446
511
  const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
447
512
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
448
- /** @type {any[]} */
513
+ /**
514
+ * @type {{
515
+ * name: string;
516
+ * version: string;
517
+ * hasCem: boolean;
518
+ * customElementsPath: string | null;
519
+ * }[]}
520
+ */
449
521
  const packages = [];
450
522
  for (const [name, version] of Object.entries(deps)) {
451
523
  const depPkgPath = resolve(scanRoot, "node_modules", ...name.split("/"), "package.json");
@@ -467,8 +539,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
467
539
  } catch {}
468
540
  }
469
541
  return Response.json(packages);
470
- } catch (/** @type {any} */ e) {
471
- return Response.json({ error: e.message }, { status: 500 });
542
+ } catch (/** @type {unknown} */ e) {
543
+ return Response.json(
544
+ { error: /** @type {{ message?: string }} */ (e).message },
545
+ { status: 500 },
546
+ );
472
547
  }
473
548
  }
474
549
 
@@ -493,8 +568,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
493
568
  if (!existsSync(cemPath)) return Response.json({ cem: null });
494
569
  const cem = JSON.parse(await readFile(cemPath, "utf8"));
495
570
  return Response.json({ cem });
496
- } catch (/** @type {any} */ e) {
497
- return Response.json({ error: e.message }, { status: 500 });
571
+ } catch (/** @type {unknown} */ e) {
572
+ return Response.json(
573
+ { error: /** @type {{ message?: string }} */ (e).message },
574
+ { status: 500 },
575
+ );
498
576
  }
499
577
  }
500
578
 
@@ -519,8 +597,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
519
597
  );
520
598
  }
521
599
  return Response.json({ ok: true });
522
- } catch (/** @type {any} */ e) {
523
- return Response.json({ error: e.message }, { status: 500 });
600
+ } catch (/** @type {unknown} */ e) {
601
+ return Response.json(
602
+ { error: /** @type {{ message?: string }} */ (e).message },
603
+ { status: 500 },
604
+ );
524
605
  }
525
606
  }
526
607
 
@@ -543,8 +624,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
543
624
  );
544
625
  }
545
626
  return Response.json({ ok: true });
546
- } catch (/** @type {any} */ e) {
547
- return Response.json({ error: e.message }, { status: 500 });
627
+ } catch (/** @type {unknown} */ e) {
628
+ return Response.json(
629
+ { error: /** @type {{ message?: string }} */ (e).message },
630
+ { status: 500 },
631
+ );
548
632
  }
549
633
  }
550
634
 
@@ -555,18 +639,21 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
555
639
  const abs = fp.startsWith("~") ? fp.replace("~", process.env.HOME || "") : fp;
556
640
  try {
557
641
  assertAccessible(abs, root, activeProjectRoot);
558
- } catch (/** @type {any} */ e) {
559
- return new Response(e.message, { status: 400 });
642
+ } catch (/** @type {unknown} */ e) {
643
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
560
644
  }
561
645
  try {
562
646
  return Response.json({
563
647
  content: await readFile(abs, "utf8"),
564
648
  path: fp,
565
649
  });
566
- } catch (/** @type {any} */ e) {
567
- return e.code === "ENOENT"
650
+ } catch (/** @type {unknown} */ e) {
651
+ return /** @type {{ code?: string }} */ (e).code === "ENOENT"
568
652
  ? new Response("Not found", { status: 404 })
569
- : Response.json({ error: e.message }, { status: 500 });
653
+ : Response.json(
654
+ { error: /** @type {{ message?: string }} */ (e).message },
655
+ { status: 500 },
656
+ );
570
657
  }
571
658
  }
572
659
 
@@ -577,15 +664,18 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
577
664
  const abs = resolve(root, fp);
578
665
  try {
579
666
  assertAccessible(abs, root, activeProjectRoot);
580
- } catch (/** @type {any} */ e) {
581
- return new Response(e.message, { status: 400 });
667
+ } catch (/** @type {unknown} */ e) {
668
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
582
669
  }
583
670
  try {
584
671
  await mkdir(dirname(abs), { recursive: true });
585
672
  await writeFile(abs, await req.text(), "utf8");
586
673
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
587
- } catch (/** @type {any} */ e) {
588
- return Response.json({ error: e.message }, { status: 500 });
674
+ } catch (/** @type {unknown} */ e) {
675
+ return Response.json(
676
+ { error: /** @type {{ message?: string }} */ (e).message },
677
+ { status: 500 },
678
+ );
589
679
  }
590
680
  }
591
681
 
@@ -596,16 +686,19 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
596
686
  const abs = resolve(root, fp);
597
687
  try {
598
688
  assertAccessible(abs, root, activeProjectRoot);
599
- } catch (/** @type {any} */ e) {
600
- return new Response(e.message, { status: 400 });
689
+ } catch (/** @type {unknown} */ e) {
690
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
601
691
  }
602
692
  try {
603
693
  await mkdir(dirname(abs), { recursive: true });
604
694
  const buffer = await req.arrayBuffer();
605
695
  await Bun.write(abs, new Uint8Array(buffer));
606
696
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
607
- } catch (/** @type {any} */ e) {
608
- return Response.json({ error: e.message }, { status: 500 });
697
+ } catch (/** @type {unknown} */ e) {
698
+ return Response.json(
699
+ { error: /** @type {{ message?: string }} */ (e).message },
700
+ { status: 500 },
701
+ );
609
702
  }
610
703
  }
611
704
 
@@ -616,16 +709,19 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
616
709
  const abs = resolve(root, fp);
617
710
  try {
618
711
  assertAccessible(abs, root, activeProjectRoot);
619
- } catch (/** @type {any} */ e) {
620
- return new Response(e.message, { status: 400 });
712
+ } catch (/** @type {unknown} */ e) {
713
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
621
714
  }
622
715
  try {
623
716
  await unlink(abs);
624
717
  return Response.json({ ok: true, path: fwd(relative(root, abs)) });
625
- } catch (/** @type {any} */ e) {
626
- return e.code === "ENOENT"
718
+ } catch (/** @type {unknown} */ e) {
719
+ return /** @type {{ code?: string }} */ (e).code === "ENOENT"
627
720
  ? new Response("Not found", { status: 404 })
628
- : Response.json({ error: e.message }, { status: 500 });
721
+ : Response.json(
722
+ { error: /** @type {{ message?: string }} */ (e).message },
723
+ { status: 500 },
724
+ );
629
725
  }
630
726
  }
631
727
 
@@ -644,8 +740,8 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
644
740
  try {
645
741
  assertAccessible(absFrom, root, activeProjectRoot);
646
742
  assertAccessible(absTo, root, activeProjectRoot);
647
- } catch (/** @type {any} */ e) {
648
- return new Response(e.message, { status: 400 });
743
+ } catch (/** @type {unknown} */ e) {
744
+ return new Response(/** @type {{ message?: string }} */ (e).message, { status: 400 });
649
745
  }
650
746
  try {
651
747
  await mkdir(dirname(absTo), { recursive: true });
@@ -655,8 +751,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
655
751
  from: fwd(relative(root, absFrom)),
656
752
  to: fwd(relative(root, absTo)),
657
753
  });
658
- } catch (/** @type {any} */ e) {
659
- return Response.json({ error: e.message }, { status: 500 });
754
+ } catch (/** @type {unknown} */ e) {
755
+ return Response.json(
756
+ { error: /** @type {{ message?: string }} */ (e).message },
757
+ { status: 500 },
758
+ );
660
759
  }
661
760
  }
662
761
 
@@ -684,8 +783,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
684
783
  path: matches[0],
685
784
  ...(matches.length > 1 ? { alternatives: matches } : {}),
686
785
  });
687
- } catch (/** @type {any} */ e) {
688
- return Response.json({ error: e.message }, { status: 500 });
786
+ } catch (/** @type {unknown} */ e) {
787
+ return Response.json(
788
+ { error: /** @type {{ message?: string }} */ (e).message },
789
+ { status: 500 },
790
+ );
689
791
  }
690
792
  }
691
793
 
@@ -705,8 +807,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
705
807
  } else {
706
808
  moduleAbsPath = resolve(root, src);
707
809
  }
708
- } catch (/** @type {any} */ e) {
709
- return Response.json({ schema: null, error: e.message });
810
+ } catch (/** @type {unknown} */ e) {
811
+ return Response.json({
812
+ schema: null,
813
+ error: /** @type {{ message?: string }} */ (e).message,
814
+ });
710
815
  }
711
816
 
712
817
  // .class.json: read and extract schema directly
@@ -715,8 +820,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
715
820
  const content = readFileSync(moduleAbsPath, "utf8");
716
821
  const classDef = JSON.parse(content);
717
822
  return Response.json({ schema: extractStudioSchema(classDef, moduleAbsPath) });
718
- } catch (/** @type {any} */ e) {
719
- return Response.json({ schema: null, error: e.message });
823
+ } catch (/** @type {unknown} */ e) {
824
+ return Response.json({
825
+ schema: null,
826
+ error: /** @type {{ message?: string }} */ (e).message,
827
+ });
720
828
  }
721
829
  }
722
830
 
@@ -741,8 +849,11 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
741
849
  return Response.json({ schema: null, error: `Export "${exportName}" not found` });
742
850
  }
743
851
  return Response.json({ schema: ExportedClass.schema ?? null });
744
- } catch (/** @type {any} */ e) {
745
- return Response.json({ schema: null, error: e.message });
852
+ } catch (/** @type {unknown} */ e) {
853
+ return Response.json({
854
+ schema: null,
855
+ error: /** @type {{ message?: string }} */ (e).message,
856
+ });
746
857
  }
747
858
  }
748
859
 
@@ -763,8 +874,44 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
763
874
 
764
875
  try {
765
876
  if (gitCmd === "status" && req.method === "GET") {
766
- const out = await runGit(["status", "--porcelain=v2", "--branch"]);
767
- return Response.json(parseGitStatus(out));
877
+ // Check if we're in a git repo first
878
+ const checkProc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
879
+ cwd,
880
+ stdout: "pipe",
881
+ stderr: "pipe",
882
+ });
883
+ const checkExit = await checkProc.exited;
884
+ if (checkExit !== 0) {
885
+ return Response.json({
886
+ isRepo: false,
887
+ branch: "",
888
+ files: [],
889
+ ahead: 0,
890
+ behind: 0,
891
+ remotes: [],
892
+ });
893
+ }
894
+
895
+ const [out, remotesOut] = await Promise.all([
896
+ runGit(["status", "--porcelain=v2", "--branch"]),
897
+ runGit(["remote"]),
898
+ ]);
899
+ const status = parseGitStatus(out);
900
+ status.isRepo = true;
901
+ status.remotes = remotesOut.trim().split("\n").filter(Boolean);
902
+ return Response.json(status);
903
+ }
904
+
905
+ if (gitCmd === "init" && req.method === "POST") {
906
+ await runGit(["init"]);
907
+ return Response.json({ ok: true });
908
+ }
909
+
910
+ if (gitCmd === "add-remote" && req.method === "POST") {
911
+ const { name, url: remoteUrl } = await req.json();
912
+ if (!name || !remoteUrl) return new Response("name and url required", { status: 400 });
913
+ await runGit(["remote", "add", name, remoteUrl]);
914
+ return Response.json({ ok: true });
768
915
  }
769
916
 
770
917
  if (gitCmd === "branches" && req.method === "GET") {
@@ -828,7 +975,17 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
828
975
  }
829
976
 
830
977
  if (gitCmd === "push" && req.method === "POST") {
831
- await runGit(["push"]);
978
+ let body = {};
979
+ try {
980
+ body = await req.json();
981
+ } catch {}
982
+ const { setUpstream } = /** @type {{ setUpstream?: boolean }} */ (body);
983
+ if (setUpstream) {
984
+ const branch = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"])).trim();
985
+ await runGit(["push", "-u", "origin", branch]);
986
+ } else {
987
+ await runGit(["push"]);
988
+ }
832
989
  return Response.json({ ok: true });
833
990
  }
834
991
 
@@ -885,11 +1042,138 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
885
1042
  await runGit(["checkout", "--", ...files]);
886
1043
  return Response.json({ ok: true });
887
1044
  }
1045
+
1046
+ if (gitCmd === "clone" && req.method === "POST") {
1047
+ const { url } = await req.json();
1048
+ if (!url || typeof url !== "string")
1049
+ return Response.json({ error: "Missing url" }, { status: 400 });
1050
+ const repoName = basename(url.replace(/\.git$/, ""));
1051
+ const dest = resolve(cwd, repoName);
1052
+ const proc = Bun.spawn(["git", "clone", url, dest], {
1053
+ cwd,
1054
+ stdout: "pipe",
1055
+ stderr: "pipe",
1056
+ });
1057
+ const exitCode = await proc.exited;
1058
+ const stderr = await new Response(proc.stderr).text();
1059
+ if (exitCode !== 0) throw new Error(stderr || `git clone exited with ${exitCode}`);
1060
+ return Response.json({ ok: true, root: dest });
1061
+ }
1062
+ } catch (/** @type {unknown} */ e) {
1063
+ return Response.json(
1064
+ { error: /** @type {{ message?: string }} */ (e).message },
1065
+ { status: 500 },
1066
+ );
1067
+ }
1068
+ }
1069
+
1070
+ // ─── Content Type Resolution ──────────────────────────────────────────────
1071
+
1072
+ if (path === "/__studio/content" && req.method === "POST") {
1073
+ try {
1074
+ const body = await req.json();
1075
+ const { $prototype, contentType, id, filter, sort, limit } = body;
1076
+ const projectRoot = activeProjectRoot || root;
1077
+
1078
+ let projectConfig;
1079
+ try {
1080
+ projectConfig = JSON.parse(readFileSync(resolve(projectRoot, "project.json"), "utf8"));
1081
+ } catch {
1082
+ return Response.json({ error: "No project.json found" }, { status: 404 });
1083
+ }
1084
+
1085
+ if (!projectConfig.contentTypes?.[contentType]) {
1086
+ return Response.json({ error: `Content type "${contentType}" not found` }, { status: 404 });
1087
+ }
1088
+
1089
+ const contentTypes = await loadContentTypes(projectRoot, projectConfig);
1090
+ const entries = contentTypes.get(contentType);
1091
+ if (!entries || entries.length === 0) {
1092
+ return Response.json($prototype === "ContentCollection" ? [] : null);
1093
+ }
1094
+
1095
+ if ($prototype === "ContentCollection") {
1096
+ const result = queryContentType(entries, { filter, sort, limit });
1097
+ return Response.json(result);
1098
+ }
1099
+
1100
+ if ($prototype === "ContentEntry") {
1101
+ const entry = findEntry(entries, id);
1102
+ return Response.json(entry ?? null);
1103
+ }
1104
+
1105
+ return Response.json({ error: "Invalid $prototype" }, { status: 400 });
1106
+ } catch (/** @type {unknown} */ e) {
1107
+ return Response.json(
1108
+ { error: /** @type {{ message?: string }} */ (e).message },
1109
+ { status: 500 },
1110
+ );
1111
+ }
1112
+ }
1113
+
1114
+ // ─── AI Assistant ─────────────────────────────────────────────────────────
1115
+
1116
+ if (path === "/__studio/ai/auth-status" && req.method === "GET") {
1117
+ const status = await claude.getAuthStatus();
1118
+ return Response.json(status);
1119
+ }
1120
+
1121
+ if (path === "/__studio/ai/session" && req.method === "POST") {
1122
+ try {
1123
+ const body = await req.json();
1124
+ const projectDir = activeProjectRoot || root;
1125
+ const result = claude.createSession(projectDir, body.message, {
1126
+ systemPrompt: body.systemPrompt,
1127
+ });
1128
+ return Response.json(result);
888
1129
  } catch (/** @type {any} */ e) {
889
1130
  return Response.json({ error: e.message }, { status: 500 });
890
1131
  }
891
1132
  }
892
1133
 
1134
+ if (
1135
+ path.startsWith("/__studio/ai/session/") &&
1136
+ path.endsWith("/stream") &&
1137
+ req.method === "GET"
1138
+ ) {
1139
+ const id = path.split("/")[4];
1140
+ return claude.streamSession(id);
1141
+ }
1142
+
1143
+ if (
1144
+ path.startsWith("/__studio/ai/session/") &&
1145
+ path.endsWith("/message") &&
1146
+ req.method === "POST"
1147
+ ) {
1148
+ try {
1149
+ const id = path.split("/")[4];
1150
+ const body = await req.json();
1151
+ claude.sendMessage(id, body.message);
1152
+ return Response.json({ ok: true });
1153
+ } catch (/** @type {any} */ e) {
1154
+ return Response.json({ error: e.message }, { status: 500 });
1155
+ }
1156
+ }
1157
+
1158
+ if (path.startsWith("/__studio/ai/session/") && path.endsWith("/stop") && req.method === "POST") {
1159
+ const id = path.split("/")[4];
1160
+ claude.stopSession(id);
1161
+ return Response.json({ ok: true });
1162
+ }
1163
+
1164
+ if (path.startsWith("/__studio/ai/session/") && req.method === "DELETE") {
1165
+ const id = path.split("/")[4];
1166
+ claude.deleteSession(id);
1167
+ return Response.json({ ok: true });
1168
+ }
1169
+
1170
+ if (path.startsWith("/__studio/ai/session/") && req.method === "GET") {
1171
+ const id = path.split("/")[4];
1172
+ const info = claude.getSession(id);
1173
+ if (!info) return Response.json({ error: "Not found" }, { status: 404 });
1174
+ return Response.json(info);
1175
+ }
1176
+
893
1177
  return null;
894
1178
  }
895
1179
 
@@ -898,9 +1182,13 @@ export async function handleStudioApi(req, url, root, activeProjectRoot = null)
898
1182
  * $defs.fields into the flat { description, properties, required } shape that renderSchemaFields()
899
1183
  * in the studio already consumes.
900
1184
  *
901
- * @param {any} classDef
1185
+ * @param {ClassJsonDef} classDef
902
1186
  * @param {string} classJsonPath
903
- * @returns {{ description: any; properties: Record<string, any>; required: string[] }}
1187
+ * @returns {{
1188
+ * description: string | undefined;
1189
+ * properties: Record<string, Record<string, unknown>>;
1190
+ * required: string[];
1191
+ * }}
904
1192
  */
905
1193
  function extractStudioSchema(classDef, classJsonPath) {
906
1194
  // If extends.$ref points to a parent, recursively merge
@@ -918,7 +1206,7 @@ function extractStudioSchema(classDef, classJsonPath) {
918
1206
 
919
1207
  const params = classDef.$defs?.parameters ?? {};
920
1208
  const fields = classDef.$defs?.fields ?? {};
921
- /** @type {Record<string, any>} */
1209
+ /** @type {Record<string, Record<string, unknown>>} */
922
1210
  const properties = {};
923
1211
  /** @type {string[]} */
924
1212
  const required = [];
@@ -934,6 +1222,7 @@ function extractStudioSchema(classDef, classJsonPath) {
934
1222
  // Build properties from parameters (constructor config surface)
935
1223
  for (const [key, param] of Object.entries(params)) {
936
1224
  const id = param.identifier ?? key;
1225
+ /** @type {Record<string, unknown>} */
937
1226
  const prop = {};
938
1227
  if (param.type && typeof param.type === "object") Object.assign(prop, param.type);
939
1228
  if (param.description) prop.description = param.description;
@@ -947,6 +1236,7 @@ function extractStudioSchema(classDef, classJsonPath) {
947
1236
  if (field.role !== "field") continue;
948
1237
  if (field.access === "private") continue;
949
1238
  const id = field.identifier ?? key;
1239
+ /** @type {Record<string, unknown>} */
950
1240
  const prop = {};
951
1241
  if (field.type && typeof field.type === "object") Object.assign(prop, field.type);
952
1242
  if (field.description) prop.description = field.description;
package/src/watch.js CHANGED
@@ -55,7 +55,7 @@ export function injectSSE(html) {
55
55
  * Create the file watcher + SSE system.
56
56
  *
57
57
  * @param {string} root - Absolute path to watch
58
- * @param {any[]} builds - Build entries (for selective rebuild)
58
+ * @param {BuildEntry[]} builds - Build entries (for selective rebuild)
59
59
  * @param {{ ignore?: string[]; debounce?: number; reloadOnAnyChange?: boolean }} [opts]
60
60
  * @returns {{
61
61
  * broadcast: () => void;
@@ -77,7 +77,7 @@ export function createWatcher(root, builds, opts = {}) {
77
77
  }
78
78
 
79
79
  function handleSSE() {
80
- /** @type {any} */
80
+ /** @type {((msg: string) => void) | undefined} */
81
81
  let send;
82
82
  const stream = new ReadableStream({
83
83
  start(c) {
@@ -96,7 +96,7 @@ export function createWatcher(root, builds, opts = {}) {
96
96
  }, 15_000);
97
97
  },
98
98
  cancel() {
99
- clients.delete(send);
99
+ if (send) clients.delete(send);
100
100
  },
101
101
  });
102
102
  return new Response(stream, {
@@ -108,7 +108,7 @@ export function createWatcher(root, builds, opts = {}) {
108
108
  });
109
109
  }
110
110
 
111
- /** @type {any} */
111
+ /** @type {ReturnType<typeof setTimeout> | null} */
112
112
  let timer = null;
113
113
  const watcher = chokidar.watch(root, {
114
114
  ignored: (watchedPath) => shouldIgnore(watchedPath, ignore),
@@ -123,7 +123,7 @@ export function createWatcher(root, builds, opts = {}) {
123
123
  watcher.on("all", (_, changedPath) => {
124
124
  const filename = relative(root, changedPath);
125
125
  if (!filename || filename.startsWith("..")) return;
126
- clearTimeout(timer);
126
+ clearTimeout(timer ?? undefined);
127
127
  timer = setTimeout(async () => {
128
128
  if (builds.length > 0) {
129
129
  const result = await rebuild(builds, filename);