@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
@@ -1,6 +1,7 @@
1
1
  import { subscribeChat } from "../../core/chat/sessions.js";
2
2
  import { watch } from "../live.js";
3
3
  import { resolveVibeable, subscribeVibeable } from "../../core/vibeables.js";
4
+ import { MACHINE_ROUTES, runInHub } from "../hub-context.js";
4
5
  import { apiRouter, PROTOCOL_VERSION, routes } from "./index.js";
5
6
  /**
6
7
  * The control plane over one WebSocket (`/api/ws`): every JSON route by
@@ -23,7 +24,22 @@ import { apiRouter, PROTOCOL_VERSION, routes } from "./index.js";
23
24
  */
24
25
  const DEFAULT_INTERVAL = 5000;
25
26
  const GET_ROUTES = new Set(routes.filter((r) => r.method === "GET" && !r.raw).map((r) => r.name));
26
- export function serveSocket(conn, workspace, version) {
27
+ /** A device's standing is re-checked this often while its socket is open (and before each of its messages). */
28
+ const RECHECK_MS = 5_000;
29
+ /**
30
+ * `recheck` re-derives the connection's trust (a device's token may have
31
+ * been revoked since it connected): a device whose answer is no longer
32
+ * `device` is disconnected at once (close code 4401).
33
+ */
34
+ /**
35
+ * `workspaceOf` finds the workspace a message is for: its `ws` (a slug), or
36
+ * the connection's own (the one the socket was opened for) when it names
37
+ * none. A daemon's socket opened at its root has no workspace of its own:
38
+ * there, a message without `ws` reaches only the machine's routes (hub,
39
+ * devices, protocol).
40
+ */
41
+ export function serveSocket(conn, workspaceOf, version, trust, recheck, hub) {
42
+ const trustKey = trust.kind === "device" ? `device:${trust.device.id}` : trust.kind;
27
43
  const stops = new Map();
28
44
  const send = (msg) => conn.send(JSON.stringify(msg));
29
45
  const stop = (key) => {
@@ -64,9 +80,11 @@ export function serveSocket(conn, workspace, version) {
64
80
  return unsubscribe();
65
81
  stops.set(key, unsubscribe);
66
82
  }
83
+ /** A refusal answered the way the message expects one (its id, watch or sub key). */
84
+ const refuse = (msg, status, error) => send({ id: msg.id, ...(typeof msg.watch === "string" ? { watch: msg.watch } : {}), ...(typeof msg.sub === "string" ? { sub: msg.sub } : {}), status, data: { error }, error });
67
85
  async function handle(msg) {
68
86
  if (typeof msg.call === "string") {
69
- const r = await apiRouter.invoke(msg.call, objectOf(msg.input));
87
+ const r = await apiRouter.invoke(msg.call, objectOf(msg.input), trust);
70
88
  return send({ id: msg.id, status: r.status, data: r.data });
71
89
  }
72
90
  if (typeof msg.watch === "string") {
@@ -74,11 +92,16 @@ export function serveSocket(conn, workspace, version) {
74
92
  const name = String(msg.name ?? "");
75
93
  if (!GET_ROUTES.has(name))
76
94
  return send({ watch: key, error: `${name} cannot be watched` });
95
+ const refused = apiRouter.allowed(name, trust);
96
+ if (refused)
97
+ return send({ watch: key, error: refused.error });
77
98
  const input = objectOf(msg.input);
78
99
  const interval = typeof msg.interval === "number" && msg.interval > 0 ? msg.interval : DEFAULT_INTERVAL;
79
100
  stop(key);
80
- stops.set(key, watch(`${name} ${JSON.stringify(input)}`, async () => {
81
- const r = await apiRouter.invoke(name, input);
101
+ stops.set(key, watch(
102
+ // Shared by callers of the same standing only: a result may depend on who asks (devices.self).
103
+ `${trustKey} ${name} ${JSON.stringify(input)}`, async () => {
104
+ const r = await apiRouter.invoke(name, input, trust);
82
105
  return { ok: r.status < 300, data: r.data };
83
106
  }, interval, (data) => send({ watch: key, data })));
84
107
  return;
@@ -95,6 +118,18 @@ export function serveSocket(conn, workspace, version) {
95
118
  return stop(msg.unsub);
96
119
  send({ id: msg.id, error: "unknown message" });
97
120
  }
121
+ /** Still the device that connected? Otherwise unpaired: drop the connection. */
122
+ const stillPaired = async () => {
123
+ if (trust.kind !== "device")
124
+ return true;
125
+ const now = await recheck().catch(() => ({ kind: "none" }));
126
+ if (now.kind === "device" && now.device.id === trust.device.id)
127
+ return true;
128
+ conn.close(4401, "unpaired");
129
+ return false;
130
+ };
131
+ const watchdog = trust.kind === "device" ? setInterval(() => void stillPaired(), RECHECK_MS) : undefined;
132
+ watchdog?.unref?.();
98
133
  conn.onMessage((text) => {
99
134
  let msg;
100
135
  try {
@@ -103,12 +138,28 @@ export function serveSocket(conn, workspace, version) {
103
138
  catch {
104
139
  return send({ error: "invalid JSON" });
105
140
  }
106
- // Every message runs in the connection's workspace, whatever started the read.
107
- void workspace.run(() => handle(msg)).catch((err) => send({ id: msg.id, error: err.message }));
141
+ // Every message runs in the connection's workspace, whatever started the read; a revoked device's message runs nowhere.
142
+ const slug = typeof msg.ws === "string" ? msg.ws : undefined;
143
+ const ws = workspaceOf(slug);
144
+ if (msg.ws !== undefined && !ws)
145
+ return refuse(msg, 404, `no workspace "${String(msg.ws)}" here`);
146
+ if (!ws && !(typeof msg.call === "string" && MACHINE_ROUTES.test(msg.call)))
147
+ return refuse(msg, 404, "name a workspace: `ws` on the message");
148
+ const run = (fn) => runInHub(hub, () => (ws ? ws.run(fn) : fn()));
149
+ void run(async () => {
150
+ if (await stillPaired())
151
+ await handle(msg);
152
+ }).catch((err) => send({ id: msg.id, error: err.message }));
108
153
  });
109
154
  conn.onClose(() => {
110
- for (const fn of stops.values())
111
- fn();
155
+ clearInterval(watchdog);
156
+ // A subscription of a workspace that closed meanwhile has nothing left to let go of.
157
+ for (const fn of stops.values()) {
158
+ try {
159
+ fn();
160
+ }
161
+ catch { }
162
+ }
112
163
  stops.clear();
113
164
  });
114
165
  send({ hello: { protocol: PROTOCOL_VERSION, version } });
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
  /** The workspace itself: identity and features, settings, the other workspaces, updates. */
3
3
  export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
4
4
  workspaceName: string;
5
+ workspaceSlug: string;
5
6
  workspaceIcon: string;
6
7
  workspaceColor: string;
7
8
  workspaceNamed: boolean;
@@ -21,6 +22,7 @@ export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
21
22
  repos: boolean;
22
23
  vibeables: boolean;
23
24
  projects: boolean;
25
+ /** @deprecated Always "": the Cloudflare Tunnel that set it was removed in 0.65. Removed with protocol 2. */
24
26
  publicUrl: string;
25
27
  cloud: import("../cloud.js").CloudStatus;
26
28
  switcher: import("../../config.js").SwitcherEntry[];
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import * as z from "zod";
3
- import { publicUrlFor, resolveWorkspace } from "../../config.js";
3
+ import { resolveWorkspace, slugFromFolder, workspaceSlug } from "../../config.js";
4
4
  import { disposeAllBackends } from "../../core/chat/sessions.js";
5
5
  import { cloudStatus } from "../cloud.js";
6
6
  import { workspaceRoot } from "../../core/context.js";
@@ -56,6 +56,7 @@ export const workspaceRoutes = [
56
56
  ];
57
57
  const identity = {
58
58
  workspaceName: name,
59
+ workspaceSlug: ws ? workspaceSlug(ws) : slugFromFolder(path.basename(root)),
59
60
  workspaceIcon: ws?.config.icon ?? "",
60
61
  workspaceColor: ws?.config.color ?? "",
61
62
  workspaceNamed: !!ws?.config.name,
@@ -80,7 +81,8 @@ export const workspaceRoutes = [
80
81
  repos: (ws?.config.repos && ws.config.repos.enabled !== false) === true,
81
82
  vibeables: (ws?.config.vibeables && ws.config.vibeables.enabled !== false) === true,
82
83
  projects: (ws?.config.projects && ws.config.projects.enabled !== false) === true,
83
- publicUrl: (ws && publicUrlFor(ws)) ?? "",
84
+ /** @deprecated Always "": the Cloudflare Tunnel that set it was removed in 0.65. Removed with protocol 2. */
85
+ publicUrl: "",
84
86
  cloud: cloudStatus(),
85
87
  switcher,
86
88
  };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Interface to bind. Loopback by default: the UI's chat runs coding agents
3
+ * against the repo, so it does not appear on the LAN because someone started
4
+ * it on a laptop in a café. Bound to the network (`kraftwerk ui --lan`, or
5
+ * KRAFTWERK_UI_HOST), only paired devices get in (see trust.ts). Inside a container the
6
+ * default flips to all interfaces — loopback there would make the published
7
+ * port unreachable while the in-container health check keeps passing, and
8
+ * the port mapping (compose publishes to 127.0.0.1) is the boundary anyway.
9
+ * KRAFTWERK_UI_HOST overrides either way.
10
+ */
11
+ export declare const IN_CONTAINER: boolean;
12
+ export declare const INSPECTOR_HOST: string;
13
+ export declare const LOOPBACK_NAMES: Set<string>;
14
+ export declare const LOOPBACK_BIND: boolean;
15
+ /** Where a device on the network reaches this server: one http URL per non-internal IPv4 address. Empty while bound to loopback. */
16
+ export declare function networkUrls(port: number): string[];
@@ -0,0 +1,25 @@
1
+ import { existsSync } from "node:fs";
2
+ import os from "node:os";
3
+ /**
4
+ * Interface to bind. Loopback by default: the UI's chat runs coding agents
5
+ * against the repo, so it does not appear on the LAN because someone started
6
+ * it on a laptop in a café. Bound to the network (`kraftwerk ui --lan`, or
7
+ * KRAFTWERK_UI_HOST), only paired devices get in (see trust.ts). Inside a container the
8
+ * default flips to all interfaces — loopback there would make the published
9
+ * port unreachable while the in-container health check keeps passing, and
10
+ * the port mapping (compose publishes to 127.0.0.1) is the boundary anyway.
11
+ * KRAFTWERK_UI_HOST overrides either way.
12
+ */
13
+ export const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv");
14
+ export const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
15
+ export const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
16
+ export const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
17
+ /** Where a device on the network reaches this server: one http URL per non-internal IPv4 address. Empty while bound to loopback. */
18
+ export function networkUrls(port) {
19
+ if (LOOPBACK_BIND)
20
+ return [];
21
+ return Object.values(os.networkInterfaces())
22
+ .flat()
23
+ .filter((a) => !!a && a.family === "IPv4" && !a.internal)
24
+ .map((a) => `http://${a.address}:${port}`);
25
+ }
@@ -38,7 +38,7 @@ export declare const cloudStatus: () => CloudStatus;
38
38
  * registration attempt (success or not); the heartbeat loop runs on its
39
39
  * own afterwards.
40
40
  */
41
- export declare function startCloudSync(port: number, version: string): Promise<void>;
41
+ export declare function startCloudSync(url: string, version: string): Promise<void>;
42
42
  /** Stop the heartbeat loop (server close, tests). */
43
43
  export declare function stopCloudSync(): void;
44
44
  /**
@@ -47,3 +47,7 @@ export declare function stopCloudSync(): void;
47
47
  * this, and a slow cloud must not hold the process.
48
48
  */
49
49
  export declare function cloudGoodbye(): Promise<void>;
50
+ /** Every served workspace: stop its heartbeat (the server closes). */
51
+ export declare function stopAllCloudSync(): void;
52
+ /** Every served workspace says goodbye, in parallel (still bounded per workspace). */
53
+ export declare function cloudGoodbyeAll(): Promise<void>;
@@ -2,23 +2,28 @@ import { createHash } from "node:crypto";
2
2
  import { promises as fs } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { cloudFor, publicUrlFor, resolveWorkspace } from "../config.js";
5
+ import { cloudFor, resolveWorkspace } from "../config.js";
6
6
  import { workspaceRoot } from "../core/context.js";
7
+ import { perWorkspace, workspaceOpen } from "../core/workspace.js";
7
8
  import { listAgents, toSummary } from "../core/agents.js";
8
9
  import { listChannels, toChannelSummary } from "../core/channels.js";
9
10
  import { tildify } from "../core/instances.js";
10
11
  const claimUrlFor = (url, code) => (code ? `${url}/claim/${encodeURIComponent(code)}` : null);
11
12
  const REQUEST_TIMEOUT_MS = 8_000;
12
13
  const GOODBYE_TIMEOUT_MS = 800;
13
- let status = { url: "", state: "off" };
14
- let identity = null;
15
- let timer = null;
16
- let stopped = false;
17
- let lastRoster = "";
18
- let selfPort = 0;
19
- let selfVersion = "";
20
- const startedAt = new Date().toISOString();
21
- export const cloudStatus = () => status;
14
+ /** The client's state, one per workspace: each served workspace registers and heartbeats on its own. */
15
+ const client = perWorkspace(() => ({
16
+ status: { url: "", state: "off" },
17
+ identity: null,
18
+ timer: null,
19
+ stopped: false,
20
+ lastRoster: "",
21
+ /** Where this workspace is reached on this machine, as the cloud lists it. */
22
+ selfUrl: "",
23
+ selfVersion: "",
24
+ startedAt: new Date().toISOString(),
25
+ }));
26
+ export const cloudStatus = () => client().status;
22
27
  const cloudDir = () => path.join(os.homedir(), ".kraftwerk", "cloud");
23
28
  const identityFile = (url, root) => path.join(cloudDir(), `${createHash("sha1").update(`${url}\n${root}`).digest("hex").slice(0, 16)}.json`);
24
29
  async function readIdentity(file) {
@@ -46,9 +51,10 @@ async function roster() {
46
51
  return { agents, channels };
47
52
  }
48
53
  async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
54
+ const c = client();
49
55
  const r = await fetch(url, {
50
56
  method: "POST",
51
- headers: { "content-type": "application/json", "user-agent": `kraftwerk/${selfVersion}` },
57
+ headers: { "content-type": "application/json", "user-agent": `kraftwerk/${c.selfVersion}` },
52
58
  body: JSON.stringify(body),
53
59
  signal: AbortSignal.timeout(timeout),
54
60
  });
@@ -66,16 +72,17 @@ async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
66
72
  }
67
73
  /** Register (or re-register) this instance. Returns false when the cloud refused or is unreachable. */
68
74
  async function register() {
75
+ const c = client();
69
76
  const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
70
77
  const cloud = project ? cloudFor(project) : undefined;
71
78
  if (!project || !cloud)
72
79
  return false;
73
80
  const file = identityFile(cloud.url, project.root);
74
- identity ??= await readIdentity(file);
81
+ c.identity ??= await readIdentity(file);
75
82
  const { agents, channels } = await roster();
76
- lastRoster = JSON.stringify({ agents, channels });
83
+ c.lastRoster = JSON.stringify({ agents, channels });
77
84
  const body = {
78
- ...(identity ?? {}),
85
+ ...(c.identity ?? {}),
79
86
  name: project.config.name ?? path.basename(project.root),
80
87
  icon: project.config.icon ?? "",
81
88
  color: project.config.color ?? "",
@@ -83,105 +90,121 @@ async function register() {
83
90
  rootLabel: tildify(project.root),
84
91
  hostname: os.hostname(),
85
92
  platform: `${process.platform}-${process.arch}`,
86
- version: selfVersion,
87
- url: `http://localhost:${selfPort}`,
88
- publicUrl: publicUrlFor(project) ?? "",
93
+ version: c.selfVersion,
94
+ url: c.selfUrl,
95
+ // Part of the cloud's register contract; always "" since the Cloudflare Tunnel was removed (0.65).
96
+ publicUrl: "",
89
97
  agents,
90
98
  channels,
91
99
  interval: cloud.interval,
92
- startedAt,
100
+ startedAt: c.startedAt,
93
101
  ...(cloud.token ? { accountToken: cloud.token } : {}),
94
102
  };
95
103
  try {
96
104
  const r = await post(`${cloud.url}/api/instances/register`, body);
97
- if (!identity || identity.id !== r.id || identity.secret !== r.secret) {
98
- identity = { id: r.id, secret: r.secret };
99
- await writeIdentity(file, cloud.url, project.root, identity);
105
+ if (!c.identity || c.identity.id !== r.id || c.identity.secret !== r.secret) {
106
+ c.identity = { id: r.id, secret: r.secret };
107
+ await writeIdentity(file, cloud.url, project.root, c.identity);
100
108
  }
101
- status = {
109
+ c.status = {
102
110
  url: cloud.url, state: "connected", id: r.id, account: r.account, interval: r.interval, lastSeen: new Date().toISOString(),
103
111
  claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode),
104
112
  };
105
113
  return true;
106
114
  }
107
115
  catch (err) {
108
- status = { url: cloud.url, state: "error", error: err.message, ...(identity ? { id: identity.id } : {}) };
116
+ c.status = { url: cloud.url, state: "error", error: err.message, ...(c.identity ? { id: c.identity.id } : {}) };
109
117
  return false;
110
118
  }
111
119
  }
112
120
  async function heartbeat() {
113
- if (!identity || status.state === "off")
121
+ const c = client();
122
+ if (!c.identity || c.status.state === "off")
114
123
  return;
115
124
  const { agents, channels } = await roster();
116
125
  const rosterNow = JSON.stringify({ agents, channels });
117
126
  const body = {
118
- ...identity,
119
- version: selfVersion,
120
- ...(rosterNow !== lastRoster ? { agents, channels } : {}),
127
+ ...c.identity,
128
+ version: c.selfVersion,
129
+ ...(rosterNow !== c.lastRoster ? { agents, channels } : {}),
121
130
  };
122
131
  try {
123
- const r = await post(`${status.url}/api/instances/heartbeat`, body);
124
- lastRoster = rosterNow;
125
- status = {
126
- ...status, state: "connected", error: undefined, interval: r.interval, lastSeen: new Date().toISOString(),
132
+ const r = await post(`${c.status.url}/api/instances/heartbeat`, body);
133
+ c.lastRoster = rosterNow;
134
+ c.status = {
135
+ ...c.status, state: "connected", error: undefined, interval: r.interval, lastSeen: new Date().toISOString(),
127
136
  ...(r.account !== undefined ? { account: r.account } : {}),
128
- ...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(status.url, r.claimCode) } : {}),
137
+ ...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(c.status.url, r.claimCode) } : {}),
129
138
  };
130
139
  }
131
140
  catch (err) {
132
141
  const message = err.message;
133
- status = { ...status, state: "error", error: message };
142
+ c.status = { ...c.status, state: "error", error: message };
134
143
  // An unknown identity (the cloud lost it) is fixed by registering again, which hands out a new one.
135
144
  if (/unknown instance|wrong instance secret|id and secret required/.test(message)) {
136
- identity = null;
137
- await register();
145
+ c.identity = null;
146
+ // Not after a goodbye: a stopped client registers nothing again.
147
+ if (!c.stopped && workspaceOpen())
148
+ await register();
138
149
  }
139
150
  }
140
151
  }
141
152
  /** Schedule the next tick after the effective interval; a failed cloud is retried at the same cadence. */
142
153
  function schedule() {
143
- if (stopped)
154
+ const c = client();
155
+ if (c.stopped)
144
156
  return;
145
- const seconds = status.interval && status.interval > 0 ? status.interval : 60;
146
- timer = setTimeout(async () => {
147
- if (status.state === "connected" || status.state === "error") {
148
- if (identity)
157
+ const seconds = c.status.interval && c.status.interval > 0 ? c.status.interval : 60;
158
+ c.timer = setTimeout(async () => {
159
+ // Stopped (or the workspace closed) while this timer waited, or while the call below ran: no next round.
160
+ if (c.stopped || !workspaceOpen())
161
+ return;
162
+ if (c.status.state === "connected" || c.status.state === "error") {
163
+ if (c.identity)
149
164
  await heartbeat();
150
165
  else
151
166
  await register();
152
167
  }
153
- schedule();
168
+ if (!c.stopped && workspaceOpen())
169
+ schedule();
154
170
  }, seconds * 1000);
155
- timer.unref();
171
+ c.timer.unref();
156
172
  }
157
173
  /**
158
174
  * Start the client once the inspector listens. Resolves after the first
159
175
  * registration attempt (success or not); the heartbeat loop runs on its
160
176
  * own afterwards.
161
177
  */
162
- export async function startCloudSync(port, version) {
163
- selfPort = port;
164
- selfVersion = version;
165
- stopped = false;
178
+ export async function startCloudSync(url, version) {
179
+ const c = client();
180
+ c.selfUrl = url;
181
+ c.selfVersion = version;
182
+ c.stopped = false;
166
183
  const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
167
184
  const cloud = project ? cloudFor(project) : undefined;
168
185
  if (!cloud) {
169
- status = { url: "", state: "off" };
186
+ c.status = { url: "", state: "off" };
170
187
  return;
171
188
  }
172
- status = { url: cloud.url, state: "connecting", interval: cloud.interval };
189
+ // The workspace may close (or the client stop) while this waits: then it does not register or schedule at all.
190
+ if (c.stopped || !workspaceOpen())
191
+ return;
192
+ c.status = { url: cloud.url, state: "connecting", interval: cloud.interval };
173
193
  await register();
194
+ if (c.stopped || !workspaceOpen())
195
+ return;
174
196
  // Until the cloud answers, retry at the configured cadence; once it does, at what it asked for.
175
- if (status.state !== "connected")
176
- status.interval = cloud.interval;
197
+ if (c.status.state !== "connected")
198
+ c.status.interval = cloud.interval;
177
199
  schedule();
178
200
  }
179
201
  /** Stop the heartbeat loop (server close, tests). */
180
202
  export function stopCloudSync() {
181
- stopped = true;
182
- if (timer)
183
- clearTimeout(timer);
184
- timer = null;
203
+ const c = client();
204
+ c.stopped = true;
205
+ if (c.timer)
206
+ clearTimeout(c.timer);
207
+ c.timer = null;
185
208
  }
186
209
  /**
187
210
  * Tell the cloud we are going away, so the record flips to "stopped" now
@@ -189,11 +212,21 @@ export function stopCloudSync() {
189
212
  * this, and a slow cloud must not hold the process.
190
213
  */
191
214
  export async function cloudGoodbye() {
215
+ const c = client();
192
216
  stopCloudSync();
193
- if (!identity || status.state === "off" || !status.url)
217
+ if (!c.identity || c.status.state === "off" || !c.status.url)
194
218
  return;
195
219
  try {
196
- await post(`${status.url}/api/instances/goodbye`, identity, GOODBYE_TIMEOUT_MS);
220
+ await post(`${c.status.url}/api/instances/goodbye`, c.identity, GOODBYE_TIMEOUT_MS);
197
221
  }
198
222
  catch { }
199
223
  }
224
+ /** Every served workspace: stop its heartbeat (the server closes). */
225
+ export function stopAllCloudSync() {
226
+ for (const [ws] of client.entries())
227
+ ws.run(stopCloudSync);
228
+ }
229
+ /** Every served workspace says goodbye, in parallel (still bounded per workspace). */
230
+ export async function cloudGoodbyeAll() {
231
+ await Promise.all(client.entries().map(([ws]) => ws.run(cloudGoodbye)));
232
+ }
@@ -0,0 +1,7 @@
1
+ import type { Hub } from "./server.js";
2
+ export declare const runInHub: <T>(hub: Hub, fn: () => T) => T;
3
+ export declare function currentHub(): Hub;
4
+ /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
5
+ export declare const MACHINE_PATHS: RegExp;
6
+ /** Route names callable over the socket without a workspace. */
7
+ export declare const MACHINE_ROUTES: RegExp;
@@ -0,0 +1,18 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /**
3
+ * The hub a request runs in, like the workspace (core/workspace.ts): the
4
+ * machine-level routes (hub.*) find theirs here, so several hubs in one
5
+ * process (tests) never act on each other's workspaces.
6
+ */
7
+ const storage = new AsyncLocalStorage();
8
+ export const runInHub = (hub, fn) => storage.run(hub, fn);
9
+ export function currentHub() {
10
+ const hub = storage.getStore();
11
+ if (!hub)
12
+ throw new Error("no hub here");
13
+ return hub;
14
+ }
15
+ /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
16
+ export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|protocol$|ws$)/;
17
+ /** Route names callable over the socket without a workspace. */
18
+ export const MACHINE_ROUTES = /^(hub|devices|protocol)\./;
@@ -37,6 +37,8 @@ interface Watch {
37
37
  * every change, and at least every `interval` ms. Returns the unwatch.
38
38
  */
39
39
  export declare function watch(key: string, evaluate: Watch["evaluate"], interval: number, push: (data: unknown) => void): () => void;
40
+ /** The current workspace closes: its watches stop re-evaluating (their sockets close with it). */
41
+ export declare function closeWatches(): void;
40
42
  /** Something changed: re-evaluate every watch of this workspace soon (debounced). */
41
43
  export declare function touch(): void;
42
44
  export {};
@@ -71,6 +71,15 @@ export function watch(key, evaluate, interval, push) {
71
71
  schedule(current);
72
72
  };
73
73
  }
74
+ /** The current workspace closes: its watches stop re-evaluating (their sockets close with it). */
75
+ export function closeWatches() {
76
+ const s = state();
77
+ for (const w of s.watches.values())
78
+ clearTimeout(w.timer);
79
+ s.watches.clear();
80
+ clearTimeout(s.touchTimer);
81
+ s.touchTimer = undefined;
82
+ }
74
83
  /** Something changed: re-evaluate every watch of this workspace soon (debounced). */
75
84
  export function touch() {
76
85
  // Outside any workspace (a unit test, a CLI command) nobody can be watching.
@@ -108,7 +108,8 @@ export async function serveVibeable(req, res, url) {
108
108
  let st = await fs.stat(abs).catch(() => null);
109
109
  if (st?.isDirectory()) {
110
110
  if (!url.pathname.endsWith("/")) {
111
- res.writeHead(302, { location: `${url.pathname}/${url.search}`, "cache-control": "no-store" });
111
+ // Relative, so it holds under a /w/<slug> prefix too.
112
+ res.writeHead(302, { location: `${url.pathname.split("/").pop()}/${url.search}`, "cache-control": "no-store" });
112
113
  return void res.end();
113
114
  }
114
115
  abs = path.join(abs, "index.html");
@@ -1,4 +1,5 @@
1
1
  import http from "node:http";
2
+ import { Workspace } from "../core/workspace.js";
2
3
  import { RESTART_EXIT_CODE } from "./version.js";
3
4
  export { RESTART_EXIT_CODE };
4
5
  export interface InspectorOptions {
@@ -7,6 +8,62 @@ export interface InspectorOptions {
7
8
  port: number;
8
9
  /** The workspace root (where kraftwerk.yml lives); defaults to the parent of outputDir. */
9
10
  root?: string;
11
+ /**
12
+ * A loopback peer is this machine and needs no token (default true).
13
+ * False makes every request pair first — tests, or a hardened setup
14
+ * behind a local reverse proxy that serves the network.
15
+ */
16
+ trustLoopback?: boolean;
10
17
  }
11
- /** Start the server; resolves once it listens. Runs until the process ends. */
18
+ /** A workspace a hub serves. */
19
+ export interface OpenWorkspace {
20
+ slug: string;
21
+ root: string;
22
+ name: string;
23
+ /** Where it is reached: <slug>.localhost:<port> under a daemon, localhost:<port> standalone. */
24
+ url: string;
25
+ /** Its own port too (kraftwerk.yml `port`), when the hub listens there for it — old links keep working. */
26
+ aliasPort?: number;
27
+ ws: Workspace;
28
+ }
29
+ /**
30
+ * One HTTP server serving workspaces by slug (see target.ts): a daemon
31
+ * (`kraftwerk daemon`) serves every workspace that is open on the machine,
32
+ * a standalone `kraftwerk ui` exactly one — its default, also reached
33
+ * without a slug, as before. Each open workspace runs in its own Workspace
34
+ * (core/workspace.ts) with its own .env, background jobs (routines, git
35
+ * sync, cloud), registry entry and live state; closing one ends all of that
36
+ * and leaves the others running.
37
+ */
38
+ export interface Hub {
39
+ readonly server: http.Server;
40
+ /** The port it listens on. */
41
+ readonly port: number;
42
+ /** Whether it is the machine's daemon (workspaces addressed by slug, none by default). */
43
+ readonly daemon: boolean;
44
+ /** Serve a workspace (idempotent: an open one is returned as it is). */
45
+ open(root: string, opts?: {
46
+ outputDir?: string;
47
+ }): Promise<OpenWorkspace>;
48
+ /** Stop serving one; false when it was not open. */
49
+ close(root: string): Promise<boolean>;
50
+ list(): OpenWorkspace[];
51
+ /** Close every workspace and the server. */
52
+ stop(): Promise<void>;
53
+ }
54
+ export interface HubOptions {
55
+ port: number;
56
+ staticDir: string;
57
+ trustLoopback?: boolean;
58
+ /** The machine's daemon: workspaces get `hub` instance files, their own ports as aliases, and no default. */
59
+ daemon?: boolean;
60
+ /** After a workspace opened or closed (the daemon notes what to reopen after a restart). */
61
+ onChange?: (hub: Hub) => void | Promise<void>;
62
+ }
63
+ export declare function startHub(opts: HubOptions): Promise<Hub>;
64
+ /**
65
+ * A standalone server for one workspace (`kraftwerk ui`, tests): a hub
66
+ * serving it as its default, reached with or without its slug. Resolves
67
+ * once it listens.
68
+ */
12
69
  export declare function startInspector(opts: InspectorOptions): Promise<http.Server>;