@netnodeag/kraftwerk 0.65.0 → 0.67.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 (77) hide show
  1. package/README.md +112 -27
  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/doctor.js +9 -1
  6. package/dist/cli/init.js +1 -0
  7. package/dist/cli/kraftwerk.js +4 -0
  8. package/dist/cli/ui.d.ts +8 -0
  9. package/dist/cli/ui.js +35 -8
  10. package/dist/cli/workspaces.js +5 -3
  11. package/dist/client/index.d.ts +8 -0
  12. package/dist/client/index.js +2 -1
  13. package/dist/client/live.d.ts +1 -2
  14. package/dist/client/live.js +2 -1
  15. package/dist/client/routes.d.ts +3 -0
  16. package/dist/client/routes.js +3 -0
  17. package/dist/config.d.ts +16 -0
  18. package/dist/config.js +23 -1
  19. package/dist/core/chat/pi.js +2 -1
  20. package/dist/core/chat/sessions.d.ts +1 -1
  21. package/dist/core/chat/sessions.js +1 -1
  22. package/dist/core/chat/state.d.ts +2 -0
  23. package/dist/core/chat/state.js +5 -0
  24. package/dist/core/daemon.d.ts +27 -0
  25. package/dist/core/daemon.js +61 -0
  26. package/dist/core/env.d.ts +16 -0
  27. package/dist/core/env.js +45 -0
  28. package/dist/core/git.d.ts +2 -0
  29. package/dist/core/git.js +20 -4
  30. package/dist/core/instances.d.ts +34 -12
  31. package/dist/core/instances.js +142 -66
  32. package/dist/core/routines.d.ts +6 -0
  33. package/dist/core/routines.js +11 -0
  34. package/dist/core/runner.js +7 -3
  35. package/dist/core/search.d.ts +2 -0
  36. package/dist/core/search.js +1 -0
  37. package/dist/core/vibeables.d.ts +2 -0
  38. package/dist/core/vibeables.js +17 -5
  39. package/dist/core/workspace.d.ts +13 -0
  40. package/dist/core/workspace.js +25 -0
  41. package/dist/server/api/hub.d.ts +25 -0
  42. package/dist/server/api/hub.js +32 -0
  43. package/dist/server/api/index.d.ts +35 -3
  44. package/dist/server/api/index.js +2 -0
  45. package/dist/server/api/socket.d.ts +9 -1
  46. package/dist/server/api/socket.js +27 -7
  47. package/dist/server/api/workspace.d.ts +16 -2
  48. package/dist/server/api/workspace.js +44 -4
  49. package/dist/server/cloud.d.ts +5 -1
  50. package/dist/server/cloud.js +86 -54
  51. package/dist/server/hub-context.d.ts +7 -0
  52. package/dist/server/hub-context.js +18 -0
  53. package/dist/server/live.d.ts +2 -0
  54. package/dist/server/live.js +9 -0
  55. package/dist/server/preview.js +2 -1
  56. package/dist/server/server.d.ts +52 -1
  57. package/dist/server/server.js +283 -52
  58. package/dist/server/target.d.ts +20 -0
  59. package/dist/server/target.js +18 -0
  60. package/package.json +1 -1
  61. package/schema/kraftwerk.schema.json +5 -0
  62. package/web/dist/assets/{dist-10uPVFvu.js → dist-BMZBs9W4.js} +1 -1
  63. package/web/dist/assets/{dist-v6-49xpW.js → dist-BkZKygT6.js} +1 -1
  64. package/web/dist/assets/{dist-Bnxg6V5L.js → dist-CZjwFXw-.js} +1 -1
  65. package/web/dist/assets/{dist-DS1R2xKJ.js → dist-D7BYSDIr.js} +1 -1
  66. package/web/dist/assets/dist-DZLlNafq.js +1 -0
  67. package/web/dist/assets/{dist-BCfXUgtf.js → dist-D_nqqdyh.js} +1 -1
  68. package/web/dist/assets/{dist-nA7q2-h7.js → dist-IO8GeoYh.js} +1 -1
  69. package/web/dist/assets/{dist-hevbMnoj.js → dist-LUKHNpC7.js} +1 -1
  70. package/web/dist/assets/{dist-DnMgiPT1.js → dist-Y0KLvK_g.js} +1 -1
  71. package/web/dist/assets/{dist-C-CclD1E.js → dist-aMqhK50K.js} +1 -1
  72. package/web/dist/assets/{dist-CAWmDGJC.js → dist-jyZ9dxfk.js} +1 -1
  73. package/web/dist/assets/{editor-C8yy8YS9.js → editor-DQouCIUq.js} +3 -3
  74. package/web/dist/assets/index-D57IrV_V.js +229 -0
  75. package/web/dist/index.html +1 -1
  76. package/web/dist/assets/dist-yoh47aAL.js +0 -1
  77. package/web/dist/assets/index-DWS1u71Y.js +0 -229
package/README.md CHANGED
@@ -7,16 +7,18 @@ happened to set them up, and every result is checked before anyone relies on
7
7
  it.
8
8
 
9
9
  ```bash
10
- npm install -g @netnodeag/kraftwerk
10
+ npm install -g @netnodeag/kraftwerk # once per machine
11
11
 
12
- cd your-project
13
- kraftwerk init # scaffold the workspace
14
- kraftwerk doctor # check harnesses, docker, workflows, env vars, legacy leftovers
15
- kraftwerk ui # open it at http://localhost:1981
12
+ cd your-project # any folder: a repo, a team folder, an empty one
13
+ kraftwerk init # make it a workspace: kraftwerk.yml + kraftwerk-data/
14
+ kraftwerk doctor # check that the agents (Claude Code, Codex, Pi) are there
15
+ kraftwerk ui # start it, then open http://localhost:1981
16
16
  ```
17
17
 
18
18
  Needs Node 20+ and at least one agent harness (Claude Code, Codex, or Pi).
19
- Details in [Install](#install).
19
+ [Start and use kraftwerk](#start-and-use-kraftwerk) explains the ways to
20
+ run it (one workspace, several at once, from your phone);
21
+ [Install](#install) has the details.
20
22
 
21
23
  **Why teams need it.** AI work today lives in individual silos. Personal
22
24
  prompts, Claude Code sessions, one-off scripts. What one person figures out
@@ -48,6 +50,73 @@ becomes shared practice.
48
50
  This README is the reference for the framework and CLI. For the overview,
49
51
  start at the [project README](https://github.com/NETNODEAG/kraftwerk#readme).
50
52
 
53
+ ## Start and use kraftwerk
54
+
55
+ A **workspace** is a folder with a `kraftwerk.yml`: its agents, knowledge,
56
+ workflows, projects and chats live in it (under `kraftwerk-data/`), and git
57
+ can share it with your team. `kraftwerk init` makes any folder one.
58
+ kraftwerk is then used in the browser, the **inspector**, which a kraftwerk
59
+ process serves from your machine. There are three ways to run that process.
60
+
61
+ **One workspace: `kraftwerk ui`.** In the workspace folder, `kraftwerk ui`
62
+ starts a server for it and prints the address, `http://localhost:1981`
63
+ (`port:` in kraftwerk.yml, or `--port`, sets another). It runs in the
64
+ foreground; Ctrl-C stops it. That is all most people need.
65
+
66
+ **Several workspaces: `kraftwerk daemon`.** With more than one workspace,
67
+ run one kraftwerk for all of them instead of a server and a port each.
68
+ Start the daemon once and leave it running (a terminal tab, or a login
69
+ item):
70
+
71
+ ```bash
72
+ kraftwerk daemon # the machine's kraftwerk, on http://localhost:1980
73
+
74
+ cd ~/work/team-blau && kraftwerk ui # opens this workspace in the daemon:
75
+ # http://team-blau.localhost:1980
76
+ cd ~/work/relaunch && kraftwerk ui # http://relaunch.localhost:1980
77
+ kraftwerk workspaces # every workspace on this machine, running or not
78
+ kraftwerk workspaces stop team-blau # close one; the daemon and the others keep running
79
+ ```
80
+
81
+ Each workspace is addressed by its **slug**: the folder name, or `slug:` in
82
+ kraftwerk.yml (the `name:` is only the title and can change freely).
83
+ Browsers resolve `<slug>.localhost` to your machine by themselves, so there
84
+ is nothing to set up, and each workspace keeps its own browser origin.
85
+ Where a host name per workspace is not possible, `http://localhost:1980/w/<slug>/`
86
+ reaches the same workspace. `http://localhost:1980` lists what is open.
87
+ A workspace with a `port:` of its own is served there too, so old links
88
+ keep working. The daemon remembers what was open and opens it again when it
89
+ starts (after a reboot or an update). Each workspace still runs on its own
90
+ inside it, with its own `.env`, routines, git sync and chats. The
91
+ workspace menu in the header switches between them, and starts a stopped
92
+ one right in the daemon. The Kraftwerk desktop app starts the daemon for
93
+ you when it opens.
94
+ `kraftwerk ui` hands the workspace to the daemon whenever one is running;
95
+ `kraftwerk ui --standalone` starts a server of its own anyway.
96
+
97
+ **From your phone or another computer.** Add `--lan` (`kraftwerk ui --lan`,
98
+ or `kraftwerk daemon --lan`) and kraftwerk also listens on your network.
99
+ Anyone who opens it there gets a pairing screen, nothing else, until you
100
+ pair that device: `kraftwerk devices pair` on this machine prints a
101
+ one-time code. A paired device reaches the daemon's workspaces at
102
+ `/w/<slug>/` on the address it used, and the workspace menu links them
103
+ that way (see [Inspector on your phone](#inspector-on-your-phone-paired-devices)).
104
+
105
+ **What to do first in the inspector.** Talk to **Ralv**, the workspace's
106
+ general chat, in plain words: it knows what is in the workspace and helps
107
+ set up the rest. From there:
108
+
109
+ - **Agents** are persistent coworkers with a role, a memory and routines.
110
+ Open one and chat with it; its sessions continue where they ended.
111
+ - **Projects** gather a goal, a brief and links; every chat in a project
112
+ starts with that context.
113
+ - **Workflows** are repeatable processes with checked results; run one from
114
+ its page or with `kraftwerk run <workflow> "<request>"`.
115
+ - **Knowledge** is the shared, human-verified context agents read.
116
+
117
+ Everything you see in the inspector is files in the workspace folder, so
118
+ the CLI (`kraftwerk --help`) and git work on the same things.
119
+
51
120
  ## Under the hood
52
121
 
53
122
  Deterministic workflow-as-code over headless agent harnesses, in the spirit of
@@ -112,7 +181,7 @@ cd your-project
112
181
  kraftwerk init # scaffold kraftwerk.yml + kraftwerk-data/ (workflow, agent, knowledge)
113
182
  kraftwerk doctor # preflight: harness CLIs, docker, workflows, declared env vars, legacy leftovers
114
183
  kraftwerk run hello "Was ist kraftwerk?"
115
- kraftwerk ui # inspector on http://localhost:1981
184
+ kraftwerk ui # inspector on http://localhost:1981 (see Start and use kraftwerk)
116
185
  ```
117
186
 
118
187
  The same without a global install:
@@ -159,8 +228,10 @@ kraftwerk run tagline "https://..." # run; --yes, --verbose
159
228
  kraftwerk run # interactive: pick workflow, type the request
160
229
  kraftwerk runs # past runs from output/*/trace.jsonl; runs show <id> for detail
161
230
  kraftwerk knowledge # Knowledge: OKF bundles (list/get/put/verify/search/...)
162
- kraftwerk ui # inspector web UI on http://localhost:1981; --port, --output
231
+ kraftwerk ui # start this workspace: http://localhost:1981 (or open it in the daemon); --port, --lan, --standalone
232
+ kraftwerk daemon # one kraftwerk for every open workspace: http://<slug>.localhost:1980; --port, --lan
163
233
  kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
234
+ kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
164
235
  kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
165
236
  kraftwerk trash # what was deleted; trash restore|purge <id>, trash empty
166
237
  kraftwerk journal <agent> ["<entry>"] # an agent's memory across sessions: print it, add a line (--kind)
@@ -182,19 +253,18 @@ The **trash** screen (`#/trash`) or `kraftwerk trash restore <id>` puts an entry
182
253
  back where it came from. Deleting it there, or `kraftwerk trash empty`, makes it
183
254
  final. A chat's agent transcripts are only removed then, so a restored chat can resume.
184
255
 
185
- The inspector binds `127.0.0.1` — it has no authentication of its own and
186
- its chat runs coding agents against the repo, so it stays off the LAN, and
187
- while bound there it only answers requests whose `Host` is a loopback name
188
- (so a page on another site cannot reach it by re-pointing its own DNS at
189
- 127.0.0.1). Inside a container it binds all interfaces instead, because the
190
- port mapping is the boundary there (`deploy-starter/` publishes to
191
- localhost, and its traefik override adds basic-auth). `KRAFTWERK_UI_HOST`
192
- overrides the default either way. A reverse proxy in front of it must set
193
- `X-Forwarded-Host` to the host the browser addressed (Caddy and traefik do
194
- by default; nginx needs `proxy_set_header X-Forwarded-Host $host;`): the
195
- loopback bind answers a non-loopback `Host` only when that header is
196
- present, and state-changing requests are refused when `Origin` names a
197
- different host.
256
+ The inspector binds `127.0.0.1`: its chat runs coding agents against the
257
+ repo, so it stays off the network unless you ask (`--lan`), and there only
258
+ paired devices get in. While bound to loopback it only answers requests
259
+ whose `Host` is a loopback name or `<slug>.localhost` (so a page on another
260
+ site cannot reach it by re-pointing its own DNS at 127.0.0.1). Inside a
261
+ container it binds all interfaces instead, because the port mapping is the
262
+ boundary there (`deploy-starter/` publishes to localhost, and its traefik
263
+ override adds basic-auth). `KRAFTWERK_UI_HOST` overrides the default either
264
+ way. A reverse proxy in front of it must set `X-Forwarded-Host` to the host
265
+ the browser addressed (Caddy and traefik do by default; nginx needs
266
+ `proxy_set_header X-Forwarded-Host $host;`), and its visitors pair like any
267
+ other device.
198
268
 
199
269
  ### The kraftwerk.yml project config
200
270
 
@@ -203,10 +273,15 @@ fields are optional. `workflows:` sets the workflows root, `output:` the
203
273
  run-artifact directory (default `output/`), `knowledge:` the OKF bundle root
204
274
  (default `knowledge/`), and `agents:` the agent-definition root (default
205
275
  `agents/`). `repos:` turns on the [repositories](#repositories) folder.
206
- `cloud:`
207
- tunes (or turns off) the registration with the
276
+ `cloud:` tunes (or turns off) the registration with the
208
277
  [kraftwerk cloud](#inspector-in-the-kraftwerk-cloud), which is on by default.
209
278
 
279
+ `name:` is the title shown in the UI and can change freely. `slug:` is the
280
+ workspace's address on this machine. It defaults to the folder name
281
+ (`~/Sites/team-blau` becomes `team-blau`). It must be lowercase letters,
282
+ digits and dashes, and unique among the workspaces on the machine.
283
+ `kraftwerk ui` and `kraftwerk doctor` warn when two workspaces share one.
284
+
210
285
  A `.env` next to kraftwerk.yml (`KEY=value` lines, `#` comments, quotes)
211
286
  is loaded into every kraftwerk process at start: `kraftwerk ui` and the
212
287
  server it supervises, so a restart from the UI re-reads the file; the chat
@@ -216,8 +291,14 @@ workspace's inspector gets its own `.env`, not the other one's. The file is
216
291
  never synced (it is on the workspace git's deny list and in the .gitignore
217
292
  `kraftwerk init` writes); `kraftwerk doctor` lists the names it loaded.
218
293
 
219
- `switcher:` links other kraftwerk workspaces from the inspector header. The
220
- workspace name becomes a dropdown listing them:
294
+ The workspace name in the inspector header is the **workspace menu**. It
295
+ lists every workspace this machine knows. Running ones open with a click,
296
+ and a stopped one starts from its row (in the daemon, when one runs). Each
297
+ link uses the address you opened this page with: `<slug>.localhost` on this
298
+ machine, `/w/<slug>/` from a paired device on the network, the path form
299
+ when you are in it.
300
+ `switcher:` adds workspaces kraftwerk cannot discover, such as one on
301
+ another machine:
221
302
 
222
303
  ```yaml
223
304
  switcher:
@@ -301,8 +382,12 @@ follows to author the workflow folder and validate and smoke it with this CLI.
301
382
 
302
383
  ### Inspector on your phone (paired devices)
303
384
 
304
- `kraftwerk ui --lan` also listens on your network and prints the addresses
305
- (for example `http://192.168.1.20:1981`). Anyone who opens one gets a
385
+ `kraftwerk ui --lan` (or `kraftwerk daemon --lan`) also listens on your
386
+ network and prints the addresses (for example `http://192.168.1.20:1981`).
387
+ On the daemon, a device opens a workspace at `/w/<slug>/` on that address
388
+ (`http://192.168.1.20:1980/w/team-blau/`), because `<slug>.localhost` only
389
+ works on this machine. The daemon's own address lists them, and the
390
+ workspace menu links the others the same way. Anyone who opens one gets a
306
391
  pairing screen, nothing else. To let a device in, make a one-time code on
307
392
  the machine kraftwerk runs on, with `kraftwerk devices pair` or settings →
308
393
  devices → pair a device, and enter it on the device. The code works once,
package/dist/acp.js CHANGED
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { Readable, Writable } from "node:stream";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from "@agentclientprotocol/sdk";
5
+ import { workspaceEnv } from "./core/env.js";
5
6
  const ADAPTERS = {
6
7
  claude: "@agentclientprotocol/claude-agent-acp/dist/index.js",
7
8
  codex: "@agentclientprotocol/codex-acp/dist/index.js",
@@ -21,7 +22,8 @@ const CLAUDE_THINKING_BUDGET = {
21
22
  * the session config it hands to `codex app-server`.
22
23
  */
23
24
  export function adapterEnv(agent, tuning) {
24
- const env = { ...process.env };
25
+ // The workspace's environment when a server runs this (one per workspace, see core/env.ts); a CLI run has none and gets process.env.
26
+ const env = workspaceEnv();
25
27
  if (agent === "claude" && tuning.effort && CLAUDE_THINKING_BUDGET[tuning.effort]) {
26
28
  env.MAX_THINKING_TOKENS = String(CLAUDE_THINKING_BUDGET[tuning.effort]);
27
29
  }
@@ -0,0 +1,14 @@
1
+ import type { Command } from "commander";
2
+ /**
3
+ * `kraftwerk daemon` — the machine's kraftwerk: one process serving every
4
+ * open workspace, each at `<slug>.localhost:<port>` (and its own port, when
5
+ * free). `kraftwerk ui` in a workspace folder opens it there; `kraftwerk
6
+ * workspaces stop <root>` closes it; the daemon reopens what was open when it
7
+ * starts again. Runs in the foreground, restarted by a supervisor after a
8
+ * self-update like `kraftwerk ui`.
9
+ */
10
+ export declare function runDaemon(opts: {
11
+ port?: string;
12
+ lan?: boolean;
13
+ }): Promise<void>;
14
+ export declare function registerDaemonCommand(program: Command): void;
@@ -0,0 +1,60 @@
1
+ import chalk from "chalk";
2
+ import { DAEMON_PORT, findDaemon, lastOpenRoots, writeDaemonFile } from "../core/daemon.js";
3
+ import { tildify } from "../core/instances.js";
4
+ import { networkUrls } from "../server/bind.js";
5
+ import { startHub } from "../server/server.js";
6
+ import { ensureBuilt, supervise } from "./ui.js";
7
+ /**
8
+ * `kraftwerk daemon` — the machine's kraftwerk: one process serving every
9
+ * open workspace, each at `<slug>.localhost:<port>` (and its own port, when
10
+ * free). `kraftwerk ui` in a workspace folder opens it there; `kraftwerk
11
+ * workspaces stop <root>` closes it; the daemon reopens what was open when it
12
+ * starts again. Runs in the foreground, restarted by a supervisor after a
13
+ * self-update like `kraftwerk ui`.
14
+ */
15
+ export async function runDaemon(opts) {
16
+ if (opts.lan)
17
+ process.env.KRAFTWERK_UI_HOST ||= "0.0.0.0";
18
+ if (process.env.KRAFTWERK_UI_SUPERVISED !== "1")
19
+ return supervise(["daemon", ...(opts.port ? ["--port", opts.port] : [])], "the daemon");
20
+ const running = await findDaemon();
21
+ if (running && running.pid !== process.pid) {
22
+ console.error(chalk.red(`✖ a kraftwerk daemon already runs (pid ${running.pid}, port ${running.port})`));
23
+ process.exit(1);
24
+ }
25
+ const port = Number(opts.port ?? process.env.KRAFTWERK_DAEMON_PORT ?? DAEMON_PORT);
26
+ const reopen = await lastOpenRoots();
27
+ const hub = await startHub({
28
+ port,
29
+ staticDir: ensureBuilt(),
30
+ daemon: true,
31
+ onChange: (h) => writeDaemonFile(h.port, h.list().map((o) => o.root)),
32
+ });
33
+ await writeDaemonFile(hub.port, []);
34
+ for (const root of reopen) {
35
+ try {
36
+ await hub.open(root);
37
+ }
38
+ catch (err) {
39
+ console.log(`${chalk.yellow("⚠")} not reopened: ${tildify(root)} — ${err.message}`);
40
+ }
41
+ }
42
+ console.log(`${chalk.green("✔")} kraftwerk daemon on ${chalk.cyan(`http://localhost:${hub.port}`)} ${chalk.dim(`(pid ${process.pid})`)}`);
43
+ for (const o of hub.list())
44
+ console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
45
+ if (!hub.list().length)
46
+ console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
47
+ const lan = networkUrls(hub.port);
48
+ if (lan.length)
49
+ console.log(`${chalk.green("✔")} On your network: ${lan.map((u) => chalk.cyan(u)).join(", ")} ${chalk.dim("(add /w/<slug>/; devices pair first)")}`);
50
+ }
51
+ export function registerDaemonCommand(program) {
52
+ program
53
+ .command("daemon")
54
+ .description("Run the machine's kraftwerk: one process serving every open workspace at <slug>.localhost")
55
+ .option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
56
+ .option("--lan", "Listen on the network too, for paired devices")
57
+ .action(async (opts) => {
58
+ await runDaemon(opts);
59
+ });
60
+ }
@@ -2,12 +2,13 @@ import { spawnSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
- import { cloudFor, ignoreEntryFor, isDir, reposRootFor, resolveWorkspace } from "../config.js";
5
+ import { cloudFor, ignoreEntryFor, isDir, reposRootFor, resolveWorkspace, workspaceSlug } from "../config.js";
6
6
  import { discoverWorkflows } from "../discover.js";
7
7
  import { missingEnv } from "../yaml.js";
8
8
  import { applyDotenv, DOTENV_FILE } from "../dotenv.js";
9
9
  import { legacyFindings, versionLt } from "./doctor-legacy.js";
10
10
  import { listDevices } from "../core/devices.js";
11
+ import { slugClashes } from "../core/instances.js";
11
12
  const ICONS = {
12
13
  ok: chalk.green("✔"),
13
14
  warn: chalk.yellow("⚠"),
@@ -72,6 +73,13 @@ export async function runDoctor(cwd) {
72
73
  process.exit(1);
73
74
  }
74
75
  report("info", `Project root: ${project.root}`, project.configPath ? path.basename(project.configPath) : "no kraftwerk.yml (fallback: workflows folder or .git)");
76
+ // The slug is the workspace's address on this machine; two workspaces cannot share one.
77
+ const slug = workspaceSlug(project);
78
+ const clash = await slugClashes(slug, project.root);
79
+ if (clash.length)
80
+ report("warn", `slug: ${slug}`, `also used by ${clash.map((r) => r.root).join(", ")} — set \`slug:\` in one kraftwerk.yml`);
81
+ else
82
+ report("ok", `slug: ${slug}`, project.config.slug ? "from kraftwerk.yml" : "the folder name — `slug:` in kraftwerk.yml sets another");
75
83
  // Project config: well-formed (guaranteed by the parse above) and the
76
84
  // configured paths actually exist.
77
85
  if (!project.configPath) {
package/dist/cli/init.js CHANGED
@@ -16,6 +16,7 @@ const configTemplate = (name) => `# yaml-language-server: $schema=${CONFIG_SCHEM
16
16
  # kraftwerk project config — also marks the project root for the CLI.
17
17
  # All fields optional. Docs: https://github.com/NETNODEAG/kraftwerk
18
18
  name: ${JSON.stringify(name)} # display name, shown in the inspector header + browser tab
19
+ # slug: my-team # this workspace's address on this machine (my-team.localhost); default: the folder name
19
20
  icon: "⚡" # emoji shown as the inspector favicon
20
21
  port: 1981 # port \`kraftwerk ui\` listens on
21
22
  workflows: ${DATA_DIR}/workflows # where workflows live
@@ -18,6 +18,7 @@ import { registerRepoCommands } from "./repos.js";
18
18
  import { registerVibeableCommands } from "./vibeables.js";
19
19
  import { registerTrashCommands } from "./trash.js";
20
20
  import { registerDevicesCommands } from "./devices.js";
21
+ import { registerDaemonCommand } from "./daemon.js";
21
22
  import { registerJournalCommands } from "./journal.js";
22
23
  import { applyDotenv } from "../dotenv.js";
23
24
  import { resolveWorkspace } from "../config.js";
@@ -41,6 +42,7 @@ import { runUi } from "./ui.js";
41
42
  * kraftwerk journal <agent> [entry] an agent's memory across sessions: print it, add a line
42
43
  * kraftwerk trash ... what was deleted: list, restore, purge, empty
43
44
  * kraftwerk devices ... paired devices: list, pair one, revoke one
45
+ * kraftwerk daemon the machine's kraftwerk: every open workspace at <slug>.localhost
44
46
  * kraftwerk doctor preflight: harness CLIs, docker, workflows, env
45
47
  * kraftwerk validate [paths...] validate without executing
46
48
  *
@@ -292,6 +294,7 @@ registerRepoCommands(program);
292
294
  registerVibeableCommands(program);
293
295
  registerTrashCommands(program);
294
296
  registerDevicesCommands(program);
297
+ registerDaemonCommand(program);
295
298
  registerJournalCommands(program);
296
299
  program
297
300
  .command("ui")
@@ -299,6 +302,7 @@ program
299
302
  .option("--port <port>", "Port for the web UI (default: kraftwerk.yml `port`, else 1981)")
300
303
  .option("--output <dir>", "Output directory to inspect (default: the project's output dir)")
301
304
  .option("--lan", "Listen on the network too, so paired devices (a phone) reach it — others get a pairing screen")
305
+ .option("--standalone", "Run a server of its own even when the kraftwerk daemon runs")
302
306
  .action(async (opts) => {
303
307
  await runUi(process.cwd(), opts);
304
308
  });
package/dist/cli/ui.d.ts CHANGED
@@ -1,5 +1,13 @@
1
+ /**
2
+ * Published packages ship web/dist. A dev checkout builds it on
3
+ * first use (and after frontend edits: `npm run build` in web/).
4
+ */
5
+ export declare function ensureBuilt(): string;
1
6
  export declare function runUi(cwd: string, opts: {
2
7
  port?: string;
3
8
  output?: string;
4
9
  lan?: boolean;
10
+ standalone?: boolean;
5
11
  }): Promise<void>;
12
+ /** The respawn loop for any long-running kraftwerk process (`ui`, `daemon`): see superviseUi. */
13
+ export declare function supervise(argv: string[], what: string): Promise<void>;
package/dist/cli/ui.js CHANGED
@@ -4,7 +4,9 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import chalk from "chalk";
7
- import { absolutePath, resolveWorkspace } from "../config.js";
7
+ import { absolutePath, resolveWorkspace, workspaceSlug } from "../config.js";
8
+ import { slugClashes } from "../core/instances.js";
9
+ import { daemonCall, findDaemon } from "../core/daemon.js";
8
10
  import { selfCommand } from "../core/self-command.js";
9
11
  import { RESTART_EXIT_CODE, startInspector } from "../server/server.js";
10
12
  import { networkUrls } from "../server/bind.js";
@@ -26,7 +28,10 @@ const packageRoot = () => path.join(path.dirname(fileURLToPath(import.meta.url))
26
28
  * Published packages ship web/dist. A dev checkout builds it on
27
29
  * first use (and after frontend edits: `npm run build` in web/).
28
30
  */
29
- function ensureBuilt() {
31
+ export function ensureBuilt() {
32
+ // Another build of the web UI (tests serve a stub; a dev setup may point at its own).
33
+ if (process.env.KRAFTWERK_WEB_DIR)
34
+ return path.resolve(process.env.KRAFTWERK_WEB_DIR);
30
35
  const web = path.join(packageRoot(), "web");
31
36
  const dist = path.join(web, "dist");
32
37
  if (existsSync(path.join(dist, "index.html")))
@@ -49,6 +54,23 @@ function ensureBuilt() {
49
54
  return dist;
50
55
  }
51
56
  export async function runUi(cwd, opts) {
57
+ // The machine runs a daemon: hand it this workspace, it serves it next to the others.
58
+ if (!opts.standalone && !opts.port && !opts.output && !opts.lan && process.env.KRAFTWERK_UI_SUPERVISED !== "1") {
59
+ const daemon = await findDaemon();
60
+ if (daemon) {
61
+ const project = await resolveWorkspace(cwd);
62
+ const r = await daemonCall(daemon.port, "open", project.root);
63
+ if (!r.ok) {
64
+ console.error(chalk.red(`✖ the kraftwerk daemon refused: ${r.data.error ?? r.status}`));
65
+ process.exit(1);
66
+ }
67
+ console.log(`${chalk.green("✔")} Kraftwerk UI: ${chalk.cyan(r.data.url)} ${chalk.dim(`(served by the kraftwerk daemon, pid ${daemon.pid})`)}`);
68
+ if (r.data.aliasPort)
69
+ console.log(chalk.dim(` also at http://localhost:${r.data.aliasPort}`));
70
+ console.log(chalk.dim(" `kraftwerk workspaces stop .` closes it there; `kraftwerk ui --standalone` runs a server of its own instead."));
71
+ return;
72
+ }
73
+ }
52
74
  // The server reads its bind address when it starts — in the supervised child, which inherits this.
53
75
  if (opts.lan)
54
76
  process.env.KRAFTWERK_UI_HOST ||= "0.0.0.0";
@@ -60,6 +82,11 @@ export async function runUi(cwd, opts) {
60
82
  // Port precedence: --port flag > kraftwerk.yml `port` > 1981.
61
83
  const port = opts.port ? Number(opts.port) : (project.config.port ?? 1981);
62
84
  await startInspector({ outputDir, staticDir, port, root: project.root });
85
+ // Two workspaces with one slug would share an address once one kraftwerk serves them all.
86
+ const slug = workspaceSlug(project);
87
+ const clash = await slugClashes(slug, project.root);
88
+ if (clash.length)
89
+ console.log(`${chalk.yellow("⚠")} slug "${slug}" is also used by ${clash.map((r) => r.root).join(", ")} — set \`slug:\` in one kraftwerk.yml so each workspace keeps its own address`);
63
90
  console.log(`${chalk.green("✔")} Kraftwerk UI: ${chalk.cyan(`http://localhost:${port}`)} ` +
64
91
  chalk.dim(`(output: ${outputDir})`));
65
92
  const lan = networkUrls(port);
@@ -78,11 +105,11 @@ export async function runUi(cwd, opts) {
78
105
  * foreground group and reaches both anyway.
79
106
  */
80
107
  async function superviseUi(opts) {
81
- const { cmd, args } = selfCommand([
82
- "ui",
83
- ...(opts.port ? ["--port", opts.port] : []),
84
- ...(opts.output ? ["--output", opts.output] : []),
85
- ]);
108
+ return supervise(["ui", "--standalone", ...(opts.port ? ["--port", opts.port] : []), ...(opts.output ? ["--output", opts.output] : [])], "the UI");
109
+ }
110
+ /** The respawn loop for any long-running kraftwerk process (`ui`, `daemon`): see superviseUi. */
111
+ export async function supervise(argv, what) {
112
+ const { cmd, args } = selfCommand(argv);
86
113
  for (;;) {
87
114
  const child = spawn(cmd, args, {
88
115
  stdio: "inherit",
@@ -104,6 +131,6 @@ async function superviseUi(opts) {
104
131
  process.exit(128 + (os.constants.signals[result.signal] ?? 1));
105
132
  process.exit(result.code ?? 1);
106
133
  }
107
- console.log(chalk.dim("↻ relaunching the UI with the current install ..."));
134
+ console.log(chalk.dim(`↻ relaunching ${what} with the current install ...`));
108
135
  }
109
136
  }
@@ -50,7 +50,9 @@ async function resolveRef(ref, needRoot = true) {
50
50
  e.root === ref ||
51
51
  e.name === ref ||
52
52
  (e.root && path.basename(e.root) === ref) ||
53
- e.url === port);
53
+ e.url === port ||
54
+ // A daemon's workspace by its slug: <slug>.localhost.
55
+ e.url.startsWith(`http://${ref}.localhost:`));
54
56
  if (hits.length === 1)
55
57
  return hits[0];
56
58
  if (hits.length === 0) {
@@ -117,8 +119,8 @@ export function registerWorkspaceCommands(program) {
117
119
  });
118
120
  workspaces
119
121
  .command("stop")
120
- .description("Stop a running UI (SIGTERM to its server; works for terminal-started ones too)")
121
- .argument("<ref>", "Workspace name, folder name, root path, or port")
122
+ .description("Stop a running UI (closes it in the kraftwerk daemon, or SIGTERM to a server of its own)")
123
+ .argument("<ref>", "Workspace name, slug, folder name, root path, or port")
122
124
  .action(async (ref) => {
123
125
  const entry = await resolveRef(ref, false);
124
126
  if (!entry.live) {
@@ -63,6 +63,12 @@ export declare function errorOf(body: unknown): string | undefined;
63
63
  export interface ClientOptions {
64
64
  /** Where the server is; "" (the default) is the page's own origin. */
65
65
  baseUrl?: string;
66
+ /**
67
+ * The workspace (its slug) to talk to, through the `/w/<slug>` path form.
68
+ * Leave it out when the address already names one (`<slug>.localhost`, or
69
+ * a server's own port).
70
+ */
71
+ workspace?: string;
66
72
  fetch?: typeof fetch;
67
73
  /**
68
74
  * A paired device's token (from `devices.pair`), sent as `Authorization:
@@ -81,5 +87,7 @@ export interface Client {
81
87
  onChange(fn: () => void): () => void;
82
88
  /** The token the client was created with, if any (the socket sends it too). */
83
89
  readonly token?: string;
90
+ /** Where routes live: baseUrl plus the workspace's `/w/<slug>` prefix. */
91
+ readonly baseUrl: string;
84
92
  }
85
93
  export declare function createClient(opts?: ClientOptions): Client;
@@ -17,7 +17,7 @@ export function errorOf(body) {
17
17
  return typeof error === "string" && error ? error : undefined;
18
18
  }
19
19
  export function createClient(opts = {}) {
20
- const base = (opts.baseUrl ?? "").replace(/\/+$/, "");
20
+ const base = (opts.baseUrl ?? "").replace(/\/+$/, "") + (opts.workspace ? `/w/${encodeURIComponent(opts.workspace)}` : "");
21
21
  const doFetch = opts.fetch ?? ((...a) => fetch(...a));
22
22
  const changeFns = new Set();
23
23
  const url = (name, input = {}) => {
@@ -66,6 +66,7 @@ export function createClient(opts = {}) {
66
66
  };
67
67
  return {
68
68
  token: opts.token,
69
+ baseUrl: base,
69
70
  onChange(fn) {
70
71
  changeFns.add(fn);
71
72
  return () => changeFns.delete(fn);
@@ -25,9 +25,8 @@ export interface Live {
25
25
  close(): void;
26
26
  }
27
27
  export interface LiveOptions {
28
- /** The HTTP client for fallbacks; its baseUrl is the server. */
28
+ /** The HTTP client: its baseUrl (workspace prefix included) is where the socket connects too. */
29
29
  client: Client;
30
- baseUrl?: string;
31
30
  WebSocket?: typeof WebSocket;
32
31
  EventSource?: typeof EventSource;
33
32
  }
@@ -12,7 +12,8 @@ export function createLive(opts) {
12
12
  let retry;
13
13
  let counter = 0;
14
14
  const socketUrl = () => {
15
- const base = opts.baseUrl ?? (typeof location === "undefined" ? "" : location.origin);
15
+ // The client's base carries the workspace prefix; a relative one is on this page's origin.
16
+ const base = /^https?:/.test(client.baseUrl) ? client.baseUrl : (typeof location === "undefined" ? "" : location.origin) + client.baseUrl;
16
17
  // A browser's socket carries the device cookie; other runtimes cannot send headers on a WebSocket, so the token rides in the query.
17
18
  return `${base.replace(/^http/, "ws").replace(/\/+$/, "")}/api/ws${client.token ? `?token=${encodeURIComponent(client.token)}` : ""}`;
18
19
  };
@@ -62,6 +62,9 @@ export declare const ROUTES: {
62
62
  readonly "git.pull": readonly ["POST", "/api/git/pull"];
63
63
  readonly "git.push": readonly ["POST", "/api/git/push"];
64
64
  readonly "git.status": readonly ["GET", "/api/git"];
65
+ readonly "hub.close": readonly ["POST", "/api/hub/close"];
66
+ readonly "hub.list": readonly ["GET", "/api/hub"];
67
+ readonly "hub.open": readonly ["POST", "/api/hub/open"];
65
68
  readonly "knowledge.concept": readonly ["GET", "/api/knowledge/:bundle/concept"];
66
69
  readonly "knowledge.create": readonly ["POST", "/api/knowledge"];
67
70
  readonly "knowledge.delete": readonly ["DELETE", "/api/knowledge/:bundle"];
@@ -63,6 +63,9 @@ export const ROUTES = {
63
63
  "git.pull": ["POST", "/api/git/pull"],
64
64
  "git.push": ["POST", "/api/git/push"],
65
65
  "git.status": ["GET", "/api/git"],
66
+ "hub.close": ["POST", "/api/hub/close"],
67
+ "hub.list": ["GET", "/api/hub"],
68
+ "hub.open": ["POST", "/api/hub/open"],
66
69
  "knowledge.concept": ["GET", "/api/knowledge/:bundle/concept"],
67
70
  "knowledge.create": ["POST", "/api/knowledge"],
68
71
  "knowledge.delete": ["DELETE", "/api/knowledge/:bundle"],
package/dist/config.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * name: my-project # display name (inspector header, "environment")
13
13
  * icon: "⚡" # emoji shown as inspector favicon
14
14
  * color: "#c2410c" # accent for this workspace in the switcher (default: derived from the root)
15
+ * slug: team-blau # this workspace's address (team-blau.localhost); default: the folder name
15
16
  * port: 1981 # port `kraftwerk ui` listens on
16
17
  * workflows: src/workflows # workflows root, relative to the file
17
18
  * output: output # run-artifact directory, relative to the file
@@ -166,6 +167,12 @@ export interface SwitcherEntry {
166
167
  export interface WorkspaceConfig {
167
168
  /** Display name of the project ("environment"), shown in the inspector header. */
168
169
  name?: string;
170
+ /**
171
+ * The workspace's address on this machine: `<slug>.localhost`, `/w/<slug>/`.
172
+ * Unique per machine. Default: the folder name (see workspaceSlug). The
173
+ * name above may change freely; the slug only when you mean to move it.
174
+ */
175
+ slug?: string;
169
176
  /** Emoji used as the inspector favicon (browser-tab icon). */
170
177
  icon?: string;
171
178
  /** Accent colour (#rgb / #rrggbb) for this workspace in the switcher. Default: derived from the root path. */
@@ -234,3 +241,12 @@ export declare const resolveProject: typeof resolveWorkspace;
234
241
  export type Project = ResolvedWorkspace;
235
242
  /** @deprecated Use `WorkspaceConfig`. */
236
243
  export type ProjectConfig = WorkspaceConfig;
244
+ /** A folder name as a slug: "Team Blau (2024)" → "team-blau-2024". */
245
+ export declare function slugFromFolder(folder: string): string;
246
+ /** The workspace's slug: kraftwerk.yml `slug`, else its folder name made into one. */
247
+ export declare function workspaceSlug(ws: {
248
+ root: string;
249
+ config: {
250
+ slug?: string;
251
+ };
252
+ }): string;