@netnodeag/kraftwerk 0.64.0 → 0.66.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 (97) hide show
  1. package/README.md +126 -138
  2. package/dist/acp.js +3 -1
  3. package/dist/cli/daemon.d.ts +14 -0
  4. package/dist/cli/daemon.js +60 -0
  5. package/dist/cli/devices.d.ts +14 -0
  6. package/dist/cli/devices.js +53 -0
  7. package/dist/cli/doctor-legacy.js +9 -1
  8. package/dist/cli/doctor.js +17 -30
  9. package/dist/cli/init.js +1 -6
  10. package/dist/cli/kraftwerk.js +8 -3
  11. package/dist/cli/ui.d.ts +9 -0
  12. package/dist/cli/ui.js +47 -23
  13. package/dist/cli/workspaces.js +5 -3
  14. package/dist/client/index.d.ts +17 -0
  15. package/dist/client/index.js +10 -2
  16. package/dist/client/live.d.ts +1 -2
  17. package/dist/client/live.js +4 -2
  18. package/dist/client/routes.d.ts +8 -0
  19. package/dist/client/routes.js +8 -0
  20. package/dist/config.d.ts +18 -55
  21. package/dist/config.js +39 -92
  22. package/dist/core/chat/context.js +1 -1
  23. package/dist/core/chat/pi.js +2 -1
  24. package/dist/core/chat/sessions.d.ts +1 -1
  25. package/dist/core/chat/sessions.js +1 -1
  26. package/dist/core/chat/state.d.ts +2 -0
  27. package/dist/core/chat/state.js +5 -0
  28. package/dist/core/daemon.d.ts +27 -0
  29. package/dist/core/daemon.js +61 -0
  30. package/dist/core/devices.d.ts +35 -0
  31. package/dist/core/devices.js +156 -0
  32. package/dist/core/env.d.ts +16 -0
  33. package/dist/core/env.js +45 -0
  34. package/dist/core/git.d.ts +2 -0
  35. package/dist/core/git.js +20 -4
  36. package/dist/core/instances.d.ts +32 -12
  37. package/dist/core/instances.js +136 -64
  38. package/dist/core/routines.d.ts +6 -0
  39. package/dist/core/routines.js +11 -0
  40. package/dist/core/runner.js +7 -3
  41. package/dist/core/vibeables.d.ts +2 -0
  42. package/dist/core/vibeables.js +17 -5
  43. package/dist/core/workspace.d.ts +13 -0
  44. package/dist/core/workspace.js +25 -0
  45. package/dist/dotenv.d.ts +2 -2
  46. package/dist/dotenv.js +2 -2
  47. package/dist/server/api/devices.d.ts +20 -0
  48. package/dist/server/api/devices.js +55 -0
  49. package/dist/server/api/hub.d.ts +25 -0
  50. package/dist/server/api/hub.js +32 -0
  51. package/dist/server/api/index.d.ts +42 -2
  52. package/dist/server/api/index.js +5 -1
  53. package/dist/server/api/router.d.ts +21 -6
  54. package/dist/server/api/router.js +37 -9
  55. package/dist/server/api/socket.d.ts +15 -1
  56. package/dist/server/api/socket.js +59 -8
  57. package/dist/server/api/workspace.d.ts +2 -0
  58. package/dist/server/api/workspace.js +4 -2
  59. package/dist/server/bind.d.ts +16 -0
  60. package/dist/server/bind.js +25 -0
  61. package/dist/server/cloud.d.ts +5 -1
  62. package/dist/server/cloud.js +89 -56
  63. package/dist/server/hub-context.d.ts +7 -0
  64. package/dist/server/hub-context.js +18 -0
  65. package/dist/server/live.d.ts +2 -0
  66. package/dist/server/live.js +9 -0
  67. package/dist/server/preview.js +2 -1
  68. package/dist/server/server.d.ts +58 -1
  69. package/dist/server/server.js +267 -103
  70. package/dist/server/target.d.ts +20 -0
  71. package/dist/server/target.js +18 -0
  72. package/dist/server/trust.d.ts +41 -0
  73. package/dist/server/trust.js +42 -0
  74. package/package.json +1 -1
  75. package/schema/kraftwerk.schema.json +7 -46
  76. package/web/dist/assets/{dist-Cgcwvf4j.js → dist-1bNkaWWA.js} +1 -1
  77. package/web/dist/assets/dist-BU1SOB5S.js +1 -0
  78. package/web/dist/assets/{dist-CboP0-T1.js → dist-Bk3f-J5U.js} +1 -1
  79. package/web/dist/assets/{dist-2kswkIH2.js → dist-Bx99Puvv.js} +1 -1
  80. package/web/dist/assets/{dist-D2PvEzo7.js → dist-C0M2aITo.js} +1 -1
  81. package/web/dist/assets/{dist-D_BcTzz-.js → dist-C0z19Zge.js} +1 -1
  82. package/web/dist/assets/{dist-36MjITWa.js → dist-COEgBKG6.js} +1 -1
  83. package/web/dist/assets/{dist-Jdx6DRrm.js → dist-CYRrdlae.js} +1 -1
  84. package/web/dist/assets/{dist-DDkd_zrP.js → dist-CbEFnSCD.js} +1 -1
  85. package/web/dist/assets/{dist-BsuA2pgO.js → dist-DCYVKU-H.js} +1 -1
  86. package/web/dist/assets/{dist-B8gPjMPP.js → dist-XyTHWtoN.js} +1 -1
  87. package/web/dist/assets/{editor-ByRUOfrX.js → editor-DPcGvSPy.js} +3 -3
  88. package/web/dist/assets/index-BAYs-2WC.css +2 -0
  89. package/web/dist/assets/index-DKLJdwYE.js +229 -0
  90. package/web/dist/index.html +2 -2
  91. package/dist/cli/tunnel.d.ts +0 -10
  92. package/dist/cli/tunnel.js +0 -280
  93. package/dist/server/access.d.ts +0 -32
  94. package/dist/server/access.js +0 -121
  95. package/web/dist/assets/dist-CVqmJrYd.js +0 -1
  96. package/web/dist/assets/index-BR7HvDzD.js +0 -229
  97. package/web/dist/assets/index-Bra74Nxe.css +0 -2
package/dist/core/git.js CHANGED
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
  import { projectsRootFor, reposRootFor, resolveWorkspace, vibeablesRootFor, isSafeGitName } from "../config.js";
5
5
  import { ENV_FILE } from "../runner/docker.js";
6
6
  import { getOutputDir, workspaceRoot } from "./context.js";
7
- import { perWorkspace } from "./workspace.js";
7
+ import { perWorkspace, workspaceOpen } from "./workspace.js";
8
+ import { workspaceEnv } from "./env.js";
8
9
  /**
9
10
  * Workspace git sync. Several people run kraftwerk against one git repo, so
10
11
  * knowledge, agents, skills, channels and workflows travel between them like
@@ -65,7 +66,7 @@ const MAX_BLOCKED = 200;
65
66
  * terminal read that never returns, and the inspector hangs with it.
66
67
  */
67
68
  const gitEnv = (extra) => ({
68
- ...process.env,
69
+ ...workspaceEnv(),
69
70
  GIT_TERMINAL_PROMPT: "0",
70
71
  GIT_ASKPASS: "",
71
72
  SSH_ASKPASS: "",
@@ -341,6 +342,8 @@ const gitState = perWorkspace(() => ({
341
342
  timer: null,
342
343
  ticking: false,
343
344
  lastRun: 0,
345
+ /** Set when the workspace closes: a tick in flight does not schedule the next one. */
346
+ stopped: false,
344
347
  }));
345
348
  /** Invalidate the status cache after anything that changes the repo. */
346
349
  const touched = () => {
@@ -643,10 +646,19 @@ const RECHECK_MS = 60_000;
643
646
  * out) before the next one starts, or a short interval piles up processes.
644
647
  */
645
648
  export function startGitSync() {
649
+ gitState().stopped = false;
646
650
  if (gitState().timer || gitState().ticking)
647
651
  return;
648
652
  schedule(0);
649
653
  }
654
+ /** Stop the current workspace's background sync (the workspace closes). A tick in flight finishes, then stops. */
655
+ export function stopGitSync() {
656
+ const s = gitState();
657
+ s.stopped = true;
658
+ if (s.timer)
659
+ clearTimeout(s.timer);
660
+ s.timer = null;
661
+ }
650
662
  function schedule(ms) {
651
663
  const timer = setTimeout(() => void tick(), ms);
652
664
  timer.unref?.();
@@ -671,8 +683,12 @@ async function tick() {
671
683
  // A failed tick is retried at the next one; the status carries lastError.
672
684
  }
673
685
  finally {
674
- gitState().ticking = false;
675
- schedule(next);
686
+ // The workspace may have closed while git ran: then its state is gone and nothing is scheduled.
687
+ if (workspaceOpen()) {
688
+ gitState().ticking = false;
689
+ if (!gitState().stopped)
690
+ schedule(next);
691
+ }
676
692
  }
677
693
  }
678
694
  async function sync(repo) {
@@ -6,6 +6,8 @@ export declare const legacyWorkspacesDir: () => string;
6
6
  /** Durable per-project record. The root is the key; everything else derives from it. */
7
7
  export interface WorkspaceRecord {
8
8
  root: string;
9
+ /** The workspace's address on this machine (kraftwerk.yml `slug`, else its folder name), as of its last start. */
10
+ slug?: string;
9
11
  firstSeen: string;
10
12
  lastStarted: string;
11
13
  /** Set on clean shutdown; older than lastStarted means the last run died. */
@@ -29,10 +31,14 @@ export interface DiscoveredInstance {
29
31
  named?: boolean;
30
32
  live: true;
31
33
  root?: string;
32
- /** Server process id (what to signal to stop it). */
34
+ /** Server process id (what to signal to stop it — unless `hub`). */
33
35
  pid: number;
34
36
  /** The kraftwerk version the instance runs. */
35
37
  version?: string;
38
+ /** Its slug, when a daemon serves it. */
39
+ slug?: string;
40
+ /** Served by a daemon next to other workspaces: stopped by closing it there. */
41
+ hub?: boolean;
36
42
  }
37
43
  /**
38
44
  * One workspace as the switcher shows it: a known project (running or
@@ -60,12 +66,20 @@ export interface WorkspaceEntry {
60
66
  export declare const workspaceRecordName: (root: string) => string;
61
67
  /** ~/… for display. */
62
68
  export declare const tildify: (p: string) => string;
63
- /** Write this instance's registry file (call once the server listens). */
64
- export declare function registerInstance(port: number, root: string): Promise<void>;
65
- /** Where this instance answers, as the switcher links it (null before listen). */
69
+ /**
70
+ * Write a served workspace's registry file (call once the server listens).
71
+ * A daemon's workspaces carry their slug and address and get a file each;
72
+ * a standalone server keeps the one `<pid>.json` it always had.
73
+ */
74
+ export declare function registerInstance(port: number, root: string, opts?: {
75
+ url?: string;
76
+ slug?: string;
77
+ hub?: boolean;
78
+ }): Promise<void>;
79
+ /** Where the current workspace answers, as the switcher links it (null before listen). */
66
80
  export declare const currentInstanceUrl: () => string | null;
67
- /** Remove this instance's registry file. Sync so exit handlers can call it. */
68
- export declare function unregisterInstance(): void;
81
+ /** Remove a served workspace's registry file — every one this process wrote when no root is given. Sync so exit handlers can call it. */
82
+ export declare function unregisterInstance(root?: string): void;
69
83
  /**
70
84
  * All other running instances, verified live. Cached briefly — /api/meta is
71
85
  * polled. `fresh` probes now; `readOnly` leaves the records of instances that
@@ -80,10 +94,14 @@ export declare function registerWorkspace(root: string): Promise<void>;
80
94
  export declare const syncWorkspaceAgents: (root: string, agents: AgentSummary[]) => Promise<void>;
81
95
  export declare const syncWorkspaceChannels: (root: string, channels: ChannelSummary[]) => Promise<void>;
82
96
  export declare const syncWorkspaceProjects: (root: string, projects: ProjectHit[]) => Promise<void>;
83
- /** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
84
- export declare function markWorkspaceStopped(): void;
97
+ /** Stamp lastStopped on a clean stop — every workspace this process serves when no root is given. Sync so exit handlers can call it. */
98
+ export declare function markWorkspaceStopped(root?: string): void;
85
99
  /** All known workspaces, most recently started first. */
86
100
  export declare function listWorkspaceRecords(): Promise<WorkspaceRecord[]>;
101
+ /** A record's slug: as registered, or (records from before 0.66) its folder name made into one. */
102
+ export declare const recordSlug: (r: WorkspaceRecord) => string;
103
+ /** Other registered workspaces with this slug — two workspaces cannot share an address. */
104
+ export declare function slugClashes(slug: string, root: string): Promise<WorkspaceRecord[]>;
87
105
  /** Drop a workspace record (the root itself is untouched) — from the legacy directory too, see migrateRegistry. */
88
106
  export declare function forgetWorkspace(root: string): Promise<boolean>;
89
107
  /**
@@ -137,10 +155,12 @@ export interface StartResult {
137
155
  */
138
156
  export declare function startWorkspace(root: string): Promise<StartResult>;
139
157
  /**
140
- * Stop a running workspace: SIGTERM to its server process, which
141
- * unregisters, stamps lastStopped and exits; the `kraftwerk ui` supervisor
142
- * follows. Matched by root, or by url for instances from older versions
143
- * that registered no root. Waits up to ~5s for the port to go quiet.
158
+ * Stop a running workspace. A daemon's workspace is closed there — the
159
+ * daemon and every other workspace in it keep running. A standalone one
160
+ * gets SIGTERM to its server process, which unregisters, stamps
161
+ * lastStopped and exits; the `kraftwerk ui` supervisor follows. Matched by
162
+ * root, or by url for instances from older versions that registered no
163
+ * root. Waits up to ~5s for it to go quiet.
144
164
  */
145
165
  export declare function stopWorkspace(target: {
146
166
  root?: string;
@@ -3,8 +3,10 @@ import { createHash } from "node:crypto";
3
3
  import fsSync, { promises as fs } from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { absolutePath, isDir, resolveWorkspace } from "../config.js";
6
+ import { absolutePath, isDir, resolveWorkspace, slugFromFolder, workspaceSlug } from "../config.js";
7
7
  import { selfCommand } from "./self-command.js";
8
+ import { DAEMON_PORT, daemonCall, findDaemon } from "./daemon.js";
9
+ import { currentWorkspace } from "./workspace.js";
8
10
  /**
9
11
  * Two registries under ~/.kraftwerk, two lifecycles:
10
12
  *
@@ -31,9 +33,16 @@ const workspacesDir = () => path.join(kwHome(), "workspaces");
31
33
  /** Where the records lived before 0.49 (`kraftwerk projects`); moved over once, see migrateRegistry. */
32
34
  export const legacyWorkspacesDir = () => path.join(kwHome(), "projects");
33
35
  const logsDir = () => path.join(kwHome(), "logs");
34
- const selfFile = () => path.join(instancesDir(), `${process.pid}.json`);
35
- let selfPort = null;
36
- let selfRoot = null;
36
+ const own = new Map();
37
+ /** The workspace the caller runs in (a request, a timer), or null outside any. */
38
+ function currentRoot() {
39
+ try {
40
+ return currentWorkspace().root;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
37
46
  /** File name of a root's record: a hash of the absolute root, the same under the old and the new directory. */
38
47
  export const workspaceRecordName = (root) => `${createHash("sha1").update(absolutePath(root)).digest("hex").slice(0, 16)}.json`;
39
48
  const recordFile = (root) => path.join(workspacesDir(), workspaceRecordName(root));
@@ -43,42 +52,61 @@ export const tildify = (p) => {
43
52
  return p === home || p.startsWith(home + path.sep) ? "~" + p.slice(home.length) : p;
44
53
  };
45
54
  /* ---------- instances (ephemeral) ---------- */
46
- /** Write this instance's registry file (call once the server listens). */
47
- export async function registerInstance(port, root) {
48
- selfPort = port;
49
- selfRoot = absolutePath(root);
55
+ /**
56
+ * Write a served workspace's registry file (call once the server listens).
57
+ * A daemon's workspaces carry their slug and address and get a file each;
58
+ * a standalone server keeps the one `<pid>.json` it always had.
59
+ */
60
+ export async function registerInstance(port, root, opts = {}) {
61
+ const abs = absolutePath(root);
62
+ const file = path.join(instancesDir(), opts.hub ? `${process.pid}-${workspaceRecordName(abs)}` : `${process.pid}.json`);
63
+ const url = opts.url ?? `http://localhost:${port}`;
64
+ const mine = { port, url, file };
65
+ own.set(abs, mine);
50
66
  try {
51
67
  await fs.mkdir(instancesDir(), { recursive: true });
52
- const rec = { pid: process.pid, port, startedAt: new Date().toISOString(), root: selfRoot };
53
- await fs.writeFile(selfFile(), JSON.stringify(rec));
68
+ const rec = { pid: process.pid, port, startedAt: new Date().toISOString(), root: abs, ...(opts.hub ? { url, slug: opts.slug, hub: true } : {}) };
69
+ await fs.writeFile(file, JSON.stringify(rec));
70
+ // Closed while this wrote (an open and a close in quick succession): the file must not outlive it.
71
+ if (own.get(abs) !== mine)
72
+ await fs.unlink(file).catch(() => { });
54
73
  }
55
74
  catch { } // best-effort — without it discovery just won't see us
75
+ cache = null;
76
+ wsCache.clear();
56
77
  }
57
- /** Where this instance answers, as the switcher links it (null before listen). */
58
- export const currentInstanceUrl = () => (selfPort ? `http://localhost:${selfPort}` : null);
59
- /** Remove this instance's registry file. Sync so exit handlers can call it. */
60
- export function unregisterInstance() {
61
- try {
62
- fsSync.unlinkSync(selfFile());
78
+ /** Where the current workspace answers, as the switcher links it (null before listen). */
79
+ export const currentInstanceUrl = () => own.get(currentRoot() ?? "")?.url ?? null;
80
+ /** Remove a served workspace's registry file — every one this process wrote when no root is given. Sync so exit handlers can call it. */
81
+ export function unregisterInstance(root) {
82
+ const roots = root ? [absolutePath(root)] : [...own.keys()];
83
+ for (const r of roots) {
84
+ try {
85
+ fsSync.unlinkSync(own.get(r)?.file ?? "");
86
+ }
87
+ catch { }
88
+ own.delete(r);
63
89
  }
64
- catch { }
90
+ cache = null;
91
+ wsCache.clear();
65
92
  }
66
93
  /**
67
94
  * Probe one registered port: is a kraftwerk inspector answering? `probe=1`
68
95
  * tells the other side to skip its own discovery — two instances probing
69
96
  * each other's /api/meta must not recurse.
70
97
  */
71
- async function probe(port) {
98
+ async function probe(port, slug) {
72
99
  // `listen(port, "localhost")` binds one address family, whichever the
73
100
  // resolver returns first (::1 on this platform), so an instance started
74
101
  // with KRAFTWERK_UI_HOST=localhost or ::1 is not on 127.0.0.1. A refused
75
102
  // connection returns at once, so the second try costs nothing when the
76
103
  // first one answers.
77
- return (await probeAt("127.0.0.1", port)) ?? (await probeAt("[::1]", port));
104
+ return (await probeAt("127.0.0.1", port, slug)) ?? (await probeAt("[::1]", port, slug));
78
105
  }
79
- async function probeAt(host, port) {
106
+ async function probeAt(host, port, slug) {
80
107
  try {
81
- const r = await fetch(`http://${host}:${port}/api/meta?probe=1`, {
108
+ // A daemon's workspace answers under its slug; the path form needs no name resolution.
109
+ const r = await fetch(`http://${host}:${port}${slug ? `/w/${slug}` : ""}/api/meta?probe=1`, {
82
110
  signal: AbortSignal.timeout(400),
83
111
  });
84
112
  if (!r.ok)
@@ -110,7 +138,7 @@ let cache = null;
110
138
  */
111
139
  export async function discoverInstances(opts = {}) {
112
140
  if (!opts.fresh && cache && Date.now() - cache.at < 5_000)
113
- return cache.entries;
141
+ return notSelf(cache.entries);
114
142
  let files;
115
143
  try {
116
144
  files = await fs.readdir(instancesDir());
@@ -128,21 +156,25 @@ export async function discoverInstances(opts = {}) {
128
156
  catch {
129
157
  return null;
130
158
  }
131
- if (rec.pid === process.pid || rec.port === selfPort)
132
- return null;
133
- const found = await probe(rec.port);
159
+ const found = await probe(rec.port, rec.hub ? rec.slug : undefined);
134
160
  if (!found) {
135
161
  if (!opts.readOnly)
136
162
  await fs.unlink(path.join(instancesDir(), f)).catch(() => { }); // stale — prune
137
163
  return null;
138
164
  }
139
- return { ...found, root: rec.root, pid: rec.pid };
165
+ return { ...found, root: rec.root, pid: rec.pid, ...(rec.hub ? { url: rec.url ?? found.url, slug: rec.slug, hub: true } : {}) };
140
166
  }));
141
167
  const entries = probed
142
168
  .filter((e) => e != null)
143
169
  .sort((a, b) => a.name.localeCompare(b.name));
144
170
  cache = { at: Date.now(), entries };
145
- return entries;
171
+ return notSelf(entries);
172
+ }
173
+ /** Leave out the asking workspace itself (an older instance file without a root: by this process and port). */
174
+ function notSelf(entries) {
175
+ const me = currentRoot();
176
+ const port = me ? own.get(me)?.port : undefined;
177
+ return entries.filter((e) => (e.root ? e.root !== me : !(e.pid === process.pid && e.url === `http://localhost:${port}`)));
146
178
  }
147
179
  /* ---------- workspaces (durable) ---------- */
148
180
  let migrated = null;
@@ -182,6 +214,7 @@ async function readRecord(file) {
182
214
  return null;
183
215
  return {
184
216
  root: rec.root,
217
+ ...(typeof rec.slug === "string" ? { slug: rec.slug } : {}),
185
218
  firstSeen: rec.firstSeen ?? rec.lastStarted ?? "",
186
219
  lastStarted: rec.lastStarted ?? rec.firstSeen ?? "",
187
220
  lastStopped: rec.lastStopped,
@@ -197,14 +230,19 @@ async function readRecord(file) {
197
230
  }
198
231
  /** Upsert the project record for a root (call on every inspector start). */
199
232
  export async function registerWorkspace(root) {
200
- await migrateRegistry();
201
233
  const abs = absolutePath(root);
234
+ // Where it goes is decided now: the registry of the home this was called in, whatever HOME is by the time it writes.
235
+ const dir = workspacesDir();
236
+ const file = path.join(dir, workspaceRecordName(abs));
237
+ await migrateRegistry();
202
238
  const now = new Date().toISOString();
203
239
  try {
204
- await fs.mkdir(workspacesDir(), { recursive: true });
205
- const prev = await readRecord(recordFile(abs));
240
+ await fs.mkdir(dir, { recursive: true });
241
+ const prev = await readRecord(file);
242
+ const slug = await resolveWorkspace(abs).then(workspaceSlug, () => slugFromFolder(path.basename(abs)));
206
243
  const rec = {
207
244
  root: abs,
245
+ slug,
208
246
  firstSeen: prev?.firstSeen || now,
209
247
  lastStarted: now,
210
248
  startCount: (prev?.startCount ?? 0) + 1,
@@ -212,7 +250,7 @@ export async function registerWorkspace(root) {
212
250
  ...(prev?.channels ? { channels: prev.channels } : {}),
213
251
  ...(prev?.projects ? { projects: prev.projects } : {}),
214
252
  };
215
- await fs.writeFile(recordFile(abs), JSON.stringify(rec, null, 2));
253
+ await fs.writeFile(file, JSON.stringify(rec, null, 2));
216
254
  }
217
255
  catch { } // best-effort, like the instance file
218
256
  }
@@ -241,17 +279,17 @@ async function syncWorkspaceList(root, key, list) {
241
279
  export const syncWorkspaceAgents = (root, agents) => syncWorkspaceList(root, "agents", agents);
242
280
  export const syncWorkspaceChannels = (root, channels) => syncWorkspaceList(root, "channels", channels);
243
281
  export const syncWorkspaceProjects = (root, projects) => syncWorkspaceList(root, "projects", projects);
244
- /** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
245
- export function markWorkspaceStopped() {
246
- if (!selfRoot)
247
- return;
248
- try {
249
- const file = recordFile(selfRoot);
250
- const rec = JSON.parse(fsSync.readFileSync(file, "utf8"));
251
- rec.lastStopped = new Date().toISOString();
252
- fsSync.writeFileSync(file, JSON.stringify(rec, null, 2));
282
+ /** Stamp lastStopped on a clean stop — every workspace this process serves when no root is given. Sync so exit handlers can call it. */
283
+ export function markWorkspaceStopped(root) {
284
+ for (const r of root ? [absolutePath(root)] : [...own.keys()]) {
285
+ try {
286
+ const file = recordFile(r);
287
+ const rec = JSON.parse(fsSync.readFileSync(file, "utf8"));
288
+ rec.lastStopped = new Date().toISOString();
289
+ fsSync.writeFileSync(file, JSON.stringify(rec, null, 2));
290
+ }
291
+ catch { }
253
292
  }
254
- catch { }
255
293
  }
256
294
  /** All known workspaces, most recently started first. */
257
295
  export async function listWorkspaceRecords() {
@@ -268,6 +306,13 @@ export async function listWorkspaceRecords() {
268
306
  .filter((r) => r != null)
269
307
  .sort((a, b) => b.lastStarted.localeCompare(a.lastStarted));
270
308
  }
309
+ /** A record's slug: as registered, or (records from before 0.66) its folder name made into one. */
310
+ export const recordSlug = (r) => r.slug ?? slugFromFolder(path.basename(r.root));
311
+ /** Other registered workspaces with this slug — two workspaces cannot share an address. */
312
+ export async function slugClashes(slug, root) {
313
+ const abs = absolutePath(root);
314
+ return (await listWorkspaceRecords()).filter((r) => r.root !== abs && recordSlug(r) === slug);
315
+ }
271
316
  /** Drop a workspace record (the root itself is untouched) — from the legacy directory too, see migrateRegistry. */
272
317
  export async function forgetWorkspace(root) {
273
318
  await migrateRegistry();
@@ -310,7 +355,8 @@ async function describeRoot(root) {
310
355
  return fallback;
311
356
  }
312
357
  }
313
- let wsCache = null;
358
+ /** Per asking workspace: each leaves itself out of its list. */
359
+ const wsCache = new Map();
314
360
  /**
315
361
  * The switcher's view: every known project joined with the live instances,
316
362
  * plus running instances with no project record yet. The calling instance
@@ -318,13 +364,15 @@ let wsCache = null;
318
364
  * discoverInstances — /api/meta is polled and this stats every root.
319
365
  */
320
366
  export async function discoverWorkspaces() {
321
- if (wsCache && Date.now() - wsCache.at < 5_000)
322
- return wsCache.entries;
367
+ const me = currentRoot() ?? "";
368
+ const hit = wsCache.get(me);
369
+ if (hit && Date.now() - hit.at < 5_000)
370
+ return hit.entries;
323
371
  const [projects, live] = await Promise.all([listWorkspaceRecords(), discoverInstances()]);
324
372
  const liveByRoot = new Map(live.filter((i) => i.root).map((i) => [i.root, i]));
325
373
  const consumed = new Set();
326
374
  const entries = (await Promise.all(projects
327
- .filter((p) => p.root !== selfRoot)
375
+ .filter((p) => p.root !== me)
328
376
  .map(async (p) => {
329
377
  const base = { root: p.root, rootLabel: tildify(p.root), lastStarted: p.lastStarted, lastStopped: p.lastStopped };
330
378
  let running = liveByRoot.get(p.root);
@@ -371,7 +419,7 @@ export async function discoverWorkspaces() {
371
419
  return a.live ? -1 : 1;
372
420
  return (b.lastStarted ?? "").localeCompare(a.lastStarted ?? "") || a.name.localeCompare(b.name);
373
421
  });
374
- wsCache = { at: Date.now(), entries };
422
+ wsCache.set(me, { at: Date.now(), entries });
375
423
  return entries;
376
424
  }
377
425
  const countDirs = async (dir, marker) => {
@@ -403,25 +451,27 @@ const countEntries = async (dir) => {
403
451
  export async function listWorkspacesDetailed() {
404
452
  const others = await discoverWorkspaces();
405
453
  const all = [...others];
406
- if (selfRoot && selfPort) {
407
- const d = await describeRoot(selfRoot);
408
- const rec = await readRecord(recordFile(selfRoot));
454
+ const me = currentRoot();
455
+ const mine = me ? own.get(me) : undefined;
456
+ if (me && mine) {
457
+ const d = await describeRoot(me);
458
+ const rec = await readRecord(recordFile(me));
409
459
  all.unshift({
410
460
  name: d.name,
411
461
  icon: d.icon,
412
462
  color: d.color,
413
463
  named: d.named,
414
- url: `http://localhost:${selfPort}`,
464
+ url: mine.url,
415
465
  live: true,
416
- root: selfRoot,
417
- rootLabel: tildify(selfRoot),
466
+ root: me,
467
+ rootLabel: tildify(me),
418
468
  exists: true,
419
469
  lastStarted: rec?.lastStarted,
420
470
  lastStopped: rec?.lastStopped,
421
471
  });
422
472
  }
423
473
  return Promise.all(all.map(async (e) => {
424
- const current = !!selfRoot && e.root === selfRoot;
474
+ const current = !!me && e.root === me;
425
475
  const base = { ...e, current, state: e.live ? "running" : "stopped" };
426
476
  if (!e.root)
427
477
  return base;
@@ -476,15 +526,25 @@ export async function startWorkspace(root) {
476
526
  if (!exists)
477
527
  return { ok: false, error: `not a project directory (missing or moved): ${abs}` };
478
528
  const url = `http://localhost:${port}`;
479
- if (abs === selfRoot)
529
+ if (abs === currentRoot())
480
530
  return { ok: false, url, live: true, error: "that is this workspace" };
481
531
  // Already running, or the port is taken by another kraftwerk?
482
- cache = wsCache = null;
532
+ cache = null;
533
+ wsCache.clear();
483
534
  const live = await discoverInstances();
484
535
  const byRoot = live.find((i) => i.root === abs);
485
536
  if (byRoot)
486
537
  return { ok: true, url: byRoot.url, live: true };
487
- if (port === selfPort)
538
+ // The machine runs a daemon: it serves this workspace next to the others.
539
+ const daemon = await findDaemon();
540
+ if (daemon) {
541
+ const r = await daemonCall(daemon.port, "open", abs).catch((err) => ({ ok: false, status: 0, data: { error: err.message } }));
542
+ cache = null;
543
+ wsCache.clear();
544
+ return r.ok ? { ok: true, url: r.data.url, live: true, pid: daemon.pid } : { ok: false, error: r.data.error ?? `the daemon refused (${r.status})` };
545
+ }
546
+ const mine = own.get(currentRoot() ?? "");
547
+ if (mine && port === mine.port)
488
548
  return { ok: false, url, error: `port ${port} is used by this workspace` };
489
549
  const byPort = live.find((i) => i.url === url);
490
550
  if (byPort)
@@ -526,7 +586,8 @@ export async function startWorkspace(root) {
526
586
  if (exited)
527
587
  return { ok: false, url, pid: child.pid, log, error: `kraftwerk ui ${exited}` };
528
588
  if (await probe(port)) {
529
- cache = wsCache = null;
589
+ cache = null;
590
+ wsCache.clear();
530
591
  return { ok: true, url, live: true, pid: child.pid, log };
531
592
  }
532
593
  }
@@ -534,20 +595,30 @@ export async function startWorkspace(root) {
534
595
  }
535
596
  /* ---------- stop a project ---------- */
536
597
  /**
537
- * Stop a running workspace: SIGTERM to its server process, which
538
- * unregisters, stamps lastStopped and exits; the `kraftwerk ui` supervisor
539
- * follows. Matched by root, or by url for instances from older versions
540
- * that registered no root. Waits up to ~5s for the port to go quiet.
598
+ * Stop a running workspace. A daemon's workspace is closed there — the
599
+ * daemon and every other workspace in it keep running. A standalone one
600
+ * gets SIGTERM to its server process, which unregisters, stamps
601
+ * lastStopped and exits; the `kraftwerk ui` supervisor follows. Matched by
602
+ * root, or by url for instances from older versions that registered no
603
+ * root. Waits up to ~5s for it to go quiet.
541
604
  */
542
605
  export async function stopWorkspace(target) {
543
- if (target.root && absolutePath(target.root) === selfRoot) {
606
+ if (target.root && absolutePath(target.root) === currentRoot()) {
544
607
  return { ok: false, error: "that is this workspace — stop it from its own terminal or pid" };
545
608
  }
546
- cache = wsCache = null;
609
+ cache = null;
610
+ wsCache.clear();
547
611
  const live = await discoverInstances();
548
612
  const inst = live.find((i) => (target.root && i.root === absolutePath(target.root)) || (target.url && i.url === target.url));
549
613
  if (!inst)
550
614
  return { ok: false, error: "not running" };
615
+ if (inst.hub && inst.root) {
616
+ const port = Number(new URL(inst.url).port) || DAEMON_PORT;
617
+ const r = await daemonCall(port, "close", inst.root).catch((err) => ({ ok: false, status: 0, data: { error: err.message } }));
618
+ cache = null;
619
+ wsCache.clear();
620
+ return r.ok ? { ok: true } : { ok: false, error: r.data.error ?? `the daemon refused (${r.status})` };
621
+ }
551
622
  try {
552
623
  process.kill(inst.pid, "SIGTERM");
553
624
  }
@@ -559,7 +630,8 @@ export async function stopWorkspace(target) {
559
630
  while (Date.now() < deadline) {
560
631
  await new Promise((r) => setTimeout(r, 250));
561
632
  if (!(await probe(port))) {
562
- cache = wsCache = null;
633
+ cache = null;
634
+ wsCache.clear();
563
635
  return { ok: true };
564
636
  }
565
637
  }
@@ -76,4 +76,10 @@ export declare function runRoutineNow(slug: string, id: string): Promise<{
76
76
  }>;
77
77
  /** Start the current workspace's scheduler; safe to call more than once. Ticks run in that workspace. */
78
78
  export declare function startRoutineScheduler(): void;
79
+ /** Stop the current workspace's scheduler (the workspace closes): no routine of it fires any more. */
80
+ export declare function stopRoutineScheduler(): void;
81
+ /** Whether the current workspace's scheduler runs (tests, the hub's view). */
82
+ export declare const routineSchedulerRunning: () => boolean;
83
+ /** The current workspace's scheduler timer, or null — for tests that check it really stops (a cleared slot would read null either way). */
84
+ export declare const routineSchedulerTimer: () => ReturnType<typeof setInterval> | null;
79
85
  export {};
@@ -305,3 +305,14 @@ export function startRoutineScheduler() {
305
305
  s.timer = setInterval(() => void tick().catch(() => { }), 20_000);
306
306
  s.timer.unref?.();
307
307
  }
308
+ /** Stop the current workspace's scheduler (the workspace closes): no routine of it fires any more. */
309
+ export function stopRoutineScheduler() {
310
+ const s = scheduler();
311
+ if (s.timer)
312
+ clearInterval(s.timer);
313
+ s.timer = null;
314
+ }
315
+ /** Whether the current workspace's scheduler runs (tests, the hub's view). */
316
+ export const routineSchedulerRunning = () => scheduler().timer !== null;
317
+ /** The current workspace's scheduler timer, or null — for tests that check it really stops (a cleared slot would read null either way). */
318
+ export const routineSchedulerTimer = () => scheduler().timer;
@@ -2,10 +2,11 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { workspaceRoot } from "./context.js";
5
- import { perWorkspace } from "./workspace.js";
5
+ import { perWorkspace, workspaceOpen } from "./workspace.js";
6
6
  import { RUN_ID_RE, getRun, safeRunDir } from "./runs.js";
7
7
  import { pushNotification } from "./notifications.js";
8
8
  import { newRunId } from "../workflow.js";
9
+ import { workspaceEnv } from "./env.js";
9
10
  /**
10
11
  * Workflow trigger for the web UI. The inspector stays decoupled from the
11
12
  * framework runtime: it shells out to `npx kraftwerk run` in the consumer
@@ -54,7 +55,7 @@ export function triggerRun(opts) {
54
55
  cwd: workspaceRoot(),
55
56
  detached: true,
56
57
  stdio: ["ignore", log, log],
57
- env: { ...process.env, FORCE_COLOR: "0" },
58
+ env: { ...workspaceEnv(), FORCE_COLOR: "0" },
58
59
  });
59
60
  child.unref();
60
61
  closeSync(log);
@@ -66,13 +67,16 @@ export function triggerRun(opts) {
66
67
  // framework wrote a trace (missing env var, npx failure) shows as failed
67
68
  // instead of running until the stale timeout.
68
69
  child.on("exit", (code, signal) => {
69
- launchers().delete(runId);
70
70
  try {
71
71
  writeFileSync(path.join(runDir, "trigger.json"), JSON.stringify({ exitCode: code ?? 1, signal, finishedAt: new Date().toISOString() }, null, 2) + "\n");
72
72
  }
73
73
  catch {
74
74
  /* run dir removed meanwhile */
75
75
  }
76
+ // A run outlives its workspace closing (it is detached): the file above is all it leaves then.
77
+ if (!workspaceOpen())
78
+ return;
79
+ launchers().delete(runId);
76
80
  void getRun(runId)
77
81
  .then((run) => {
78
82
  const ok = run?.status === "ok";
@@ -140,3 +140,5 @@ export declare function startDev(slug: string): Promise<VibeableStatus>;
140
140
  export declare function stopDev(slug: string): Promise<VibeableStatus>;
141
141
  /** Server shutdown: no dev server may outlive the inspector. */
142
142
  export declare function disposeAllDevs(): void;
143
+ /** The current workspace's dev servers and file watchers end (the workspace closes). */
144
+ export declare function disposeWorkspaceDevs(): void;