@netnodeag/kraftwerk 0.62.0 → 0.63.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.
Files changed (73) hide show
  1. package/dist/cli/routines.js +2 -3
  2. package/dist/client/index.d.ts +59 -0
  3. package/dist/client/index.js +63 -0
  4. package/dist/client/live.d.ts +34 -0
  5. package/dist/client/live.js +169 -0
  6. package/dist/client/routes.d.ts +125 -0
  7. package/dist/client/routes.js +126 -0
  8. package/dist/inspector/api/agents.d.ts +31 -0
  9. package/dist/inspector/api/agents.js +50 -0
  10. package/dist/inspector/api/attention.d.ts +10 -0
  11. package/dist/inspector/api/attention.js +23 -0
  12. package/dist/inspector/api/channels.d.ts +67 -0
  13. package/dist/inspector/api/channels.js +58 -0
  14. package/dist/inspector/api/chats.d.ts +55 -0
  15. package/dist/inspector/api/chats.js +154 -0
  16. package/dist/inspector/api/files.d.ts +15 -0
  17. package/dist/inspector/api/files.js +43 -0
  18. package/dist/inspector/api/git.d.ts +25 -0
  19. package/dist/inspector/api/git.js +56 -0
  20. package/dist/inspector/api/index.d.ts +334 -0
  21. package/dist/inspector/api/index.js +43 -0
  22. package/dist/inspector/api/knowledge.d.ts +13 -0
  23. package/dist/inspector/api/knowledge.js +56 -0
  24. package/dist/inspector/api/mime.d.ts +2 -0
  25. package/dist/inspector/api/mime.js +18 -0
  26. package/dist/inspector/api/projects.d.ts +11 -0
  27. package/dist/inspector/api/projects.js +32 -0
  28. package/dist/inspector/api/router.d.ts +75 -0
  29. package/dist/inspector/api/router.js +186 -0
  30. package/dist/inspector/api/runs.d.ts +33 -0
  31. package/dist/inspector/api/runs.js +105 -0
  32. package/dist/inspector/api/socket.d.ts +3 -0
  33. package/dist/inspector/api/socket.js +115 -0
  34. package/dist/inspector/api/vibeables.d.ts +4 -0
  35. package/dist/inspector/api/vibeables.js +31 -0
  36. package/dist/inspector/api/workspace.d.ts +55 -0
  37. package/dist/inspector/api/workspace.js +117 -0
  38. package/dist/inspector/chat/sessions.js +31 -19
  39. package/dist/inspector/context.d.ts +5 -7
  40. package/dist/inspector/context.js +6 -20
  41. package/dist/inspector/git.js +52 -43
  42. package/dist/inspector/live.d.ts +42 -0
  43. package/dist/inspector/live.js +91 -0
  44. package/dist/inspector/notifications.js +19 -13
  45. package/dist/inspector/routines.d.ts +1 -1
  46. package/dist/inspector/routines.js +8 -5
  47. package/dist/inspector/runner.js +5 -4
  48. package/dist/inspector/server.d.ts +2 -2
  49. package/dist/inspector/server.js +59 -1393
  50. package/dist/inspector/version.d.ts +9 -0
  51. package/dist/inspector/version.js +27 -0
  52. package/dist/inspector/vibeables.js +25 -23
  53. package/dist/inspector/workspace.d.ts +42 -0
  54. package/dist/inspector/workspace.js +63 -0
  55. package/dist/inspector/ws.d.ts +11 -0
  56. package/dist/inspector/ws.js +143 -0
  57. package/inspector/dist/assets/{dist-COwx246i.js → dist-9RESajfo.js} +1 -1
  58. package/inspector/dist/assets/{dist-OMwGczSn.js → dist-Bm5S1BoZ.js} +1 -1
  59. package/inspector/dist/assets/dist-C8BofvHx.js +1 -0
  60. package/inspector/dist/assets/{dist-jBYydzOe.js → dist-CIs4wnTu.js} +1 -1
  61. package/inspector/dist/assets/{dist-Crdu4xR8.js → dist-CjuETd-_.js} +1 -1
  62. package/inspector/dist/assets/{dist-ClyjyU-C.js → dist-CmVK6rgN.js} +1 -1
  63. package/inspector/dist/assets/{dist-CeGII4MF.js → dist-CryXvMV_.js} +1 -1
  64. package/inspector/dist/assets/{dist-DqU5Wgif.js → dist-D1hk0iC5.js} +1 -1
  65. package/inspector/dist/assets/{dist-BIHxko9t.js → dist-DvW9QE1d.js} +1 -1
  66. package/inspector/dist/assets/{dist-BMBdyFA5.js → dist-VD9jMxBv.js} +1 -1
  67. package/inspector/dist/assets/{dist-BnnTidDP.js → dist-VlAqWD8T.js} +1 -1
  68. package/inspector/dist/assets/{editor-Cmri6xKM.js → editor-DkWqwl89.js} +3 -3
  69. package/inspector/dist/assets/index-DRJS2pGj.js +229 -0
  70. package/inspector/dist/index.html +1 -1
  71. package/package.json +7 -2
  72. package/inspector/dist/assets/dist-wc-PzIzB.js +0 -1
  73. package/inspector/dist/assets/index-C1s81t9X.js +0 -229
@@ -1,100 +1,22 @@
1
1
  import { existsSync, promises as fs } from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
- import { attachmentPath, readMeta, saveAttachment } from "./chat/store.js";
5
- import { setOutputDir, setProjectRoot, getOutputDir, getProjectRoot } from "./context.js";
6
- import { publicHostFor, publicUrlFor, resolveProject, tunnelFor } from "../config.js";
4
+ import { setDefaultWorkspace, Workspace } from "./workspace.js";
5
+ import { publicHostFor, resolveProject, tunnelFor } from "../config.js";
7
6
  import { ACCESS_HEADER, verifyAccessToken } from "./access.js";
8
- import { cloudGoodbye, cloudStatus, startCloudSync, stopCloudSync } from "./cloud.js";
9
- import { listRuns, getRun, readRunFile, deleteRun, safeRunDir } from "./runs.js";
10
- import { agentStatuses } from "./agent-status.js";
11
- import { listAttention } from "./attention.js";
12
- import { deleteFile, filesSummary, listFiles, listScopes, makeFolder, moveFile, openFile, saveUpload } from "./files.js";
13
- import { appendJournal, readJournal } from "./journal.js";
14
- import { emptyTrash, listTrash, purgeFromTrash, restoreFromTrash, trashRoot } from "./trash.js";
15
- import { decide } from "./decisions.js";
16
- import { canSelfUpdate, startUpdate, updateStatus } from "./update.js";
17
- import { listWorkflows, getWorkflow, deleteWorkflow } from "./workflows.js";
18
- import { dockerStatus, triggerRun, stopRun } from "./runner.js";
19
- import { clearNotifications, listNotifications, markNotificationsRead } from "./notifications.js";
20
- import { startDiagnosis } from "./diagnose.js";
21
- import { deleteChannel, getChannel, listChannels, saveChannel, channelsRoot } from "./channels.js";
22
- import { cancelChat, createChat, forkChat, resetSession, answerElicitation, steerChat, stopChatTask, setChatConfig, listAgentSessionsFor, deleteChat, renameChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
23
- import { bundleDetail, conceptDetail, createBundle, deleteBundle, knowledgeIndex, UI_ACTOR, putConcept, verifyFromUi, } from "./knowledge.js";
24
- import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
25
- import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
26
- import { discoverWorkspaces, forgetWorkspace, listWorkspacesDetailed, markWorkspaceStopped, registerInstance, registerWorkspace, startWorkspace, stopWorkspace, tildify, unregisterInstance, } from "./instances.js";
27
- import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
28
- import { getSettings, saveSettings } from "./settings.js";
29
- import { addRepo, listRepos, openRepos, removeRepo, repoCommitDiff, repoDetail, repoDiff, updateRepo, } from "./repos.js";
30
- import { createVibeable, deleteVibeable, disposeAllDevs, listVibeables, openVibeables, proxyUpgrade, resolveVibeable, serveVibeable, startDev, stopDev, subscribeVibeable, vibeableStatus, } from "./vibeables.js";
31
- import { searchAgents } from "./search.js";
32
- import { appendProjectLog, createProject, deleteProject, saveLayout, getProject, linkProject, listProjects, openProjects, saveProject, } from "./projects.js";
33
- import { allRoutines, deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
34
- /**
35
- * The inspector server: a plain node:http server with no dependencies.
36
- * Serves the prebuilt SPA (inspector/dist) plus the JSON/file API the
37
- * frontend polls. Realtime is polling — no daemon, no socket, works on a
38
- * plain filesystem.
39
- */
40
- /** CSP for raw run files: same shape as the vibeable preview — scripts allowed, origin opaque. */
41
- const RUN_FILE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads";
42
- const MIME = {
43
- ".html": "text/html; charset=utf-8",
44
- ".js": "text/javascript; charset=utf-8",
45
- ".css": "text/css; charset=utf-8",
46
- ".svg": "image/svg+xml",
47
- ".png": "image/png",
48
- ".jpg": "image/jpeg",
49
- ".jpeg": "image/jpeg",
50
- ".gif": "image/gif",
51
- ".webp": "image/webp",
52
- ".ico": "image/x-icon",
53
- ".pdf": "application/pdf",
54
- ".json": "application/json; charset=utf-8",
55
- ".woff": "font/woff",
56
- ".woff2": "font/woff2",
57
- ".txt": "text/plain; charset=utf-8",
58
- };
59
- /** Text preview payloads are capped; the tail matters most for logs. */
60
- const MAX_TEXT = 400_000;
61
- function json(res, body, status = 200) {
62
- // A late error on an SSE response must not try to write headers again.
63
- if (res.headersSent)
64
- return void res.end();
65
- res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
66
- res.end(JSON.stringify(body));
67
- }
68
- /** A raw upload body (attachments), capped at `max` bytes. */
69
- function readRawBody(req, max) {
70
- return new Promise((resolve, reject) => {
71
- const chunks = [];
72
- let size = 0;
73
- req.on("data", (c) => {
74
- size += c.length;
75
- if (size > max) {
76
- reject(new Error(`file too large (max ${Math.round(max / 1_000_000)} MB)`));
77
- req.destroy();
78
- return;
79
- }
80
- chunks.push(c);
81
- });
82
- req.on("end", () => resolve(Buffer.concat(chunks)));
83
- req.on("error", reject);
84
- });
85
- }
86
- function readBody(req) {
87
- return new Promise((resolve, reject) => {
88
- let data = "";
89
- req.on("data", (c) => {
90
- data += c;
91
- if (data.length > 1_000_000)
92
- reject(new Error("body too large"));
93
- });
94
- req.on("end", () => resolve(data));
95
- req.on("error", reject);
96
- });
97
- }
7
+ import { cloudGoodbye, startCloudSync, stopCloudSync } from "./cloud.js";
8
+ import { disposeAllBackends } from "./chat/sessions.js";
9
+ import { markWorkspaceStopped, registerInstance, registerWorkspace, unregisterInstance } from "./instances.js";
10
+ import { startGitSync } from "./git.js";
11
+ import { disposeAllDevs, proxyUpgrade, serveVibeable } from "./vibeables.js";
12
+ import { startRoutineScheduler } from "./routines.js";
13
+ import { apiRouter } from "./api/index.js";
14
+ import { serveSocket } from "./api/socket.js";
15
+ import { acceptWebSocket } from "./ws.js";
16
+ import { json } from "./api/router.js";
17
+ import { MIME } from "./api/mime.js";
18
+ import { getPkgVersion, RESTART_EXIT_CODE } from "./version.js";
19
+ export { RESTART_EXIT_CODE };
98
20
  /**
99
21
  * Interface to bind. Loopback by default: the UI is unauthenticated and its
100
22
  * chat runs coding agents against the repo, so it must not appear on the LAN
@@ -108,16 +30,6 @@ const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv
108
30
  const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
109
31
  const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
110
32
  const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
111
- /**
112
- * The hostname from kraftwerk.yml `public`, when set: the name a tunnel or
113
- * reverse proxy delivers as Host. A Cloudflare Tunnel forwards the
114
- * browser's Host untouched and sets no X-Forwarded-Host, so without this
115
- * the loopback bind would refuse every request that came through it. A
116
- * rebinding page cannot exploit it: the name is one the operator owns.
117
- */
118
- let publicHost = "";
119
- /** Access verification for requests arriving via the public hostname (kraftwerk.yml `tunnel.access`). */
120
- let access;
121
33
  /**
122
34
  * Whether the Host header names this server. A loopback bind alone does not
123
35
  * keep other sites out: a page on evil.example can re-point that name at
@@ -133,7 +45,7 @@ let access;
133
45
  * browser only sends it after a CORS preflight, which this server never
134
46
  * answers, and a form post cannot set headers at all.
135
47
  */
136
- function hostAllowed(req) {
48
+ function hostAllowed(req, { publicHost }) {
137
49
  if (!LOOPBACK_BIND)
138
50
  return true;
139
51
  if (forwardedHost(req))
@@ -145,7 +57,7 @@ function hostAllowed(req) {
145
57
  return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost") || (!!publicHost && name === publicHost);
146
58
  }
147
59
  /** Whether the browser addressed the public hostname (directly or, via a proxy, in X-Forwarded-Host). */
148
- function viaPublicHost(req) {
60
+ function viaPublicHost(req, { publicHost }) {
149
61
  if (!publicHost)
150
62
  return false;
151
63
  const host = forwardedHost(req) || req.headers.host;
@@ -159,8 +71,9 @@ function viaPublicHost(req) {
159
71
  * proxy) can deliver the public name in the first place. Returns the reason
160
72
  * to refuse, or undefined to proceed.
161
73
  */
162
- async function accessRefusal(req) {
163
- if (!access || !viaPublicHost(req))
74
+ async function accessRefusal(req, gate) {
75
+ const { access } = gate;
76
+ if (!access || !viaPublicHost(req, gate))
164
77
  return undefined;
165
78
  const raw = req.headers[ACCESS_HEADER];
166
79
  const token = Array.isArray(raw) ? raw[0] : raw;
@@ -183,36 +96,6 @@ function hostnameOf(host) {
183
96
  return "";
184
97
  }
185
98
  }
186
- /** Exit code the `kraftwerk ui` supervisor treats as "relaunch me". */
187
- export const RESTART_EXIT_CODE = 75;
188
- /** Whether a supervisor is around to respawn us (set by `kraftwerk ui`). */
189
- const supervised = () => process.env.KRAFTWERK_UI_SUPERVISED === "1";
190
- /** Package version as it is on disk right now (re-read per call, detects upgrades). */
191
- async function getDiskVersion() {
192
- try {
193
- const raw = await fs.readFile(new URL("../../package.json", import.meta.url), "utf8");
194
- return JSON.parse(raw).version ?? "";
195
- }
196
- catch {
197
- return "";
198
- }
199
- }
200
- /** Package version this process loaded with, read once. Works from src/ (tsx) and dist/ alike. */
201
- let pkgVersion;
202
- async function getPkgVersion() {
203
- pkgVersion ??= await getDiskVersion();
204
- return pkgVersion;
205
- }
206
- /** Package name from package.json (registry lookups). */
207
- async function getPkgName() {
208
- try {
209
- const raw = await fs.readFile(new URL("../../package.json", import.meta.url), "utf8");
210
- return JSON.parse(raw).name ?? "";
211
- }
212
- catch {
213
- return "";
214
- }
215
- }
216
99
  /**
217
100
  * Whether a state-changing request came from this UI rather than from some
218
101
  * other page the user happens to have open. The inspector has no login of
@@ -249,1241 +132,6 @@ function sameOrigin(req) {
249
132
  return false;
250
133
  }
251
134
  }
252
- async function handleApi(req, res, url) {
253
- const seg = url.pathname.split("/").filter(Boolean); // ["api", ...]
254
- const method = req.method ?? "GET";
255
- if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
256
- return json(res, { error: "cross-origin request refused" }, 403);
257
- }
258
- // Channels — shared transcripts with several agents (channels/<slug>/channel.yml + one chat).
259
- // GET /api/channels | POST /api/channels {name, purpose?, members, responder?}
260
- // POST /api/channels/from-chat {chatId, name, members, ...} — an agent session becomes a channel
261
- // GET/PUT/DELETE /api/channels/:slug
262
- if (seg.length >= 2 && seg[1] === "channels") {
263
- const view = async (c) => {
264
- const meta = await ensureChannelChat(c);
265
- const live = (await listChats()).find((x) => x.id === meta.id);
266
- return { ...c, chatId: meta.id, busy: live?.busy ?? false, awaitingApproval: live?.awaitingApproval ?? false, updatedAt: meta.updatedAt };
267
- };
268
- let body = {};
269
- if (method === "POST" || method === "PUT") {
270
- try {
271
- const raw = await readBody(req);
272
- if (raw)
273
- body = JSON.parse(raw);
274
- }
275
- catch {
276
- return json(res, { error: "invalid JSON body" }, 400);
277
- }
278
- }
279
- try {
280
- if (seg.length === 2 && method === "GET") {
281
- const channels = await listChannels();
282
- return json(res, { root: await channelsRoot(), channels: await Promise.all(channels.map(view)) });
283
- }
284
- if (seg.length === 2 && method === "POST") {
285
- const { chatId: _c, slug: _s, ...input } = body;
286
- return json(res, await view(await saveChannel(input)));
287
- }
288
- if (seg.length === 3 && seg[2] === "from-chat" && method === "POST") {
289
- const { chatId, slug: _s, project: _p, ...input } = body;
290
- if (!chatId)
291
- return json(res, { error: "chatId is required" }, 400);
292
- // A project chat's coworkers work in that project: the chat decides, not the body.
293
- let source = null;
294
- try {
295
- source = await readMeta(chatId);
296
- }
297
- catch { }
298
- if (!source)
299
- return json(res, { error: "chat not found" }, 404);
300
- const channel = await saveChannel({ ...input, ...(source.scope.kind === "project" ? { project: source.scope.slug } : {}) });
301
- const converted = await convertChatToChannel(chatId, channel);
302
- if (converted.error) {
303
- await deleteChannel(channel.slug).catch(() => { });
304
- return json(res, { error: converted.error }, 409);
305
- }
306
- return json(res, await view(channel));
307
- }
308
- if (seg.length === 3) {
309
- const slug = decodeURIComponent(seg[2]);
310
- const channel = await getChannel(slug);
311
- if (!channel)
312
- return json(res, { error: "channel not found" }, 404);
313
- if (method === "GET")
314
- return json(res, await view(channel));
315
- if (method === "PUT") {
316
- const { chatId: _c, slug: _s, ...input } = body;
317
- const saved = await saveChannel({ ...input, slug });
318
- const meta = await ensureChannelChat(saved);
319
- await dropChannelSeats(meta.id, saved.members);
320
- return json(res, await view(saved));
321
- }
322
- if (method === "DELETE") {
323
- const meta = (await listChats()).find((c) => c.scope.kind === "channel" && c.scope.slug === slug);
324
- if (meta)
325
- await deleteChat(meta.id);
326
- await deleteChannel(slug);
327
- return json(res, { ok: true });
328
- }
329
- }
330
- }
331
- catch (err) {
332
- return json(res, { error: err.message }, 400);
333
- }
334
- return json(res, { error: "not found" }, 404);
335
- }
336
- // GET /api/notifications — the bell: attention items, newest first, plus unread count.
337
- // POST /api/notifications/read {ids?: string[]} — mark some (or all) read.
338
- // DELETE /api/notifications — clear the list.
339
- if (seg.length >= 2 && seg[1] === "notifications") {
340
- if (seg.length === 2 && method === "GET")
341
- return json(res, await listNotifications());
342
- if (seg.length === 2 && method === "DELETE") {
343
- await clearNotifications();
344
- return json(res, { ok: true });
345
- }
346
- // POST /api/notifications/:id/diagnose — open a chat that investigates the failure behind an item.
347
- if (seg.length === 4 && seg[3] === "diagnose" && method === "POST") {
348
- const result = await startDiagnosis(seg[2]);
349
- if ("error" in result)
350
- return json(res, result, result.status);
351
- return json(res, result);
352
- }
353
- if (seg.length === 3 && seg[2] === "read" && method === "POST") {
354
- let body = {};
355
- try {
356
- const raw = await readBody(req);
357
- if (raw)
358
- body = JSON.parse(raw);
359
- }
360
- catch {
361
- return json(res, { error: "invalid JSON body" }, 400);
362
- }
363
- const ids = Array.isArray(body.ids) ? body.ids.map(String) : "all";
364
- return json(res, await markNotificationsRead(ids));
365
- }
366
- return json(res, { error: "not found" }, 404);
367
- }
368
- // GET /api/meta — package version + project name ("environment") for the UI.
369
- // ?probe=1 marks a discovery probe from another instance: answer identity
370
- // only, skip our own discovery (two instances must not probe each other
371
- // recursively).
372
- if (seg.length === 2 && seg[1] === "meta" && method === "GET") {
373
- const project = await resolveProject(getProjectRoot()).catch(() => null);
374
- const projectName = project?.config.name ?? (project ? path.basename(project.root) : "");
375
- const manual = project?.config.switcher ?? [];
376
- const discovered = url.searchParams.get("probe") === "1" ? [] : await discoverWorkspaces();
377
- // Manual switcher entries keep their configured name/icon; discovered
378
- // workspaces that duplicate one (same url, running) only contribute
379
- // the live flag. Stopped workspaces never collide — they carry a root,
380
- // and a manual entry pointing at the same port is just a link.
381
- const norm = (u) => u.replace(/\/+$/, "").replace("127.0.0.1", "localhost").toLowerCase();
382
- const manualUrls = new Set(manual.map((e) => norm(e.url)));
383
- const liveUrls = new Set(discovered.filter((d) => d.live).map((d) => norm(d.url)));
384
- const switcher = [
385
- ...manual.map((e) => (liveUrls.has(norm(e.url)) ? { ...e, live: true } : e)),
386
- ...discovered.filter((d) => !(d.live && manualUrls.has(norm(d.url)))),
387
- ];
388
- return json(res, {
389
- version: await getPkgVersion(),
390
- // A differing disk version means an upgrade landed while this process
391
- // runs — the UI offers a relaunch when a supervisor can respawn us.
392
- diskVersion: await getDiskVersion(),
393
- restartable: supervised(),
394
- projectName,
395
- projectIcon: project?.config.icon ?? "",
396
- projectColor: project?.config.color ?? "",
397
- projectNamed: !!project?.config.name,
398
- projectRoot: project?.root ?? getProjectRoot(),
399
- projectRootLabel: tildify(project?.root ?? getProjectRoot()),
400
- git: (project?.config.git && project.config.git.enabled !== false) === true,
401
- repos: (project?.config.repos && project.config.repos.enabled !== false) === true,
402
- vibeables: (project?.config.vibeables && project.config.vibeables.enabled !== false) === true,
403
- projects: (project?.config.projects && project.config.projects.enabled !== false) === true,
404
- publicUrl: (project && publicUrlFor(project)) ?? "",
405
- cloud: cloudStatus(),
406
- switcher,
407
- });
408
- }
409
- // GET /api/git — branch, ahead/behind, changed files (the git screen polls
410
- // this, so it is cached for a beat; ?fresh=1 bypasses the cache).
411
- if (seg.length === 2 && seg[1] === "git" && method === "GET") {
412
- return json(res, await gitStatus(url.searchParams.has("fresh")));
413
- }
414
- // GET /api/git/diff?path= — unified diff for one file
415
- if (seg.length === 3 && seg[1] === "git" && seg[2] === "diff" && method === "GET") {
416
- const file = url.searchParams.get("path");
417
- if (!file)
418
- return json(res, { error: "path required" }, 400);
419
- return json(res, await gitDiff(file));
420
- }
421
- // POST /api/git/commit {paths, message} — stage and commit the selection.
422
- // Commit and push stay manual; the background timer never writes history.
423
- if (seg.length === 3 && seg[1] === "git" && seg[2] === "commit" && method === "POST") {
424
- try {
425
- const body = JSON.parse(await readBody(req));
426
- const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === "string") : [];
427
- const message = typeof body.message === "string" ? body.message : "";
428
- const result = await gitCommit(paths, message);
429
- return json(res, result, result.ok ? 200 : 409);
430
- }
431
- catch (err) {
432
- return json(res, { error: err.message }, 400);
433
- }
434
- }
435
- // POST /api/git/{fetch,pull,push}
436
- if (seg.length === 3 && seg[1] === "git" && method === "POST" && ["fetch", "pull", "push"].includes(seg[2])) {
437
- const run = seg[2] === "fetch" ? gitFetch : seg[2] === "pull" ? gitPull : gitPush;
438
- const result = await run();
439
- return json(res, result, result.ok ? 200 : 409);
440
- }
441
- // GET /api/repos — every clone under the repos root, read live from git
442
- if (seg.length === 2 && seg[1] === "repos" && method === "GET") {
443
- return json(res, await listRepos());
444
- }
445
- // POST /api/repos {url, name?, branch?, depth?} — clone into the root
446
- if (seg.length === 2 && seg[1] === "repos" && method === "POST") {
447
- if ((await openRepos()).off)
448
- return json(res, { error: "repositories are off" }, 409);
449
- try {
450
- const body = JSON.parse(await readBody(req));
451
- const str = (v) => (typeof v === "string" ? v : undefined);
452
- const depth = body.depth === undefined ? undefined : typeof body.depth === "number" ? body.depth : Number.NaN;
453
- const repo = await addRepo({ url: str(body.url) ?? "", name: str(body.name), branch: str(body.branch), depth });
454
- return json(res, repo, 201);
455
- }
456
- catch (err) {
457
- return json(res, { error: err.message }, 400);
458
- }
459
- }
460
- // GET /api/vibeables — every app folder under the vibeables root | POST {name} — create one with the starter
461
- if (seg.length === 2 && seg[1] === "vibeables") {
462
- if (method === "GET")
463
- return json(res, await listVibeables());
464
- if (method === "POST") {
465
- if ((await openVibeables()).off)
466
- return json(res, { error: "vibeables are off" }, 409);
467
- try {
468
- const body = JSON.parse(await readBody(req));
469
- return json(res, await createVibeable(typeof body.name === "string" ? body.name : ""), 201);
470
- }
471
- catch (err) {
472
- return json(res, { error: err.message }, 400);
473
- }
474
- }
475
- }
476
- const vibeableError = (err) => {
477
- const msg = err.message;
478
- return [/^no vibeable/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
479
- };
480
- // GET /api/vibeables/<slug> — how the app previews: mode, static url, dev server state | DELETE — remove the folder
481
- if (seg.length === 3 && seg[1] === "vibeables") {
482
- try {
483
- if (method === "GET")
484
- return json(res, await vibeableStatus(seg[2]));
485
- if (method === "DELETE") {
486
- await deleteVibeable(seg[2]);
487
- return json(res, { ok: true });
488
- }
489
- }
490
- catch (err) {
491
- const [status, body] = vibeableError(err);
492
- return json(res, body, status);
493
- }
494
- }
495
- // GET /api/projects — every project folder under the projects root, with the sidebar's layout | POST {title, goal?, slug?} — create one
496
- // from the starter | PUT {top, sections} — save the sidebar's order and sections
497
- if (seg.length === 2 && seg[1] === "projects") {
498
- if (method === "GET")
499
- return json(res, await listProjects());
500
- if (method === "PUT") {
501
- try {
502
- return json(res, { ok: true, layout: await saveLayout(JSON.parse(await readBody(req))) });
503
- }
504
- catch (err) {
505
- return json(res, { error: err.message }, /are off/.test(err.message) ? 409 : 400);
506
- }
507
- }
508
- if (method === "POST") {
509
- if ((await openProjects()).off)
510
- return json(res, { error: "projects are off" }, 409);
511
- try {
512
- const body = JSON.parse(await readBody(req));
513
- const str = (v) => (typeof v === "string" ? v : undefined);
514
- return json(res, await createProject({ title: str(body.title) ?? "", goal: str(body.goal), slug: str(body.slug) }), 201);
515
- }
516
- catch (err) {
517
- return json(res, { error: err.message }, 400);
518
- }
519
- }
520
- }
521
- const projectError = (err) => {
522
- const msg = err.message;
523
- return [/^no project/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
524
- };
525
- // GET /api/projects/<slug> — definition, brief, state, log and link states | PUT — save fields | DELETE — remove the folder
526
- if (seg.length === 3 && seg[1] === "projects") {
527
- try {
528
- if (method === "GET") {
529
- const p = await getProject(seg[2]);
530
- return p ? json(res, p) : json(res, { error: `no project "${seg[2]}"` }, 404);
531
- }
532
- if (method === "PUT") {
533
- const body = JSON.parse(await readBody(req));
534
- return json(res, await saveProject(seg[2], body));
535
- }
536
- if (method === "DELETE") {
537
- await deleteProject(seg[2]);
538
- return json(res, { ok: true });
539
- }
540
- }
541
- catch (err) {
542
- const [status, body] = projectError(err);
543
- return json(res, body, status);
544
- }
545
- }
546
- // POST /api/projects/<slug>/log {entry, actor?} — append a stamped line to log.md
547
- if (seg.length === 4 && seg[1] === "projects" && seg[3] === "log" && method === "POST") {
548
- try {
549
- const body = JSON.parse(await readBody(req));
550
- const log = await appendProjectLog(seg[2], typeof body.entry === "string" ? body.entry : "", typeof body.actor === "string" ? body.actor : "human:user");
551
- return json(res, { ok: true, log });
552
- }
553
- catch (err) {
554
- const [status, body] = projectError(err);
555
- return json(res, body, status);
556
- }
557
- }
558
- // POST /api/projects/<slug>/links {kind, target, remove?} — add or drop one linked slug
559
- if (seg.length === 4 && seg[1] === "projects" && seg[3] === "links" && method === "POST") {
560
- try {
561
- const body = JSON.parse(await readBody(req));
562
- return json(res, await linkProject(seg[2], typeof body.kind === "string" ? body.kind : "", typeof body.target === "string" ? body.target : "", body.remove === true));
563
- }
564
- catch (err) {
565
- const [status, body] = projectError(err);
566
- return json(res, body, status);
567
- }
568
- }
569
- // GET /api/vibeables/<slug>/events — SSE: file changes (debounced) and dev-server state
570
- if (seg.length === 4 && seg[1] === "vibeables" && seg[3] === "events" && method === "GET") {
571
- let dir;
572
- try {
573
- dir = (await resolveVibeable(seg[2])).dir;
574
- }
575
- catch (err) {
576
- const [status, body] = vibeableError(err);
577
- return json(res, body, status);
578
- }
579
- res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
580
- res.write(":ok\n\n");
581
- const unsubscribe = subscribeVibeable(seg[2], dir, (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`));
582
- const heartbeat = setInterval(() => res.write(":hb\n\n"), 25_000);
583
- req.on("close", () => {
584
- clearInterval(heartbeat);
585
- unsubscribe();
586
- });
587
- return;
588
- }
589
- // POST /api/vibeables/<slug>/dev/{start,stop} — the app's dev command
590
- if (seg.length === 5 && seg[1] === "vibeables" && seg[3] === "dev" && method === "POST") {
591
- try {
592
- if (seg[4] === "start")
593
- return json(res, await startDev(seg[2]));
594
- if (seg[4] === "stop")
595
- return json(res, await stopDev(seg[2]));
596
- return json(res, { error: "not found" }, 404);
597
- }
598
- catch (err) {
599
- const [status, body] = vibeableError(err);
600
- return json(res, body, status);
601
- }
602
- }
603
- const repoError = (err) => {
604
- const msg = err.message;
605
- return [/^no repository/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
606
- };
607
- // GET /api/repos/<slug> — what is happening in the clone: changed files, line counts, recent commits (unpushed marked)
608
- if (seg.length === 3 && seg[1] === "repos" && method === "GET") {
609
- try {
610
- const detail = await repoDetail(seg[2]);
611
- return detail ? json(res, detail) : json(res, { error: `no repository "${seg[2]}"` }, 404);
612
- }
613
- catch (err) {
614
- const [status, body] = repoError(err);
615
- return json(res, body, status);
616
- }
617
- }
618
- // GET /api/repos/<slug>/diff?path= — unified diff of one changed file against HEAD
619
- if (seg.length === 4 && seg[1] === "repos" && seg[3] === "diff" && method === "GET") {
620
- try {
621
- const d = await repoDiff(seg[2], url.searchParams.get("path") ?? "");
622
- return json(res, d, d.error ? (/^not shown/.test(d.error) ? 404 : 400) : 200);
623
- }
624
- catch (err) {
625
- const [status, body] = repoError(err);
626
- return json(res, body, status);
627
- }
628
- }
629
- // GET /api/repos/<slug>/commits/<hash> — one commit as a patch
630
- if (seg.length === 5 && seg[1] === "repos" && seg[3] === "commits" && method === "GET") {
631
- try {
632
- const d = await repoCommitDiff(seg[2], seg[4]);
633
- return json(res, d, d.error ? (/^no such commit/.test(d.error) ? 404 : 400) : 200);
634
- }
635
- catch (err) {
636
- const [status, body] = repoError(err);
637
- return json(res, body, status);
638
- }
639
- }
640
- // POST /api/repos/<slug>/update — fetch, fast-forward when clean
641
- if (seg.length === 4 && seg[1] === "repos" && seg[3] === "update" && method === "POST") {
642
- try {
643
- const result = await updateRepo(seg[2]);
644
- return json(res, result, result.ok ? 200 : result.repo || result.off ? 409 : 404);
645
- }
646
- catch (err) {
647
- return json(res, { error: err.message }, 400);
648
- }
649
- }
650
- // DELETE /api/repos/<slug>[?force=1] — remove the clone; 409 while it holds unpushed or uncommitted work
651
- if (seg.length === 3 && seg[1] === "repos" && method === "DELETE") {
652
- try {
653
- const force = ["1", "true"].includes(url.searchParams.get("force") ?? "");
654
- const result = await removeRepo(seg[2], force);
655
- return json(res, result, result.ok ? 200 : result.conflict || result.off ? 409 : 404);
656
- }
657
- catch (err) {
658
- return json(res, { error: err.message }, 400);
659
- }
660
- }
661
- // GET /api/search/agents — active agents and channels of every reachable workspace (the ⌘K palette)
662
- if (seg.length === 3 && seg[1] === "search" && seg[2] === "agents" && method === "GET") {
663
- return json(res, await searchAgents());
664
- }
665
- // GET /api/workspaces — every known workspace incl. this one, with state + counts (admin screen)
666
- if (seg.length === 2 && seg[1] === "workspaces" && method === "GET") {
667
- return json(res, await listWorkspacesDetailed());
668
- }
669
- // POST /api/workspaces/start {root} — launch `kraftwerk ui` for a known
670
- // workspace as a detached process (the switcher's Start button).
671
- if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "start" && method === "POST") {
672
- try {
673
- const { root } = JSON.parse(await readBody(req));
674
- if (!root)
675
- return json(res, { error: "root required" }, 400);
676
- const result = await startWorkspace(root);
677
- return json(res, result, result.ok ? 200 : 409);
678
- }
679
- catch (err) {
680
- return json(res, { error: err.message }, 400);
681
- }
682
- }
683
- // POST /api/workspaces/stop {root | url} — SIGTERM a running workspace's server
684
- if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "stop" && method === "POST") {
685
- try {
686
- const target = JSON.parse(await readBody(req));
687
- if (!target.root && !target.url)
688
- return json(res, { error: "root or url required" }, 400);
689
- const result = await stopWorkspace(target);
690
- return json(res, result, result.ok ? 200 : 409);
691
- }
692
- catch (err) {
693
- return json(res, { error: err.message }, 400);
694
- }
695
- }
696
- // POST /api/workspaces/forget {root} — drop a workspace from the registry
697
- if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "forget" && method === "POST") {
698
- try {
699
- const { root } = JSON.parse(await readBody(req));
700
- if (!root)
701
- return json(res, { error: "root required" }, 400);
702
- return json(res, { ok: await forgetWorkspace(root) });
703
- }
704
- catch (err) {
705
- return json(res, { error: err.message }, 400);
706
- }
707
- }
708
- // GET /api/settings — kraftwerk.yml (parsed + resolved paths) for the settings page
709
- if (seg.length === 2 && seg[1] === "settings" && method === "GET") {
710
- return json(res, await getSettings());
711
- }
712
- // PUT /api/settings — write the UI-editable subset (name, icon, switcher, git, repos) back
713
- if (seg.length === 2 && seg[1] === "settings" && method === "PUT") {
714
- try {
715
- const input = JSON.parse(await readBody(req));
716
- return json(res, await saveSettings(input));
717
- }
718
- catch (err) {
719
- return json(res, { error: err.message }, 400);
720
- }
721
- }
722
- // GET /api/update-check — ask the npm registry for the latest published version
723
- if (seg.length === 2 && seg[1] === "update-check" && method === "GET") {
724
- const name = await getPkgName();
725
- const current = await getDiskVersion();
726
- try {
727
- const r = await fetch(`https://registry.npmjs.org/${name}/latest`, {
728
- signal: AbortSignal.timeout(5_000),
729
- });
730
- if (!r.ok)
731
- throw new Error(String(r.status));
732
- const latest = (await r.json()).version ?? "";
733
- return json(res, { name, current, latest });
734
- }
735
- catch {
736
- return json(res, { name, current, latest: "", error: "npm registry unreachable" }, 502);
737
- }
738
- }
739
- // GET /api/update — state and log of the self-update, plus whether one can run here.
740
- // POST /api/update {version?} — run `npm i -g <name>@<version>`; 409 when refused or already running.
741
- if (seg.length === 2 && seg[1] === "update") {
742
- const name = await getPkgName();
743
- if (method === "GET") {
744
- const can = canSelfUpdate(name);
745
- return json(res, { ...updateStatus(), name, available: can.ok, reason: can.ok ? undefined : can.reason, restartable: supervised() });
746
- }
747
- if (method === "POST") {
748
- let version = "latest";
749
- try {
750
- const body = JSON.parse((await readBody(req)) || "{}");
751
- if (typeof body.version === "string" && body.version)
752
- version = body.version;
753
- }
754
- catch {
755
- return json(res, { error: "invalid body" }, 400);
756
- }
757
- try {
758
- return json(res, { ...startUpdate(name, version), name }, 202);
759
- }
760
- catch (err) {
761
- return json(res, { error: err.message }, 409);
762
- }
763
- }
764
- }
765
- // POST /api/restart — exit with the respawn code; the `kraftwerk ui` supervisor relaunches
766
- if (seg.length === 2 && seg[1] === "restart" && method === "POST") {
767
- if (!supervised()) {
768
- return json(res, { error: "not supervised — restart `kraftwerk ui` manually" }, 400);
769
- }
770
- json(res, { ok: true });
771
- setTimeout(() => {
772
- disposeAllBackends();
773
- disposeAllDevs();
774
- process.exit(RESTART_EXIT_CODE);
775
- }, 150);
776
- return;
777
- }
778
- // GET /api/runs
779
- if (seg.length === 2 && seg[1] === "runs" && method === "GET") {
780
- return json(res, { outputDir: getOutputDir(), runs: await listRuns() });
781
- }
782
- // GET /api/runs/:id
783
- if (seg.length === 3 && seg[1] === "runs" && method === "GET") {
784
- try {
785
- const run = await getRun(seg[2]);
786
- return run ? json(res, run) : json(res, { error: "not found" }, 404);
787
- }
788
- catch {
789
- return json(res, { error: "invalid run id" }, 400);
790
- }
791
- }
792
- // GET /api/runs/:id/file?name=...&raw=1
793
- if (seg.length === 4 && seg[1] === "runs" && seg[3] === "file" && method === "GET") {
794
- const name = url.searchParams.get("name") ?? "";
795
- const raw = url.searchParams.get("raw") === "1";
796
- let file;
797
- try {
798
- file = await readRunFile(seg[2], name);
799
- }
800
- catch {
801
- return json(res, { error: "invalid request" }, 400);
802
- }
803
- if (!file)
804
- return json(res, { error: "not found" }, 404);
805
- const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
806
- const buf = await fs.readFile(file.absPath);
807
- if (raw) {
808
- // Agent-written files must not run as the inspector's origin: the
809
- // sandbox keeps scripts working (interactive reports) but makes the
810
- // origin opaque, so no fetch() into /api and no cookies. PDFs are
811
- // exempt — Chrome's viewer refuses sandboxed documents, and PDF
812
- // scripting has no DOM or origin access.
813
- res.writeHead(200, {
814
- "content-type": MIME[ext] ?? "text/plain; charset=utf-8",
815
- "cache-control": "no-store",
816
- "x-content-type-options": "nosniff",
817
- ...(ext === ".pdf" ? {} : { "content-security-policy": RUN_FILE_CSP }),
818
- });
819
- return void res.end(buf);
820
- }
821
- let text = buf.toString("utf8");
822
- let truncated = false;
823
- if (text.length > MAX_TEXT) {
824
- text = text.slice(-MAX_TEXT);
825
- truncated = true;
826
- }
827
- return json(res, { name, size: file.size, truncated, content: text });
828
- }
829
- // POST /api/runs/:id/decision {decision, note?} — a person answers the
830
- // step that wrote decision-request.json; the answer is written once.
831
- if (seg.length === 4 && seg[1] === "runs" && seg[3] === "decision" && method === "POST") {
832
- let runDir;
833
- let body;
834
- try {
835
- runDir = safeRunDir(seg[2]);
836
- body = JSON.parse((await readBody(req)) || "{}");
837
- }
838
- catch {
839
- return json(res, { error: "invalid request" }, 400);
840
- }
841
- const r = await decide(runDir, body ?? {});
842
- return r.ok ? json(res, r.answer) : json(res, { error: r.error }, r.status);
843
- }
844
- // POST /api/runs/:id/stop
845
- if (seg.length === 4 && seg[1] === "runs" && seg[3] === "stop" && method === "POST") {
846
- return stopRun(seg[2])
847
- ? json(res, { stopped: true })
848
- : json(res, { error: "nothing to stop: no launcher of this inspector and no sandbox container for this run" }, 404);
849
- }
850
- // DELETE /api/runs/:id — refused while the run is live (stop it first)
851
- if (seg.length === 3 && seg[1] === "runs" && method === "DELETE") {
852
- let outcome;
853
- try {
854
- outcome = await deleteRun(seg[2]);
855
- }
856
- catch {
857
- return json(res, { error: "invalid run id" }, 400);
858
- }
859
- if (outcome === "missing")
860
- return json(res, { error: "not found" }, 404);
861
- if (outcome === "running")
862
- return json(res, { error: "run is still running — stop it first" }, 409);
863
- return json(res, { deleted: true });
864
- }
865
- // GET /api/workflows
866
- if (seg.length === 2 && seg[1] === "workflows" && method === "GET") {
867
- return json(res, await listWorkflows());
868
- }
869
- // GET /api/workflows/:slug
870
- if (seg.length === 3 && seg[1] === "workflows" && method === "GET") {
871
- const wf = await getWorkflow(decodeURIComponent(seg[2]));
872
- return wf ? json(res, wf) : json(res, { error: "not found" }, 404);
873
- }
874
- // DELETE /api/workflows/:slug — move it to the trash
875
- if (seg.length === 3 && seg[1] === "workflows" && method === "DELETE") {
876
- const outcome = await deleteWorkflow(decodeURIComponent(seg[2]));
877
- if (outcome === "missing")
878
- return json(res, { error: "not found" }, 404);
879
- if (outcome === "running")
880
- return json(res, { error: "a run of this workflow is still running — stop it first" }, 409);
881
- return json(res, { ok: true });
882
- }
883
- // GET/POST /api/workflows/:slug/run
884
- if (seg.length === 4 && seg[1] === "workflows" && seg[3] === "run") {
885
- if (method === "GET")
886
- return json(res, dockerStatus());
887
- if (method === "POST") {
888
- const wf = await getWorkflow(decodeURIComponent(seg[2]));
889
- if (!wf || wf.error || !wf.name)
890
- return json(res, { error: "workflow not found or broken" }, 404);
891
- let body;
892
- try {
893
- body = JSON.parse(await readBody(req));
894
- }
895
- catch {
896
- return json(res, { error: "invalid JSON body" }, 400);
897
- }
898
- const request = (body.request ?? "").trim();
899
- if (!request && wf.usesRequest)
900
- return json(res, { error: "this workflow needs a request" }, 400);
901
- try {
902
- const { runId } = triggerRun({
903
- workflowName: wf.name,
904
- request,
905
- sandbox: body.sandbox ?? true,
906
- ssh: !!body.ssh,
907
- });
908
- return json(res, { runId });
909
- }
910
- catch (err) {
911
- return json(res, { error: err.message }, 503);
912
- }
913
- }
914
- }
915
- // GET/POST /api/knowledge — bundle index / create a bundle
916
- if (seg.length === 2 && seg[1] === "knowledge") {
917
- if (method === "GET")
918
- return json(res, await knowledgeIndex());
919
- if (method === "POST") {
920
- let body;
921
- try {
922
- body = JSON.parse(await readBody(req));
923
- }
924
- catch {
925
- return json(res, { error: "invalid JSON body" }, 400);
926
- }
927
- try {
928
- return json(res, await createBundle(String(body.name ?? "").trim()));
929
- }
930
- catch (err) {
931
- return json(res, { error: err.message }, 400);
932
- }
933
- }
934
- }
935
- // GET /api/knowledge/:bundle — concepts + log
936
- if (seg.length === 3 && seg[1] === "knowledge" && method === "GET") {
937
- try {
938
- const detail = await bundleDetail(decodeURIComponent(seg[2]));
939
- return detail ? json(res, detail) : json(res, { error: "not found" }, 404);
940
- }
941
- catch {
942
- return json(res, { error: "invalid bundle name" }, 400);
943
- }
944
- }
945
- // DELETE /api/knowledge/:bundle — move the bundle to the trash
946
- if (seg.length === 3 && seg[1] === "knowledge" && method === "DELETE") {
947
- try {
948
- await deleteBundle(decodeURIComponent(seg[2]));
949
- return json(res, { ok: true });
950
- }
951
- catch (err) {
952
- return json(res, { error: err.message }, 400);
953
- }
954
- }
955
- // GET /api/trash — what was deleted, newest first; DELETE /api/trash — empty it for good.
956
- // POST /api/trash/restore {id} — put one back; POST /api/trash/purge {id} — delete one for good.
957
- if (seg.length >= 2 && seg[1] === "trash") {
958
- try {
959
- if (seg.length === 2 && method === "GET")
960
- return json(res, { root: tildify(await trashRoot()), entries: await listTrash() });
961
- if (seg.length === 2 && method === "DELETE")
962
- return json(res, { ok: true, purged: await emptyTrash() });
963
- if (seg.length === 3 && method === "POST" && (seg[2] === "restore" || seg[2] === "purge")) {
964
- const { id } = JSON.parse(await readBody(req));
965
- if (!id)
966
- return json(res, { error: "id is required" }, 400);
967
- return json(res, seg[2] === "restore" ? await restoreFromTrash(id) : await purgeFromTrash(id));
968
- }
969
- }
970
- catch (err) {
971
- return json(res, { error: err.message }, 400);
972
- }
973
- return json(res, { error: "not found" }, 404);
974
- }
975
- // GET/POST /api/knowledge/:bundle/concept?id=... — read / write one concept
976
- if (seg.length === 4 && seg[1] === "knowledge" && seg[3] === "concept") {
977
- const bundle = decodeURIComponent(seg[2]);
978
- if (method === "GET") {
979
- const id = url.searchParams.get("id") ?? "";
980
- try {
981
- const concept = await conceptDetail(bundle, id);
982
- return concept ? json(res, concept) : json(res, { error: "not found" }, 404);
983
- }
984
- catch (err) {
985
- return json(res, { error: err.message }, 400);
986
- }
987
- }
988
- if (method === "POST") {
989
- let body;
990
- try {
991
- body = JSON.parse(await readBody(req));
992
- }
993
- catch {
994
- return json(res, { error: "invalid JSON body" }, 400);
995
- }
996
- if (!body.id || !body.content?.trim()) {
997
- return json(res, { error: "id and content are required" }, 400);
998
- }
999
- try {
1000
- return json(res, await putConcept(bundle, body.id, body.content));
1001
- }
1002
- catch (err) {
1003
- return json(res, { error: err.message }, 400);
1004
- }
1005
- }
1006
- }
1007
- // POST /api/knowledge/:bundle/verify — human verification from the UI
1008
- if (seg.length === 4 && seg[1] === "knowledge" && seg[3] === "verify" && method === "POST") {
1009
- let body;
1010
- try {
1011
- body = JSON.parse(await readBody(req));
1012
- }
1013
- catch {
1014
- return json(res, { error: "invalid JSON body" }, 400);
1015
- }
1016
- if (!body.id)
1017
- return json(res, { error: "id is required" }, 400);
1018
- try {
1019
- return json(res, await verifyFromUi(decodeURIComponent(seg[2]), body.id));
1020
- }
1021
- catch (err) {
1022
- return json(res, { error: err.message }, 400);
1023
- }
1024
- }
1025
- // GET /api/skills — discovered skills (workspace root + .claude/skills + ~/.claude/skills)
1026
- if (seg.length === 2 && seg[1] === "skills" && method === "GET") {
1027
- const [root, skills] = await Promise.all([skillsRoot(), listSkills()]);
1028
- return json(res, { root, skills });
1029
- }
1030
- // GET /api/skills/:name — one skill with SKILL.md content + bundled files
1031
- if (seg.length === 3 && seg[1] === "skills" && method === "GET") {
1032
- const skill = await getSkill(decodeURIComponent(seg[2]));
1033
- return skill ? json(res, skill) : json(res, { error: "not found" }, 404);
1034
- }
1035
- // GET/POST /api/agents — member list / create a member
1036
- if (seg.length === 2 && seg[1] === "agents") {
1037
- if (method === "GET") {
1038
- return json(res, { root: await agentsRoot(), agents: await listAgents() });
1039
- }
1040
- if (method === "POST") {
1041
- try {
1042
- const body = JSON.parse(await readBody(req));
1043
- return json(res, await saveAgent({ ...body, slug: undefined }));
1044
- }
1045
- catch (err) {
1046
- return json(res, { error: err.message }, 400);
1047
- }
1048
- }
1049
- }
1050
- // POST /api/agents/:slug/archive — archive/unarchive a member ({ archived: boolean })
1051
- if (seg.length === 4 && seg[1] === "agents" && seg[3] === "archive" && method === "POST") {
1052
- try {
1053
- const body = JSON.parse(await readBody(req));
1054
- return json(res, await setAgentArchived(decodeURIComponent(seg[2]), body.archived === true));
1055
- }
1056
- catch (err) {
1057
- return json(res, { error: err.message }, 400);
1058
- }
1059
- }
1060
- // GET/POST /api/agents/:slug/skills — the agent's own skills / create or update one
1061
- if (seg.length === 4 && seg[1] === "agents" && seg[3] === "skills") {
1062
- const slug = decodeURIComponent(seg[2]);
1063
- try {
1064
- if (method === "GET")
1065
- return json(res, { skills: await listAgentSkills(slug) });
1066
- if (method === "POST") {
1067
- const body = JSON.parse(await readBody(req));
1068
- return json(res, await saveAgentSkill(slug, String(body.name ?? ""), String(body.content ?? "")));
1069
- }
1070
- }
1071
- catch (err) {
1072
- return json(res, { error: err.message }, 400);
1073
- }
1074
- }
1075
- // GET/DELETE /api/agents/:slug/skills/:name — one agent skill with content / delete
1076
- if (seg.length === 5 && seg[1] === "agents" && seg[3] === "skills") {
1077
- const slug = decodeURIComponent(seg[2]);
1078
- const name = decodeURIComponent(seg[4]);
1079
- try {
1080
- if (method === "GET") {
1081
- const skill = (await listAgentSkills(slug)).find((s) => s.name.toLowerCase() === name.toLowerCase());
1082
- if (!skill)
1083
- return json(res, { error: "not found" }, 404);
1084
- return json(res, { ...skill, content: await readSkill(skill) });
1085
- }
1086
- if (method === "DELETE") {
1087
- await deleteAgentSkill(slug, name);
1088
- return json(res, { ok: true });
1089
- }
1090
- }
1091
- catch (err) {
1092
- return json(res, { error: err.message }, 400);
1093
- }
1094
- }
1095
- // GET /api/routines — every agent's routines with run state and the workflows each one names
1096
- if (seg.length === 2 && seg[1] === "routines" && method === "GET") {
1097
- return json(res, { routines: await allRoutines((await listWorkflows()).workflows) });
1098
- }
1099
- // GET/POST /api/agents/:slug/routines — list (with run state) / upsert
1100
- if (seg.length === 4 && seg[1] === "agents" && seg[3] === "routines") {
1101
- const slug = decodeURIComponent(seg[2]);
1102
- try {
1103
- if (method === "GET")
1104
- return json(res, { routines: await routineStatuses(slug) });
1105
- if (method === "POST") {
1106
- const body = JSON.parse(await readBody(req));
1107
- return json(res, await saveRoutine(slug, body));
1108
- }
1109
- }
1110
- catch (err) {
1111
- return json(res, { error: err.message }, 400);
1112
- }
1113
- }
1114
- // DELETE /api/agents/:slug/routines/:id | POST /api/agents/:slug/routines/:id/run
1115
- if (seg.length >= 5 && seg[1] === "agents" && seg[3] === "routines") {
1116
- const slug = decodeURIComponent(seg[2]);
1117
- const id = decodeURIComponent(seg[4]);
1118
- try {
1119
- if (seg.length === 5 && method === "DELETE") {
1120
- await deleteRoutine(slug, id);
1121
- return json(res, { ok: true });
1122
- }
1123
- if (seg.length === 6 && seg[5] === "run" && method === "POST") {
1124
- return json(res, await runRoutineNow(slug, id));
1125
- }
1126
- }
1127
- catch (err) {
1128
- return json(res, { error: err.message }, 400);
1129
- }
1130
- }
1131
- // Files: GET /api/files (the roots: workspace + one per project), GET /api/files/list?scope&path,
1132
- // GET /api/files/raw?scope&path[&download=1], POST /api/files/upload?scope&path (raw body, x-file-name),
1133
- // POST /api/files/folder {scope, path}, POST /api/files/move {scope, from, to}, DELETE /api/files?scope&path (to the trash).
1134
- if (seg[1] === "files" && seg.length <= 3) {
1135
- const scope = url.searchParams.get("scope") ?? "workspace";
1136
- const rel = url.searchParams.get("path") ?? "";
1137
- try {
1138
- if (seg.length === 2 && method === "GET")
1139
- return json(res, { scopes: await listScopes() });
1140
- if (seg.length === 2 && method === "DELETE") {
1141
- await deleteFile(scope, rel);
1142
- return json(res, { ok: true });
1143
- }
1144
- if (seg[2] === "list" && method === "GET")
1145
- return json(res, await listFiles(scope, rel));
1146
- if (seg[2] === "recent" && method === "GET")
1147
- return json(res, (await filesSummary(scope, 5)) ?? { root: "", count: 0, recent: [] });
1148
- if (seg[2] === "raw" && method === "GET") {
1149
- const f = await openFile(scope, rel);
1150
- res.writeHead(200, {
1151
- "content-type": f.mime,
1152
- "content-length": f.size,
1153
- "cache-control": "no-store",
1154
- "x-content-type-options": "nosniff",
1155
- // Anything in here may come from outside: a page, an SVG or XML renders without scripts, in an origin of its own.
1156
- // (Not on PDFs and media: a sandboxed document cannot show the browser's PDF viewer.)
1157
- ...(/^(text\/html|image\/svg|text\/xml|application\/xml)/.test(f.mime) ? { "content-security-policy": "sandbox" } : {}),
1158
- "content-disposition": `${url.searchParams.get("download") ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(f.name)}`,
1159
- });
1160
- f.stream.pipe(res);
1161
- return;
1162
- }
1163
- if (seg[2] === "upload" && method === "POST") {
1164
- const name = decodeURIComponent(String(req.headers["x-file-name"] ?? "file"));
1165
- return json(res, await saveUpload(scope, rel, name, req), 201);
1166
- }
1167
- if (seg[2] === "folder" && method === "POST") {
1168
- const body = JSON.parse(await readBody(req));
1169
- return json(res, await makeFolder(body.scope ?? scope, String(body.path ?? "")), 201);
1170
- }
1171
- if (seg[2] === "move" && method === "POST") {
1172
- const body = JSON.parse(await readBody(req));
1173
- await moveFile(body.scope ?? scope, String(body.from ?? ""), String(body.to ?? ""));
1174
- return json(res, { ok: true });
1175
- }
1176
- }
1177
- catch (err) {
1178
- const msg = err.message;
1179
- return json(res, { error: msg }, /^no (file|folder|project)/.test(msg) ? 404 : 400);
1180
- }
1181
- return json(res, { error: "not found" }, 404);
1182
- }
1183
- // GET /api/attention — what needs you: waiting approvals and questions, unread failures, with their owner
1184
- if (seg.length === 2 && seg[1] === "attention" && method === "GET") {
1185
- return json(res, await listAttention());
1186
- }
1187
- // GET /api/agent-status — per agent: waiting, working, next routine, last active (the rail's status line)
1188
- if (seg.length === 2 && seg[1] === "agent-status" && method === "GET") {
1189
- return json(res, await agentStatuses());
1190
- }
1191
- // GET /api/agents/:slug/journal — the journal; POST {entry, kind?} — a human adds a line
1192
- if (seg.length === 4 && seg[1] === "agents" && seg[3] === "journal") {
1193
- const slug = decodeURIComponent(seg[2]);
1194
- try {
1195
- if (method === "GET")
1196
- return json(res, { journal: await readJournal(slug) });
1197
- if (method === "POST") {
1198
- const body = JSON.parse(await readBody(req));
1199
- return json(res, { journal: await appendJournal(slug, String(body.entry ?? ""), { kind: body.kind, actor: UI_ACTOR }) });
1200
- }
1201
- }
1202
- catch (err) {
1203
- return json(res, { error: err.message }, 400);
1204
- }
1205
- }
1206
- // GET/PUT/DELETE /api/agents/:slug
1207
- if (seg.length === 3 && seg[1] === "agents") {
1208
- const slug = decodeURIComponent(seg[2]);
1209
- try {
1210
- if (method === "GET") {
1211
- const agent = await getAgent(slug);
1212
- return agent ? json(res, agent) : json(res, { error: "not found" }, 404);
1213
- }
1214
- if (method === "PUT") {
1215
- const body = JSON.parse(await readBody(req));
1216
- return json(res, await saveAgent({ ...body, slug }));
1217
- }
1218
- if (method === "DELETE") {
1219
- await deleteAgent(slug);
1220
- return json(res, { ok: true });
1221
- }
1222
- }
1223
- catch (err) {
1224
- return json(res, { error: err.message }, 400);
1225
- }
1226
- }
1227
- // GET/POST /api/chats
1228
- if (seg.length === 2 && seg[1] === "chats") {
1229
- if (method === "GET")
1230
- return json(res, { chats: await listChats() });
1231
- if (method === "POST") {
1232
- let body;
1233
- try {
1234
- body = JSON.parse(await readBody(req));
1235
- }
1236
- catch {
1237
- return json(res, { error: "invalid JSON body" }, 400);
1238
- }
1239
- let agent = body.agent;
1240
- let scope;
1241
- if (body.scope?.kind === "agent" && body.scope.slug) {
1242
- // Agent sessions run on the agent's configured harness.
1243
- const def = await getAgent(body.scope.slug).catch(() => null);
1244
- if (!def)
1245
- return json(res, { error: "agent not found" }, 404);
1246
- scope = { kind: "agent", slug: def.slug };
1247
- agent = def.harness;
1248
- }
1249
- else if (body.scope?.kind === "run" && body.scope.runId) {
1250
- scope = { kind: "run", runId: body.scope.runId };
1251
- }
1252
- else if (body.scope?.kind === "kraftwerk") {
1253
- scope = { kind: "kraftwerk" };
1254
- }
1255
- else if (body.scope?.kind === "knowledge") {
1256
- scope = { kind: "knowledge", ...(body.scope.bundle ? { bundle: body.scope.bundle } : {}) };
1257
- }
1258
- else if (body.scope?.kind === "project" && body.scope.slug) {
1259
- // A project chat needs its folder; a missing one is a 404 here, not a chat that apologizes.
1260
- let found;
1261
- try {
1262
- found = await getProject(body.scope.slug);
1263
- }
1264
- catch (err) {
1265
- const [status, e] = projectError(err);
1266
- return json(res, e, status);
1267
- }
1268
- if (!found)
1269
- return json(res, { error: "project not found" }, 404);
1270
- scope = { kind: "project", slug: found.slug };
1271
- // Like agent sessions: the project decides the harness, not the caller.
1272
- agent = found.harness;
1273
- }
1274
- else {
1275
- scope = { kind: "general" };
1276
- }
1277
- if (!["claude", "codex", "pi"].includes(agent)) {
1278
- return json(res, { error: "agent must be claude, codex, or pi" }, 400);
1279
- }
1280
- try {
1281
- return json(res, await createChat({ agent, scope, ...(typeof body.resume === "string" && body.resume ? { resume: body.resume } : {}) }));
1282
- }
1283
- catch (err) {
1284
- return json(res, { error: err.message }, 400);
1285
- }
1286
- }
1287
- }
1288
- // GET /api/agent-sessions?agent=claude|codex — the agent's own sessions in the project root
1289
- if (seg.length === 2 && seg[1] === "agent-sessions" && method === "GET") {
1290
- const agent = url.searchParams.get("agent") ?? "claude";
1291
- if (!["claude", "codex", "pi"].includes(agent))
1292
- return json(res, { error: "agent must be claude, codex, or pi" }, 400);
1293
- try {
1294
- return json(res, { sessions: await listAgentSessionsFor(agent) });
1295
- }
1296
- catch (err) {
1297
- return json(res, { error: err.message }, 502);
1298
- }
1299
- }
1300
- // DELETE /api/chats/:id
1301
- if (seg.length === 3 && seg[1] === "chats" && method === "DELETE") {
1302
- try {
1303
- const result = await deleteChat(seg[2]);
1304
- return json(res, result, result.error ? 404 : 200);
1305
- }
1306
- catch {
1307
- return json(res, { error: "invalid chat id" }, 400);
1308
- }
1309
- }
1310
- // PATCH /api/chats/:id {title} — rename the chat
1311
- if (seg.length === 3 && seg[1] === "chats" && method === "PATCH") {
1312
- try {
1313
- const body = JSON.parse(await readBody(req));
1314
- const result = await renameChat(seg[2], body.title);
1315
- return json(res, result, result.error ? (result.error === "not found" ? 404 : 400) : 200);
1316
- }
1317
- catch {
1318
- return json(res, { error: "invalid chat id or body" }, 400);
1319
- }
1320
- }
1321
- // GET /api/chats/:id
1322
- if (seg.length === 3 && seg[1] === "chats" && method === "GET") {
1323
- try {
1324
- const chat = await getChat(seg[2]);
1325
- return chat ? json(res, chat) : json(res, { error: "not found" }, 404);
1326
- }
1327
- catch {
1328
- return json(res, { error: "invalid chat id" }, 400);
1329
- }
1330
- }
1331
- // GET /api/chats/:id/events?after=N — SSE stream of thread events.
1332
- if (seg.length === 4 && seg[1] === "chats" && seg[3] === "events" && method === "GET") {
1333
- const afterSeq = Number(url.searchParams.get("after") ?? "0") || 0;
1334
- const send = (ev) => {
1335
- res.write(`id: ${ev.seq}\ndata: ${JSON.stringify(ev)}\n\n`);
1336
- };
1337
- let unsubscribe = null;
1338
- try {
1339
- res.writeHead(200, {
1340
- "content-type": "text/event-stream",
1341
- "cache-control": "no-store",
1342
- connection: "keep-alive",
1343
- });
1344
- unsubscribe = await subscribeChat(seg[2], afterSeq, send);
1345
- }
1346
- catch {
1347
- return void res.end();
1348
- }
1349
- if (!unsubscribe)
1350
- return void res.end();
1351
- const heartbeat = setInterval(() => res.write(":hb\n\n"), 25_000);
1352
- req.on("close", () => {
1353
- clearInterval(heartbeat);
1354
- unsubscribe?.();
1355
- });
1356
- return;
1357
- }
1358
- // POST /api/chats/:id/vibeable {slug | null} — open an app in the chat's preview pane (cwd follows), or close it
1359
- if (seg.length === 4 && seg[1] === "chats" && seg[3] === "vibeable" && method === "POST") {
1360
- try {
1361
- const body = JSON.parse((await readBody(req)) || "{}");
1362
- const slug = body.slug == null || body.slug === "" ? null : String(body.slug);
1363
- const result = await setChatVibeable(seg[2], slug);
1364
- return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
1365
- }
1366
- catch (err) {
1367
- return json(res, { error: err.message }, 400);
1368
- }
1369
- }
1370
- // POST /api/chats/:id/fork — a new chat continuing a copy of this one's session
1371
- if (seg.length === 4 && seg[1] === "chats" && seg[3] === "fork" && method === "POST") {
1372
- try {
1373
- const result = await forkChat(seg[2]);
1374
- return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
1375
- }
1376
- catch (err) {
1377
- return json(res, { error: err.message }, 400);
1378
- }
1379
- }
1380
- // POST /api/chats/:id/attachments — raw file body; x-file-name names it. Returns the stored attachment.
1381
- if (seg.length === 4 && seg[1] === "chats" && seg[3] === "attachments" && method === "POST") {
1382
- try {
1383
- if (!(await getChat(seg[2])))
1384
- return json(res, { error: "not found" }, 404);
1385
- const data = await readRawBody(req, 25_000_000);
1386
- if (data.length === 0)
1387
- return json(res, { error: "empty file" }, 400);
1388
- const mimeType = (req.headers["content-type"] ?? "application/octet-stream").split(";")[0].trim();
1389
- const name = await saveAttachment(seg[2], decodeURIComponent(String(req.headers["x-file-name"] ?? "file")), data);
1390
- return json(res, { name, mimeType, size: data.length });
1391
- }
1392
- catch (err) {
1393
- return json(res, { error: err.message }, 400);
1394
- }
1395
- }
1396
- // GET /api/chats/:id/attachments/:name — the stored file
1397
- if (seg.length === 5 && seg[1] === "chats" && seg[3] === "attachments" && method === "GET") {
1398
- try {
1399
- const file = attachmentPath(seg[2], seg[4]);
1400
- const ext = path.extname(file).toLowerCase();
1401
- const data = await fs.readFile(file);
1402
- res.writeHead(200, {
1403
- "content-type": MIME[ext] ?? "application/octet-stream",
1404
- "content-length": data.length,
1405
- "cache-control": "private, max-age=86400",
1406
- "x-content-type-options": "nosniff",
1407
- });
1408
- return void res.end(data);
1409
- }
1410
- catch {
1411
- return json(res, { error: "not found" }, 404);
1412
- }
1413
- }
1414
- // POST /api/chats/:id/reset-session — forget the agent's session; the next message starts fresh
1415
- if (seg.length === 4 && seg[1] === "chats" && seg[3] === "reset-session" && method === "POST") {
1416
- try {
1417
- const result = await resetSession(seg[2]);
1418
- return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
1419
- }
1420
- catch (err) {
1421
- return json(res, { error: err.message }, 400);
1422
- }
1423
- }
1424
- // POST /api/chats/:id/{message,permission,cancel}
1425
- if (seg.length === 4 && seg[1] === "chats" && method === "POST") {
1426
- let body = {};
1427
- try {
1428
- const raw = await readBody(req);
1429
- if (raw)
1430
- body = JSON.parse(raw);
1431
- }
1432
- catch {
1433
- return json(res, { error: "invalid JSON body" }, 400);
1434
- }
1435
- try {
1436
- let result;
1437
- const attachments = Array.isArray(body.attachments)
1438
- ? body.attachments
1439
- .filter((a) => a && typeof a.name === "string" && typeof a.mimeType === "string")
1440
- .map((a) => ({ name: String(a.name), mimeType: String(a.mimeType), size: Number(a.size) || 0 }))
1441
- : [];
1442
- if (seg[3] === "message") {
1443
- const text = String(body.text ?? "").trim();
1444
- if (!text && attachments.length === 0)
1445
- return json(res, { error: "text is required" }, 400);
1446
- // Channels: the poster's display name signs the message.
1447
- result = await postMessage(seg[2], text || "(see attached)", { from: typeof body.from === "string" ? body.from : undefined, attachments });
1448
- }
1449
- else if (seg[3] === "permission") {
1450
- result = await resolvePermission(seg[2], String(body.requestId ?? ""), body.optionId == null ? null : String(body.optionId));
1451
- }
1452
- else if (seg[3] === "cancel") {
1453
- // Channels: `agent` stops one member; without it every agent in the chat.
1454
- result = await cancelChat(seg[2], typeof body.agent === "string" && body.agent ? body.agent : undefined);
1455
- }
1456
- else if (seg[3] === "steer") {
1457
- const text = String(body.text ?? "").trim();
1458
- if (!text && attachments.length === 0)
1459
- return json(res, { error: "text is required" }, 400);
1460
- result = await steerChat(seg[2], text || "(see attached)", attachments);
1461
- }
1462
- else if (seg[3] === "elicitation") {
1463
- const action = body.action === "accept" || body.action === "decline" || body.action === "cancel" ? body.action : null;
1464
- if (!action)
1465
- return json(res, { error: "action must be accept, decline, or cancel" }, 400);
1466
- const content = body.content && typeof body.content === "object" ? body.content : {};
1467
- result = await answerElicitation(seg[2], String(body.requestId ?? ""), action === "accept" ? { action, content } : { action });
1468
- }
1469
- else if (seg[3] === "task-stop") {
1470
- result = await stopChatTask(seg[2], String(body.taskId ?? ""));
1471
- }
1472
- else if (seg[3] === "config") {
1473
- const value = typeof body.value === "boolean" ? body.value : String(body.value ?? "");
1474
- result = await setChatConfig(seg[2], String(body.configId ?? ""), value);
1475
- }
1476
- else {
1477
- return json(res, { error: "not found" }, 404);
1478
- }
1479
- return result.error ? json(res, result, 409) : json(res, { ok: true });
1480
- }
1481
- catch (err) {
1482
- return json(res, { error: err.message }, 400);
1483
- }
1484
- }
1485
- json(res, { error: "not found" }, 404);
1486
- }
1487
135
  async function serveStatic(res, staticDir, pathname) {
1488
136
  // SPA: unknown paths fall back to index.html (routing is client-side).
1489
137
  const rel = pathname === "/" ? "index.html" : pathname.slice(1);
@@ -1508,15 +156,16 @@ async function serveStatic(res, staticDir, pathname) {
1508
156
  }
1509
157
  /** Start the server; resolves once it listens. Runs until the process ends. */
1510
158
  export async function startInspector(opts) {
1511
- setOutputDir(opts.outputDir);
1512
- if (opts.projectRoot)
1513
- setProjectRoot(opts.projectRoot);
159
+ // Every request, timer and listener below runs in this workspace (see workspace.ts).
160
+ const ws = setDefaultWorkspace(new Workspace({ outputDir: opts.outputDir, root: opts.projectRoot }));
1514
161
  // Read once: like the port, the public hostname takes effect on restart.
1515
- const project = await resolveProject(getProjectRoot()).catch(() => null);
1516
- publicHost = (project && publicHostFor(project)) ?? "";
1517
- access = project ? tunnelFor(project)?.access : undefined;
1518
- startRoutineScheduler();
1519
- startGitSync();
162
+ const project = await resolveProject(ws.root).catch(() => null);
163
+ // This server's own: several servers in one process each guard their own workspace.
164
+ const gate = { publicHost: (project && publicHostFor(project)) ?? "", access: project ? tunnelFor(project)?.access : undefined };
165
+ ws.run(() => {
166
+ startRoutineScheduler();
167
+ startGitSync();
168
+ });
1520
169
  // Chat agent subprocesses must die with the server — signals bypass
1521
170
  // "exit" handlers, so hook the signals themselves.
1522
171
  for (const sig of ["SIGINT", "SIGTERM"]) {
@@ -1537,16 +186,22 @@ export async function startInspector(opts) {
1537
186
  if (code !== RESTART_EXIT_CODE)
1538
187
  markWorkspaceStopped();
1539
188
  });
1540
- const server = http.createServer(async (req, res) => {
189
+ const server = http.createServer((req, res) => ws.run(() => serve(req, res)));
190
+ const serve = async (req, res) => {
1541
191
  try {
1542
192
  const url = new URL(req.url ?? "/", "http://localhost");
1543
- if (!hostAllowed(req))
193
+ if (!hostAllowed(req, gate))
1544
194
  return json(res, { error: "unexpected Host header" }, 421);
1545
- const refusal = await accessRefusal(req);
195
+ const refusal = await accessRefusal(req, gate);
1546
196
  if (refusal)
1547
197
  return json(res, { error: refusal }, 401);
1548
- if (url.pathname.startsWith("/api/"))
1549
- await handleApi(req, res, url);
198
+ if (url.pathname.startsWith("/api/")) {
199
+ const method = req.method ?? "GET";
200
+ if (method !== "GET" && method !== "HEAD" && !sameOrigin(req))
201
+ json(res, { error: "cross-origin request refused" }, 403);
202
+ else
203
+ await apiRouter.handle(req, res, url);
204
+ }
1550
205
  // /vibeables/<slug>/… is an app's own files, served for the preview pane.
1551
206
  else if (url.pathname === "/vibeables" || url.pathname.startsWith("/vibeables/"))
1552
207
  await serveVibeable(req, res, url);
@@ -1556,23 +211,34 @@ export async function startInspector(opts) {
1556
211
  catch (err) {
1557
212
  json(res, { error: err.message }, 500);
1558
213
  }
1559
- });
1560
- // WebSocket upgrades exist for one reason: a vibeable's dev server (HMR).
214
+ };
215
+ // WebSocket upgrades: the control plane's socket (/api/ws), and a vibeable's dev server (HMR).
1561
216
  server.on("upgrade", (req, socket, head) => {
1562
- if (!hostAllowed(req))
217
+ if (!hostAllowed(req, gate))
1563
218
  return void socket.destroy();
1564
- accessRefusal(req).then((refusal) => (refusal ? socket.destroy() : proxyUpgrade(req, socket, head)), () => socket.destroy());
219
+ ws.run(() => accessRefusal(req, gate).then(async (refusal) => {
220
+ if (refusal)
221
+ return void socket.destroy();
222
+ if (new URL(req.url ?? "/", "http://localhost").pathname !== "/api/ws")
223
+ return proxyUpgrade(req, socket, head);
224
+ // A socket is not covered by CORS: a page from another site could open one, so the Origin must be ours.
225
+ if (!sameOrigin(req))
226
+ return void socket.end("HTTP/1.1 403 Forbidden\r\nconnection: close\r\n\r\n");
227
+ const conn = acceptWebSocket(req, socket, head);
228
+ if (conn)
229
+ serveSocket(conn, ws, await getPkgVersion());
230
+ }, () => socket.destroy()));
1565
231
  });
1566
232
  return new Promise((resolve, reject) => {
1567
233
  server.once("error", reject);
1568
234
  server.once("close", stopCloudSync);
1569
- server.listen(opts.port, INSPECTOR_HOST, () => {
235
+ server.listen(opts.port, INSPECTOR_HOST, () => ws.run(() => {
1570
236
  const addr = server.address();
1571
237
  const port = typeof addr === "object" && addr ? addr.port : opts.port;
1572
- void registerInstance(port, getProjectRoot());
1573
- void registerWorkspace(getProjectRoot());
238
+ void registerInstance(port, ws.root);
239
+ void registerWorkspace(ws.root);
1574
240
  void getPkgVersion().then((v) => startCloudSync(port, v));
1575
241
  resolve(server);
1576
- });
242
+ }));
1577
243
  });
1578
244
  }