@netnodeag/kraftwerk 0.48.0 → 0.50.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 (51) hide show
  1. package/README.md +103 -7
  2. package/dist/cli/doctor.js +7 -1
  3. package/dist/cli/init.js +1 -0
  4. package/dist/cli/kraftwerk.js +5 -1
  5. package/dist/cli/projects.d.ts +0 -14
  6. package/dist/cli/projects.js +181 -107
  7. package/dist/cli/repos.js +1 -1
  8. package/dist/cli/ui.js +1 -1
  9. package/dist/cli/vibeables.js +1 -1
  10. package/dist/cli/workspaces.d.ts +16 -0
  11. package/dist/cli/workspaces.js +149 -0
  12. package/dist/config.d.ts +22 -1
  13. package/dist/config.js +19 -3
  14. package/dist/inspector/agents.js +2 -2
  15. package/dist/inspector/channels.d.ts +10 -1
  16. package/dist/inspector/channels.js +11 -2
  17. package/dist/inspector/chat/sessions.d.ts +8 -3
  18. package/dist/inspector/chat/sessions.js +58 -19
  19. package/dist/inspector/chat/types.d.ts +11 -0
  20. package/dist/inspector/git.d.ts +51 -0
  21. package/dist/inspector/git.js +22 -12
  22. package/dist/inspector/instances.d.ts +14 -12
  23. package/dist/inspector/instances.js +71 -31
  24. package/dist/inspector/notifications.js +2 -0
  25. package/dist/inspector/projects.d.ts +167 -0
  26. package/dist/inspector/projects.js +520 -0
  27. package/dist/inspector/repos.d.ts +47 -0
  28. package/dist/inspector/repos.js +121 -2
  29. package/dist/inspector/search.d.ts +1 -1
  30. package/dist/inspector/search.js +3 -3
  31. package/dist/inspector/server.js +149 -21
  32. package/dist/inspector/settings.d.ts +3 -1
  33. package/dist/inspector/settings.js +14 -3
  34. package/inspector/dist/assets/dist-BB5opUKX.js +1 -0
  35. package/inspector/dist/assets/{dist-Dv40dCIn.js → dist-BV28M6sp.js} +1 -1
  36. package/inspector/dist/assets/{dist-BNGsfwfO.js → dist-Bi4ZTD6W.js} +1 -1
  37. package/inspector/dist/assets/{dist-n8GR1Slo.js → dist-BuqvesGh.js} +1 -1
  38. package/inspector/dist/assets/{dist-O97r5Ket.js → dist-CLSTyUdp.js} +1 -1
  39. package/inspector/dist/assets/{dist-D6fPO3Jz.js → dist-Ca8OxgWP.js} +1 -1
  40. package/inspector/dist/assets/{dist-DUq31xBw.js → dist-DGB-RjHI.js} +1 -1
  41. package/inspector/dist/assets/{dist-Bz3XVWXl.js → dist-DHS6DxTY.js} +1 -1
  42. package/inspector/dist/assets/{dist-Dk5ZcwMb.js → dist-DVWyuKcp.js} +1 -1
  43. package/inspector/dist/assets/{dist-BWkBM49t.js → dist-DkEzjj-j.js} +1 -1
  44. package/inspector/dist/assets/{dist-CmH9PvLb.js → dist-Dv003C77.js} +1 -1
  45. package/inspector/dist/assets/{editor-toVFopCO.js → editor-B5WUiag9.js} +3 -3
  46. package/inspector/dist/assets/{index-CC0tY40i.js → index-BfiWjmwA.js} +11 -11
  47. package/inspector/dist/assets/{index-DQrsXEWt.css → index-DCpGWY1g.css} +1 -1
  48. package/inspector/dist/index.html +2 -2
  49. package/package.json +1 -1
  50. package/schema/kraftwerk.schema.json +18 -0
  51. package/inspector/dist/assets/dist-JaBY_kZV.js +0 -1
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { gitignoreHas, ignoreEntryFor, reposRootFor, resolveProject } from "../config.js";
4
4
  import { getProjectRoot } from "./context.js";
5
5
  import { newestMtime } from "./mtime.js";
6
- import { git, gitNet, sshCommandFor } from "./git.js";
6
+ import { capDiff, DENY, diffAgainstHead, git, gitNet, label, literal, parseStatus, sshCommandFor } from "./git.js";
7
7
  /**
8
8
  * A clone is the one call here that can legitimately take long: a large
9
9
  * repository on a slow link is minutes, not seconds. Prompts are already
@@ -63,7 +63,8 @@ async function inspect(root, slug) {
63
63
  const dir = path.join(root, slug);
64
64
  const info = { slug, path: dir, dirty: 0 };
65
65
  const [status, log, origin, newest] = await Promise.all([
66
- git(["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], dir),
66
+ // Every untracked file counts, so the listing's "N changed" and the page's file list agree.
67
+ git(["status", "--porcelain=v2", "--branch", "--untracked-files=all"], dir),
67
68
  git(["log", "-1", "--format=%h%x00%s%x00%cI"], dir),
68
69
  git(["remote", "get-url", "origin"], dir),
69
70
  newestMtime(dir),
@@ -111,6 +112,124 @@ async function inspect(root, slug) {
111
112
  }
112
113
  return info;
113
114
  }
115
+ const COMMITS = 40;
116
+ async function cloneDir(slug) {
117
+ safeRepoSlug(slug);
118
+ const opened = await openRepos();
119
+ if (!opened.root)
120
+ throw new Error(opened.error ?? "repositories are off");
121
+ const dir = path.join(opened.root, slug);
122
+ if (!(await fs.stat(path.join(dir, ".git")).catch(() => null)))
123
+ throw new Error(`no repository "${slug}"`);
124
+ return { root: opened.root, dir };
125
+ }
126
+ /** What is happening in one clone; null when there is no such clone. */
127
+ export async function repoDetail(slug) {
128
+ let root;
129
+ let dir;
130
+ try {
131
+ ({ root, dir } = await cloneDir(slug));
132
+ }
133
+ catch (err) {
134
+ if (/^no repository/.test(err.message))
135
+ return null;
136
+ throw err;
137
+ }
138
+ const info = await inspect(root, slug);
139
+ const detail = { ...info, files: [], commits: [], insertions: 0, deletions: 0 };
140
+ if (info.error)
141
+ return detail;
142
+ const [status, log, local, stat, upstream] = await Promise.all([
143
+ git(["status", "--porcelain=v1", "-z", "--untracked-files=all"], dir),
144
+ git(["log", `-${COMMITS}`, "--format=%H%x00%h%x00%s%x00%an%x00%cI"], dir),
145
+ // Bounded like the log: the local commits among the newest N are a prefix of the first N non-remote ones.
146
+ git(["rev-list", `-n`, String(COMMITS), "HEAD", "--not", "--remotes"], dir),
147
+ git(["diff", "HEAD", "--shortstat"], dir),
148
+ git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], dir),
149
+ ]);
150
+ if (status.ok)
151
+ detail.files = parseStatus(status.stdout).map((f) => ({ path: f.path, code: f.code, status: label(f.code) }));
152
+ if (log.ok) {
153
+ const localSet = new Set(local.ok ? local.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []);
154
+ detail.commits = log.stdout
155
+ .split("\n")
156
+ .filter((l) => l.trim())
157
+ .map((l) => {
158
+ const [hash, short, subject, author, committedAt] = l.split("\0");
159
+ return { hash, short, subject, author, committedAt, local: localSet.has(hash) };
160
+ });
161
+ }
162
+ if (stat.ok) {
163
+ const ins = /(\d+) insertion/.exec(stat.stdout);
164
+ const del = /(\d+) deletion/.exec(stat.stdout);
165
+ detail.insertions = ins ? Number(ins[1]) : 0;
166
+ detail.deletions = del ? Number(del[1]) : 0;
167
+ }
168
+ if (upstream.ok && upstream.stdout.trim())
169
+ detail.upstream = upstream.stdout.trim();
170
+ return detail;
171
+ }
172
+ /**
173
+ * Unified diff of one changed file in a clone, against HEAD (the whole
174
+ * file for an untracked one). Same rule as the workspace diff: only a path
175
+ * the status lists is shown, and never a secret — a clone's untracked .env
176
+ * is the file itself.
177
+ */
178
+ export async function repoDiff(slug, file) {
179
+ if (!file || file.includes("\0") || path.isAbsolute(file) || file.split("/").includes(".."))
180
+ return { diff: "", error: "invalid path" };
181
+ const { dir } = await cloneDir(slug);
182
+ if (DENY.some((re) => re.test(file)))
183
+ return { diff: "", error: "not shown: secret or key" };
184
+ const status = await git(["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", literal(file)], dir);
185
+ const entry = status.ok ? parseStatus(status.stdout).find((f) => f.path === file) : undefined;
186
+ if (!entry)
187
+ return { diff: "", error: "not shown: not a changed file in this repository" };
188
+ return diffAgainstHead(dir, file, entry.code);
189
+ }
190
+ /**
191
+ * One commit of a clone as a patch: header, stat, then the diff. The
192
+ * commit's own path list decides what is shown: a denied path (a committed
193
+ * key, a renamed-in .env) is left out of both the stat and the patch by
194
+ * pathspec, so no header shape git emits can smuggle it through.
195
+ */
196
+ export async function repoCommitDiff(slug, hash) {
197
+ if (!/^[0-9a-f]{7,40}$/.test(hash))
198
+ return { diff: "", error: "invalid commit" };
199
+ const { dir } = await cloneDir(slug);
200
+ if (!(await git(["rev-parse", "--verify", "-q", `${hash}^{commit}`], dir)).ok)
201
+ return { diff: "", error: "no such commit" };
202
+ // Post-image paths of the commit: "M\0path\0", renames and copies as "R100\0old\0new\0".
203
+ const tree = await git(["diff-tree", "-r", "-M", "-z", "--root", "--name-status", hash], dir);
204
+ if (!tree.ok)
205
+ return { diff: "", error: tree.stderr || "git diff-tree failed" };
206
+ const parts = tree.stdout.split("\0");
207
+ const paths = [];
208
+ for (let i = 0; i < parts.length; i++) {
209
+ const status = parts[i];
210
+ if (!status || /^[0-9a-f]{40}$/.test(status))
211
+ continue; // the leading hash line of --root
212
+ const two = /^[RC]/.test(status);
213
+ const file = two ? parts[i + 2] : parts[i + 1];
214
+ if (file)
215
+ paths.push(file);
216
+ i += two ? 2 : 1;
217
+ }
218
+ const kept = paths.filter((f) => !DENY.some((re) => re.test(f)));
219
+ const hidden = paths.length - kept.length;
220
+ const format = "--format=commit %H%nAuthor: %an <%ae>%nDate: %cI%n%n %s%n";
221
+ if (kept.length === 0 && paths.length > 0) {
222
+ const head = await git(["show", "--no-patch", format, hash], dir);
223
+ return { diff: `${head.stdout}\n(${hidden} file${hidden === 1 ? "" : "s"} not shown: secret or key)\n` };
224
+ }
225
+ const r = await git(["show", "--stat", "--patch", format, hash, "--", ...kept.map(literal)], dir);
226
+ if (!r.ok)
227
+ return { diff: "", error: r.stderr || "git show failed" };
228
+ const out = capDiff(r.stdout);
229
+ if (hidden > 0)
230
+ out.diff += `\n(${hidden} file${hidden === 1 ? "" : "s"} not shown: secret or key)\n`;
231
+ return out;
232
+ }
114
233
  /** Every clone under the root, alphabetically. Folders without .git are skipped. */
115
234
  export async function listRepos() {
116
235
  const opened = await openRepos();
@@ -4,7 +4,7 @@ import { type ChannelSummary } from "./channels.js";
4
4
  * The ⌘K palette: every active agent and every channel in every workspace
5
5
  * this machine knows, so a user can jump to one without switching
6
6
  * workspaces first. This instance reads its own lists from disk; the others
7
- * come from the project registry, where each instance records them
7
+ * come from the workspace registry, where each instance records them
8
8
  * whenever it reads them (see instances.ts). Stopped workspaces are listed too — the
9
9
  * palette starts them on the way to the agent.
10
10
  */
@@ -3,7 +3,7 @@ import { resolveProject } from "../config.js";
3
3
  import { getProjectRoot } from "./context.js";
4
4
  import { listAgents, toSummary } from "./agents.js";
5
5
  import { listChannels, toChannelSummary } from "./channels.js";
6
- import { currentInstanceUrl, discoverWorkspaces, listProjects } from "./instances.js";
6
+ import { currentInstanceUrl, discoverWorkspaces, listWorkspaceRecords } from "./instances.js";
7
7
  export async function searchAgents() {
8
8
  const project = await resolveProject(getProjectRoot()).catch(() => null);
9
9
  const self = {
@@ -16,8 +16,8 @@ export async function searchAgents() {
16
16
  agents: (await listAgents().catch(() => [])).filter((a) => !a.archived).map(toSummary),
17
17
  channels: (await listChannels().catch(() => [])).map(toChannelSummary),
18
18
  };
19
- const [workspaces, projects] = await Promise.all([discoverWorkspaces(), listProjects()]);
20
- const records = new Map(projects.map((p) => [p.root, p]));
19
+ const [workspaces, known] = await Promise.all([discoverWorkspaces(), listWorkspaceRecords()]);
20
+ const records = new Map(known.map((p) => [p.root, p]));
21
21
  const others = workspaces
22
22
  .filter((w) => w.root && w.exists !== false)
23
23
  .map((w) => ({
@@ -1,7 +1,7 @@
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, saveAttachment } from "./chat/store.js";
4
+ import { attachmentPath, readMeta, saveAttachment } from "./chat/store.js";
5
5
  import { setOutputDir, setProjectRoot, getOutputDir, getProjectRoot } from "./context.js";
6
6
  import { publicHostFor, publicUrlFor, resolveProject, tunnelFor } from "../config.js";
7
7
  import { ACCESS_HEADER, verifyAccessToken } from "./access.js";
@@ -16,12 +16,13 @@ import { cancelChat, createChat, forkChat, resetSession, answerElicitation, stee
16
16
  import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
17
17
  import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
18
18
  import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
19
- import { discoverWorkspaces, forgetProject, listWorkspacesDetailed, markProjectStopped, registerInstance, registerProject, startProject, stopProject, tildify, unregisterInstance, } from "./instances.js";
19
+ import { discoverWorkspaces, forgetWorkspace, listWorkspacesDetailed, markWorkspaceStopped, registerInstance, registerWorkspace, startWorkspace, stopWorkspace, tildify, unregisterInstance, } from "./instances.js";
20
20
  import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
21
21
  import { getSettings, saveSettings } from "./settings.js";
22
- import { addRepo, listRepos, openRepos, removeRepo, updateRepo } from "./repos.js";
22
+ import { addRepo, listRepos, openRepos, removeRepo, repoCommitDiff, repoDetail, repoDiff, updateRepo, } from "./repos.js";
23
23
  import { createVibeable, deleteVibeable, disposeAllDevs, listVibeables, openVibeables, proxyUpgrade, resolveVibeable, serveVibeable, startDev, stopDev, subscribeVibeable, vibeableStatus, } from "./vibeables.js";
24
24
  import { searchAgents } from "./search.js";
25
+ import { appendProjectLog, createProject, deleteProject, getProject, linkProject, listProjects, openProjects, saveProject, } from "./projects.js";
25
26
  import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
26
27
  /**
27
28
  * The inspector server: a plain node:http server with no dependencies.
@@ -278,10 +279,18 @@ async function handleApi(req, res, url) {
278
279
  return json(res, await view(await saveChannel(input)));
279
280
  }
280
281
  if (seg.length === 3 && seg[2] === "from-chat" && method === "POST") {
281
- const { chatId, slug: _s, ...input } = body;
282
+ const { chatId, slug: _s, project: _p, ...input } = body;
282
283
  if (!chatId)
283
284
  return json(res, { error: "chatId is required" }, 400);
284
- const channel = await saveChannel(input);
285
+ // A project chat's coworkers work in that project: the chat decides, not the body.
286
+ let source = null;
287
+ try {
288
+ source = await readMeta(chatId);
289
+ }
290
+ catch { }
291
+ if (!source)
292
+ return json(res, { error: "chat not found" }, 404);
293
+ const channel = await saveChannel({ ...input, ...(source.scope.kind === "project" ? { project: source.scope.slug } : {}) });
285
294
  const converted = await convertChatToChannel(chatId, channel);
286
295
  if (converted.error) {
287
296
  await deleteChannel(channel.slug).catch(() => { });
@@ -360,7 +369,7 @@ async function handleApi(req, res, url) {
360
369
  const discovered = url.searchParams.get("probe") === "1" ? [] : await discoverWorkspaces();
361
370
  // Manual switcher entries keep their configured name/icon; discovered
362
371
  // workspaces that duplicate one (same url, running) only contribute
363
- // the live flag. Stopped projects never collide — they carry a root,
372
+ // the live flag. Stopped workspaces never collide — they carry a root,
364
373
  // and a manual entry pointing at the same port is just a link.
365
374
  const norm = (u) => u.replace(/\/+$/, "").replace("127.0.0.1", "localhost").toLowerCase();
366
375
  const manualUrls = new Set(manual.map((e) => norm(e.url)));
@@ -384,6 +393,7 @@ async function handleApi(req, res, url) {
384
393
  git: (project?.config.git && project.config.git.enabled !== false) === true,
385
394
  repos: (project?.config.repos && project.config.repos.enabled !== false) === true,
386
395
  vibeables: (project?.config.vibeables && project.config.vibeables.enabled !== false) === true,
396
+ projects: (project?.config.projects && project.config.projects.enabled !== false) === true,
387
397
  publicUrl: (project && publicUrlFor(project)) ?? "",
388
398
  switcher,
389
399
  });
@@ -474,6 +484,71 @@ async function handleApi(req, res, url) {
474
484
  return json(res, body, status);
475
485
  }
476
486
  }
487
+ // GET /api/projects — every project folder under the projects root | POST {title, goal?, slug?} — create one from the starter
488
+ if (seg.length === 2 && seg[1] === "projects") {
489
+ if (method === "GET")
490
+ return json(res, await listProjects());
491
+ if (method === "POST") {
492
+ if ((await openProjects()).off)
493
+ return json(res, { error: "projects are off" }, 409);
494
+ try {
495
+ const body = JSON.parse(await readBody(req));
496
+ const str = (v) => (typeof v === "string" ? v : undefined);
497
+ return json(res, await createProject({ title: str(body.title) ?? "", goal: str(body.goal), slug: str(body.slug) }), 201);
498
+ }
499
+ catch (err) {
500
+ return json(res, { error: err.message }, 400);
501
+ }
502
+ }
503
+ }
504
+ const projectError = (err) => {
505
+ const msg = err.message;
506
+ return [/^no project/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
507
+ };
508
+ // GET /api/projects/<slug> — definition, brief, state, log and link states | PUT — save fields | DELETE — remove the folder
509
+ if (seg.length === 3 && seg[1] === "projects") {
510
+ try {
511
+ if (method === "GET") {
512
+ const p = await getProject(seg[2]);
513
+ return p ? json(res, p) : json(res, { error: `no project "${seg[2]}"` }, 404);
514
+ }
515
+ if (method === "PUT") {
516
+ const body = JSON.parse(await readBody(req));
517
+ return json(res, await saveProject(seg[2], body));
518
+ }
519
+ if (method === "DELETE") {
520
+ await deleteProject(seg[2]);
521
+ return json(res, { ok: true });
522
+ }
523
+ }
524
+ catch (err) {
525
+ const [status, body] = projectError(err);
526
+ return json(res, body, status);
527
+ }
528
+ }
529
+ // POST /api/projects/<slug>/log {entry, actor?} — append a stamped line to log.md
530
+ if (seg.length === 4 && seg[1] === "projects" && seg[3] === "log" && method === "POST") {
531
+ try {
532
+ const body = JSON.parse(await readBody(req));
533
+ const log = await appendProjectLog(seg[2], typeof body.entry === "string" ? body.entry : "", typeof body.actor === "string" ? body.actor : "human:user");
534
+ return json(res, { ok: true, log });
535
+ }
536
+ catch (err) {
537
+ const [status, body] = projectError(err);
538
+ return json(res, body, status);
539
+ }
540
+ }
541
+ // POST /api/projects/<slug>/links {kind, target, remove?} — add or drop one linked slug
542
+ if (seg.length === 4 && seg[1] === "projects" && seg[3] === "links" && method === "POST") {
543
+ try {
544
+ const body = JSON.parse(await readBody(req));
545
+ return json(res, await linkProject(seg[2], typeof body.kind === "string" ? body.kind : "", typeof body.target === "string" ? body.target : "", body.remove === true));
546
+ }
547
+ catch (err) {
548
+ const [status, body] = projectError(err);
549
+ return json(res, body, status);
550
+ }
551
+ }
477
552
  // GET /api/vibeables/<slug>/events — SSE: file changes (debounced) and dev-server state
478
553
  if (seg.length === 4 && seg[1] === "vibeables" && seg[3] === "events" && method === "GET") {
479
554
  let dir;
@@ -508,6 +583,43 @@ async function handleApi(req, res, url) {
508
583
  return json(res, body, status);
509
584
  }
510
585
  }
586
+ const repoError = (err) => {
587
+ const msg = err.message;
588
+ return [/^no repository/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
589
+ };
590
+ // GET /api/repos/<slug> — what is happening in the clone: changed files, line counts, recent commits (unpushed marked)
591
+ if (seg.length === 3 && seg[1] === "repos" && method === "GET") {
592
+ try {
593
+ const detail = await repoDetail(seg[2]);
594
+ return detail ? json(res, detail) : json(res, { error: `no repository "${seg[2]}"` }, 404);
595
+ }
596
+ catch (err) {
597
+ const [status, body] = repoError(err);
598
+ return json(res, body, status);
599
+ }
600
+ }
601
+ // GET /api/repos/<slug>/diff?path= — unified diff of one changed file against HEAD
602
+ if (seg.length === 4 && seg[1] === "repos" && seg[3] === "diff" && method === "GET") {
603
+ try {
604
+ const d = await repoDiff(seg[2], url.searchParams.get("path") ?? "");
605
+ return json(res, d, d.error ? (/^not shown/.test(d.error) ? 404 : 400) : 200);
606
+ }
607
+ catch (err) {
608
+ const [status, body] = repoError(err);
609
+ return json(res, body, status);
610
+ }
611
+ }
612
+ // GET /api/repos/<slug>/commits/<hash> — one commit as a patch
613
+ if (seg.length === 5 && seg[1] === "repos" && seg[3] === "commits" && method === "GET") {
614
+ try {
615
+ const d = await repoCommitDiff(seg[2], seg[4]);
616
+ return json(res, d, d.error ? (/^no such commit/.test(d.error) ? 404 : 400) : 200);
617
+ }
618
+ catch (err) {
619
+ const [status, body] = repoError(err);
620
+ return json(res, body, status);
621
+ }
622
+ }
511
623
  // POST /api/repos/<slug>/update — fetch, fast-forward when clean
512
624
  if (seg.length === 4 && seg[1] === "repos" && seg[3] === "update" && method === "POST") {
513
625
  try {
@@ -533,44 +645,44 @@ async function handleApi(req, res, url) {
533
645
  if (seg.length === 3 && seg[1] === "search" && seg[2] === "agents" && method === "GET") {
534
646
  return json(res, await searchAgents());
535
647
  }
536
- // GET /api/projects — every known workspace incl. this one, with state + counts (admin screen)
537
- if (seg.length === 2 && seg[1] === "projects" && method === "GET") {
648
+ // GET /api/workspaces — every known workspace incl. this one, with state + counts (admin screen)
649
+ if (seg.length === 2 && seg[1] === "workspaces" && method === "GET") {
538
650
  return json(res, await listWorkspacesDetailed());
539
651
  }
540
- // POST /api/projects/start {root} — launch `kraftwerk ui` for a known
541
- // project as a detached process (the switcher's Start button).
542
- if (seg.length === 3 && seg[1] === "projects" && seg[2] === "start" && method === "POST") {
652
+ // POST /api/workspaces/start {root} — launch `kraftwerk ui` for a known
653
+ // workspace as a detached process (the switcher's Start button).
654
+ if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "start" && method === "POST") {
543
655
  try {
544
656
  const { root } = JSON.parse(await readBody(req));
545
657
  if (!root)
546
658
  return json(res, { error: "root required" }, 400);
547
- const result = await startProject(root);
659
+ const result = await startWorkspace(root);
548
660
  return json(res, result, result.ok ? 200 : 409);
549
661
  }
550
662
  catch (err) {
551
663
  return json(res, { error: err.message }, 400);
552
664
  }
553
665
  }
554
- // POST /api/projects/stop {root | url} — SIGTERM a running workspace's server
555
- if (seg.length === 3 && seg[1] === "projects" && seg[2] === "stop" && method === "POST") {
666
+ // POST /api/workspaces/stop {root | url} — SIGTERM a running workspace's server
667
+ if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "stop" && method === "POST") {
556
668
  try {
557
669
  const target = JSON.parse(await readBody(req));
558
670
  if (!target.root && !target.url)
559
671
  return json(res, { error: "root or url required" }, 400);
560
- const result = await stopProject(target);
672
+ const result = await stopWorkspace(target);
561
673
  return json(res, result, result.ok ? 200 : 409);
562
674
  }
563
675
  catch (err) {
564
676
  return json(res, { error: err.message }, 400);
565
677
  }
566
678
  }
567
- // POST /api/projects/forget {root} — drop a project from the registry
568
- if (seg.length === 3 && seg[1] === "projects" && seg[2] === "forget" && method === "POST") {
679
+ // POST /api/workspaces/forget {root} — drop a workspace from the registry
680
+ if (seg.length === 3 && seg[1] === "workspaces" && seg[2] === "forget" && method === "POST") {
569
681
  try {
570
682
  const { root } = JSON.parse(await readBody(req));
571
683
  if (!root)
572
684
  return json(res, { error: "root required" }, 400);
573
- return json(res, { ok: await forgetProject(root) });
685
+ return json(res, { ok: await forgetWorkspace(root) });
574
686
  }
575
687
  catch (err) {
576
688
  return json(res, { error: err.message }, 400);
@@ -993,6 +1105,22 @@ async function handleApi(req, res, url) {
993
1105
  else if (body.scope?.kind === "knowledge") {
994
1106
  scope = { kind: "knowledge", ...(body.scope.bundle ? { bundle: body.scope.bundle } : {}) };
995
1107
  }
1108
+ else if (body.scope?.kind === "project" && body.scope.slug) {
1109
+ // A project chat needs its folder; a missing one is a 404 here, not a chat that apologizes.
1110
+ let found;
1111
+ try {
1112
+ found = await getProject(body.scope.slug);
1113
+ }
1114
+ catch (err) {
1115
+ const [status, e] = projectError(err);
1116
+ return json(res, e, status);
1117
+ }
1118
+ if (!found)
1119
+ return json(res, { error: "project not found" }, 404);
1120
+ scope = { kind: "project", slug: found.slug };
1121
+ // Like agent sessions: the project decides the harness, not the caller.
1122
+ agent = found.harness;
1123
+ }
996
1124
  else {
997
1125
  scope = { kind: "general" };
998
1126
  }
@@ -1235,7 +1363,7 @@ export async function startInspector(opts) {
1235
1363
  disposeAllBackends();
1236
1364
  disposeAllDevs();
1237
1365
  unregisterInstance();
1238
- markProjectStopped();
1366
+ markWorkspaceStopped();
1239
1367
  process.exit(sig === "SIGINT" ? 130 : 143);
1240
1368
  });
1241
1369
  }
@@ -1245,7 +1373,7 @@ export async function startInspector(opts) {
1245
1373
  unregisterInstance();
1246
1374
  // A self-restart (new version) is not a stop — the project stays "running".
1247
1375
  if (code !== RESTART_EXIT_CODE)
1248
- markProjectStopped();
1376
+ markWorkspaceStopped();
1249
1377
  });
1250
1378
  const server = http.createServer(async (req, res) => {
1251
1379
  try {
@@ -1279,7 +1407,7 @@ export async function startInspector(opts) {
1279
1407
  const addr = server.address();
1280
1408
  const port = typeof addr === "object" && addr ? addr.port : opts.port;
1281
1409
  void registerInstance(port, getProjectRoot());
1282
- void registerProject(getProjectRoot());
1410
+ void registerWorkspace(getProjectRoot());
1283
1411
  resolve(server);
1284
1412
  });
1285
1413
  });
@@ -1,7 +1,7 @@
1
1
  import { type ProjectConfig, type SwitcherEntry } from "../config.js";
2
2
  /**
3
3
  * Workspace settings: read kraftwerk.yml for the UI and write the
4
- * UI-editable subset (name, icon, switcher, git, repos, vibeables) back. Edits go through the
4
+ * UI-editable subset (name, icon, switcher, git, repos, vibeables, projects) back. Edits go through the
5
5
  * yaml Document API so comments and every other key survive untouched;
6
6
  * a missing kraftwerk.yml is created at the project root on first save.
7
7
  */
@@ -30,6 +30,8 @@ export interface SaveSettingsInput {
30
30
  repos?: ReposSettingsInput;
31
31
  /** Omitted = untouched. Same shape and rules as repos. */
32
32
  vibeables?: ReposSettingsInput;
33
+ /** Omitted = untouched. Same shape and rules as repos. */
34
+ projects?: ReposSettingsInput;
33
35
  }
34
36
  export interface ReposSettingsInput {
35
37
  enabled: boolean;
@@ -1,7 +1,8 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseDocument } from "yaml";
4
- import { ignoreEntryFor, REPOS_DEFAULT_ROOT, resolveProject, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
4
+ import { ignoreEntryFor, PROJECTS_DEFAULT_ROOT, REPOS_DEFAULT_ROOT, resolveProject, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
5
+ import { ensureProjectsRoot } from "./projects.js";
5
6
  import { disposeAllDevs, ensureVibeablesRoot } from "./vibeables.js";
6
7
  import { getProjectRoot } from "./context.js";
7
8
  import { ensureReposRoot } from "./repos.js";
@@ -75,7 +76,8 @@ function cleanGit(value) {
75
76
  return null;
76
77
  return { enabled: false, ...out };
77
78
  }
78
- /** The repos or vibeables block to write; null when the feature is off and nothing else is set. */
79
+ const ROOT_DEFAULTS = { repos: REPOS_DEFAULT_ROOT, vibeables: VIBEABLES_DEFAULT_ROOT, projects: PROJECTS_DEFAULT_ROOT };
80
+ /** The repos, vibeables or projects block to write; null when the feature is off and nothing else is set. */
79
81
  function cleanRootBlock(block, value, projectRoot) {
80
82
  if (typeof value !== "object" || value === null)
81
83
  throw new Error(`${block} must be an object`);
@@ -86,7 +88,7 @@ function cleanRootBlock(block, value, projectRoot) {
86
88
  if (root && !ignoreEntryFor(projectRoot, root)) {
87
89
  throw new Error(`${block}.root must be a directory inside the project (not ".", ".." or an absolute path outside it)`);
88
90
  }
89
- const dflt = block === "repos" ? REPOS_DEFAULT_ROOT : VIBEABLES_DEFAULT_ROOT;
91
+ const dflt = ROOT_DEFAULTS[block];
90
92
  if (root && root !== dflt)
91
93
  out.root = root;
92
94
  if (value.enabled)
@@ -154,6 +156,13 @@ export async function saveSettings(input) {
154
156
  if (rootBefore !== rootAfter)
155
157
  disposeAllDevs();
156
158
  }
159
+ if (input.projects !== undefined) {
160
+ const projects = cleanRootBlock("projects", input.projects, project.root);
161
+ if (projects === null)
162
+ doc.delete("projects");
163
+ else
164
+ doc.set("projects", projects);
165
+ }
157
166
  await fs.writeFile(configPath, doc.toString());
158
167
  // Turning repositories on prepares the folder so the first clone and the
159
168
  // workspace git both find it in the right state.
@@ -161,5 +170,7 @@ export async function saveSettings(input) {
161
170
  await ensureReposRoot().catch(() => { });
162
171
  if (input.vibeables?.enabled)
163
172
  await ensureVibeablesRoot().catch(() => { });
173
+ if (input.projects?.enabled)
174
+ await ensureProjectsRoot().catch(() => { });
164
175
  return getSettings();
165
176
  }
@@ -0,0 +1 @@
1
+ import{a as e}from"./editor-B5WUiag9.js";export{e as markdown};
@@ -1 +1 @@
1
- import{L as e,R as t,Ut as n,i as r,r as i,s as a,u as o}from"./dist-H3rerbMg.js";import{n as s}from"./dist-D6fPO3Jz.js";import{r as c}from"./dist-BWkBM49t.js";var l=i.deserialize({version:14,states:"%pOVOWOOObQPOOOpOSO'#C_OOOO'#Cp'#CpQVOWOOQxQPOOO!TQQOOQ!YQPOOOOOO,58y,58yO!_OSO,58yOOOO-E6n-E6nO!dQQO'#CqQ{QPOOO!iQPOOQ{QPOOO!qQPOOOOOO1G.e1G.eOOQO,59],59]OOQO-E6o-E6oO!yOpO'#CiO#RO`O'#CiQOQPOOO#ZO#tO'#CmO#fO!bO'#CmOOQO,59T,59TO#qOpO,59TO#vO`O,59TOOOO'#Cr'#CrO#{O#tO,59XOOQO,59X,59XOOOO'#Cs'#CsO$WO!bO,59XOOQO1G.o1G.oOOOO-E6p-E6pOOQO1G.s1G.sOOOO-E6q-E6q",stateData:"$g~OjOS~OQROUROkQO~OWTOXUOZUO`VO~OSXOTWO~OXUO[]OlZO~OY^O~O[_O~OT`O~OYaO~OmcOodO~OmfOogO~O^iOnhO~O_jOphO~ObkOqkOrmO~OcnOsnOtmO~OnpO~OppO~ObkOqkOrrO~OcnOsnOtrO~OWX`~",goto:`!^hPPPiPPPPPPPPPmPPPpPPsy!Q!WTROSRe]Re_QSORYSS[T^Rb[QlfRqlQogRso`,nodeNames:`⚠ Content Text Interpolation InterpolationContent }} Entity Attribute VueAttributeName : Identifier @ Is ScriptAttributeValue AttributeScript AttributeScript AttributeName AttributeValue Entity Entity`,maxTerm:36,nodeProps:[[`isolate`,-3,3,13,17,``]],skippedNodes:[0],repeatNodeCount:4,tokenData:"'y~RdXY!aYZ!a]^!apq!ars!rwx!w}!O!|!O!P#t!Q![#y![!]$s!_!`%g!b!c%l!c!}#y#R#S#y#T#j#y#j#k%q#k#o#y%W;'S#y;'S;:j$m<%lO#y~!fSj~XY!aYZ!a]^!apq!a~!wOm~~!|Oo~!b#RX`!b}!O!|!Q![!|![!]!|!c!}!|#R#S!|#T#o!|%W;'S!|;'S;:j#n<%lO!|!b#qP;=`<%l!|~#yOl~%W$QXY#t`!b}!O!|!Q![#y![!]!|!c!}#y#R#S#y#T#o#y%W;'S#y;'S;:j$m<%lO#y%W$pP;=`<%l#y~$zXX~`!b}!O!|!Q![!|![!]!|!c!}!|#R#S!|#T#o!|%W;'S!|;'S;:j#n<%lO!|~%lO[~~%qOZ~%W%xXY#t`!b}!O&e!Q![#y![!]!|!c!}#y#R#S#y#T#o#y%W;'S#y;'S;:j$m<%lO#y!b&jX`!b}!O!|!Q![!|![!]!|!c!}'V#R#S!|#T#o'V%W;'S!|;'S;:j#n<%lO!|!b'^XW!b`!b}!O!|!Q![!|![!]!|!c!}'V#R#S!|#T#o'V%W;'S!|;'S;:j#n<%lO!|",tokenizers:[6,7,new r(`b~RP#q#rU~XP#q#r[~aOT~~`,17,4),new r("!k~RQvwX#o#p!_~^TU~Opmq!]m!^;'Sm;'S;=`!X<%lOm~pUOpmq!]m!]!^!S!^;'Sm;'S;=`!X<%lOm~!XOU~~![P;=`<%lm~!bP#o#p!e~!jOk~~",72,2),new r(`[~RPwxU~ZOp~~`,11,15),new r(`[~RPrsU~ZOn~~`,11,14),new r("!e~RQvwXwx!_~^Tc~Opmq!]m!^;'Sm;'S;=`!X<%lOm~pUOpmq!]m!]!^!S!^;'Sm;'S;=`!X<%lOm~!XOc~~![P;=`<%lm~!dOt~~",66,35),new r("!e~RQrsXvw^~^Or~~cTb~Oprq!]r!^;'Sr;'S;=`!^<%lOr~uUOprq!]r!]!^!X!^;'Sr;'S;=`!^<%lOr~!^Ob~~!aP;=`<%lr~",66,33)],topRules:{Content:[0,1],Attribute:[1,7]},tokenPrec:157}),u=c.parser.configure({top:`SingleExpression`}),d=l.configure({props:[e({Text:t.content,Is:t.definitionOperator,AttributeName:t.attributeName,VueAttributeName:t.keyword,Identifier:t.variableName,"AttributeValue ScriptAttributeValue":t.attributeValue,Entity:t.character,"{{ }}":t.brace,"@ :":t.punctuation})]}),f={parser:u},p=d.configure({wrap:n((e,t)=>e.name==`InterpolationContent`?f:null)}),m=d.configure({wrap:n((e,t)=>e.name==`AttributeScript`?f:null),top:`Attribute`}),h={parser:p},g={parser:m},_=s();function v(e){return e.configure({dialect:`selfClosing`,wrap:n(b)},`vue`)}var y=v(_.language);function b(e,t){switch(e.name){case`Attribute`:return/^(@|:|v-)/.test(t.read(e.from,e.from+2))?g:null;case`Text`:return h}return null}function x(e={}){let t=_;if(e.base){if(e.base.language.name!=`html`||!(e.base.language instanceof a))throw RangeError(`The base option must be the result of calling html(...)`);t=e.base}return new o(t.language==_.language?y:v(t.language),[t.support,t.language.data.of({closeBrackets:{brackets:[`{`,`"`]}})])}export{x as vue};
1
+ import{L as e,R as t,Ut as n,i as r,r as i,s as a,u as o}from"./dist-H3rerbMg.js";import{n as s}from"./dist-Ca8OxgWP.js";import{r as c}from"./dist-DkEzjj-j.js";var l=i.deserialize({version:14,states:"%pOVOWOOObQPOOOpOSO'#C_OOOO'#Cp'#CpQVOWOOQxQPOOO!TQQOOQ!YQPOOOOOO,58y,58yO!_OSO,58yOOOO-E6n-E6nO!dQQO'#CqQ{QPOOO!iQPOOQ{QPOOO!qQPOOOOOO1G.e1G.eOOQO,59],59]OOQO-E6o-E6oO!yOpO'#CiO#RO`O'#CiQOQPOOO#ZO#tO'#CmO#fO!bO'#CmOOQO,59T,59TO#qOpO,59TO#vO`O,59TOOOO'#Cr'#CrO#{O#tO,59XOOQO,59X,59XOOOO'#Cs'#CsO$WO!bO,59XOOQO1G.o1G.oOOOO-E6p-E6pOOQO1G.s1G.sOOOO-E6q-E6q",stateData:"$g~OjOS~OQROUROkQO~OWTOXUOZUO`VO~OSXOTWO~OXUO[]OlZO~OY^O~O[_O~OT`O~OYaO~OmcOodO~OmfOogO~O^iOnhO~O_jOphO~ObkOqkOrmO~OcnOsnOtmO~OnpO~OppO~ObkOqkOrrO~OcnOsnOtrO~OWX`~",goto:`!^hPPPiPPPPPPPPPmPPPpPPsy!Q!WTROSRe]Re_QSORYSS[T^Rb[QlfRqlQogRso`,nodeNames:`⚠ Content Text Interpolation InterpolationContent }} Entity Attribute VueAttributeName : Identifier @ Is ScriptAttributeValue AttributeScript AttributeScript AttributeName AttributeValue Entity Entity`,maxTerm:36,nodeProps:[[`isolate`,-3,3,13,17,``]],skippedNodes:[0],repeatNodeCount:4,tokenData:"'y~RdXY!aYZ!a]^!apq!ars!rwx!w}!O!|!O!P#t!Q![#y![!]$s!_!`%g!b!c%l!c!}#y#R#S#y#T#j#y#j#k%q#k#o#y%W;'S#y;'S;:j$m<%lO#y~!fSj~XY!aYZ!a]^!apq!a~!wOm~~!|Oo~!b#RX`!b}!O!|!Q![!|![!]!|!c!}!|#R#S!|#T#o!|%W;'S!|;'S;:j#n<%lO!|!b#qP;=`<%l!|~#yOl~%W$QXY#t`!b}!O!|!Q![#y![!]!|!c!}#y#R#S#y#T#o#y%W;'S#y;'S;:j$m<%lO#y%W$pP;=`<%l#y~$zXX~`!b}!O!|!Q![!|![!]!|!c!}!|#R#S!|#T#o!|%W;'S!|;'S;:j#n<%lO!|~%lO[~~%qOZ~%W%xXY#t`!b}!O&e!Q![#y![!]!|!c!}#y#R#S#y#T#o#y%W;'S#y;'S;:j$m<%lO#y!b&jX`!b}!O!|!Q![!|![!]!|!c!}'V#R#S!|#T#o'V%W;'S!|;'S;:j#n<%lO!|!b'^XW!b`!b}!O!|!Q![!|![!]!|!c!}'V#R#S!|#T#o'V%W;'S!|;'S;:j#n<%lO!|",tokenizers:[6,7,new r(`b~RP#q#rU~XP#q#r[~aOT~~`,17,4),new r("!k~RQvwX#o#p!_~^TU~Opmq!]m!^;'Sm;'S;=`!X<%lOm~pUOpmq!]m!]!^!S!^;'Sm;'S;=`!X<%lOm~!XOU~~![P;=`<%lm~!bP#o#p!e~!jOk~~",72,2),new r(`[~RPwxU~ZOp~~`,11,15),new r(`[~RPrsU~ZOn~~`,11,14),new r("!e~RQvwXwx!_~^Tc~Opmq!]m!^;'Sm;'S;=`!X<%lOm~pUOpmq!]m!]!^!S!^;'Sm;'S;=`!X<%lOm~!XOc~~![P;=`<%lm~!dOt~~",66,35),new r("!e~RQrsXvw^~^Or~~cTb~Oprq!]r!^;'Sr;'S;=`!^<%lOr~uUOprq!]r!]!^!X!^;'Sr;'S;=`!^<%lOr~!^Ob~~!aP;=`<%lr~",66,33)],topRules:{Content:[0,1],Attribute:[1,7]},tokenPrec:157}),u=c.parser.configure({top:`SingleExpression`}),d=l.configure({props:[e({Text:t.content,Is:t.definitionOperator,AttributeName:t.attributeName,VueAttributeName:t.keyword,Identifier:t.variableName,"AttributeValue ScriptAttributeValue":t.attributeValue,Entity:t.character,"{{ }}":t.brace,"@ :":t.punctuation})]}),f={parser:u},p=d.configure({wrap:n((e,t)=>e.name==`InterpolationContent`?f:null)}),m=d.configure({wrap:n((e,t)=>e.name==`AttributeScript`?f:null),top:`Attribute`}),h={parser:p},g={parser:m},_=s();function v(e){return e.configure({dialect:`selfClosing`,wrap:n(b)},`vue`)}var y=v(_.language);function b(e,t){switch(e.name){case`Attribute`:return/^(@|:|v-)/.test(t.read(e.from,e.from+2))?g:null;case`Text`:return h}return null}function x(e={}){let t=_;if(e.base){if(e.base.language.name!=`html`||!(e.base.language instanceof a))throw RangeError(`The base option must be the result of calling html(...)`);t=e.base}return new o(t.language==_.language?y:v(t.language),[t.support,t.language.data.of({closeBrackets:{brackets:[`{`,`"`]}})])}export{x as vue};