@netnodeag/kraftwerk 0.65.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 (75) hide show
  1. package/README.md +93 -23
  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 +32 -12
  31. package/dist/core/instances.js +136 -64
  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/vibeables.d.ts +2 -0
  36. package/dist/core/vibeables.js +17 -5
  37. package/dist/core/workspace.d.ts +13 -0
  38. package/dist/core/workspace.js +25 -0
  39. package/dist/server/api/hub.d.ts +25 -0
  40. package/dist/server/api/hub.js +32 -0
  41. package/dist/server/api/index.d.ts +24 -1
  42. package/dist/server/api/index.js +2 -0
  43. package/dist/server/api/socket.d.ts +9 -1
  44. package/dist/server/api/socket.js +27 -7
  45. package/dist/server/api/workspace.d.ts +1 -0
  46. package/dist/server/api/workspace.js +2 -1
  47. package/dist/server/cloud.d.ts +5 -1
  48. package/dist/server/cloud.js +86 -54
  49. package/dist/server/hub-context.d.ts +7 -0
  50. package/dist/server/hub-context.js +18 -0
  51. package/dist/server/live.d.ts +2 -0
  52. package/dist/server/live.js +9 -0
  53. package/dist/server/preview.js +2 -1
  54. package/dist/server/server.d.ts +52 -1
  55. package/dist/server/server.js +249 -52
  56. package/dist/server/target.d.ts +20 -0
  57. package/dist/server/target.js +18 -0
  58. package/package.json +1 -1
  59. package/schema/kraftwerk.schema.json +5 -0
  60. package/web/dist/assets/{dist-CAWmDGJC.js → dist-1bNkaWWA.js} +1 -1
  61. package/web/dist/assets/dist-BU1SOB5S.js +1 -0
  62. package/web/dist/assets/{dist-BCfXUgtf.js → dist-Bk3f-J5U.js} +1 -1
  63. package/web/dist/assets/{dist-DnMgiPT1.js → dist-Bx99Puvv.js} +1 -1
  64. package/web/dist/assets/{dist-v6-49xpW.js → dist-C0M2aITo.js} +1 -1
  65. package/web/dist/assets/{dist-C-CclD1E.js → dist-C0z19Zge.js} +1 -1
  66. package/web/dist/assets/{dist-nA7q2-h7.js → dist-COEgBKG6.js} +1 -1
  67. package/web/dist/assets/{dist-Bnxg6V5L.js → dist-CYRrdlae.js} +1 -1
  68. package/web/dist/assets/{dist-hevbMnoj.js → dist-CbEFnSCD.js} +1 -1
  69. package/web/dist/assets/{dist-DS1R2xKJ.js → dist-DCYVKU-H.js} +1 -1
  70. package/web/dist/assets/{dist-10uPVFvu.js → dist-XyTHWtoN.js} +1 -1
  71. package/web/dist/assets/{editor-C8yy8YS9.js → editor-DPcGvSPy.js} +3 -3
  72. package/web/dist/assets/index-DKLJdwYE.js +229 -0
  73. package/web/dist/index.html +1 -1
  74. package/web/dist/assets/dist-yoh47aAL.js +0 -1
  75. 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,68 @@ 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.
91
+ `kraftwerk ui` hands the workspace to the daemon whenever one is running;
92
+ `kraftwerk ui --standalone` starts a server of its own anyway.
93
+
94
+ **From your phone or another computer.** Add `--lan` (`kraftwerk ui --lan`,
95
+ or `kraftwerk daemon --lan`) and kraftwerk also listens on your network.
96
+ Anyone who opens it there gets a pairing screen, nothing else, until you
97
+ pair that device: `kraftwerk devices pair` on this machine prints a
98
+ one-time code (see [Inspector on your phone](#inspector-on-your-phone-paired-devices)).
99
+
100
+ **What to do first in the inspector.** Talk to **Ralv**, the workspace's
101
+ general chat, in plain words: it knows what is in the workspace and helps
102
+ set up the rest. From there:
103
+
104
+ - **Agents** are persistent coworkers with a role, a memory and routines.
105
+ Open one and chat with it; its sessions continue where they ended.
106
+ - **Projects** gather a goal, a brief and links; every chat in a project
107
+ starts with that context.
108
+ - **Workflows** are repeatable processes with checked results; run one from
109
+ its page or with `kraftwerk run <workflow> "<request>"`.
110
+ - **Knowledge** is the shared, human-verified context agents read.
111
+
112
+ Everything you see in the inspector is files in the workspace folder, so
113
+ the CLI (`kraftwerk --help`) and git work on the same things.
114
+
51
115
  ## Under the hood
52
116
 
53
117
  Deterministic workflow-as-code over headless agent harnesses, in the spirit of
@@ -112,7 +176,7 @@ cd your-project
112
176
  kraftwerk init # scaffold kraftwerk.yml + kraftwerk-data/ (workflow, agent, knowledge)
113
177
  kraftwerk doctor # preflight: harness CLIs, docker, workflows, declared env vars, legacy leftovers
114
178
  kraftwerk run hello "Was ist kraftwerk?"
115
- kraftwerk ui # inspector on http://localhost:1981
179
+ kraftwerk ui # inspector on http://localhost:1981 (see Start and use kraftwerk)
116
180
  ```
117
181
 
118
182
  The same without a global install:
@@ -159,8 +223,10 @@ kraftwerk run tagline "https://..." # run; --yes, --verbose
159
223
  kraftwerk run # interactive: pick workflow, type the request
160
224
  kraftwerk runs # past runs from output/*/trace.jsonl; runs show <id> for detail
161
225
  kraftwerk knowledge # Knowledge: OKF bundles (list/get/put/verify/search/...)
162
- kraftwerk ui # inspector web UI on http://localhost:1981; --port, --output
226
+ kraftwerk ui # start this workspace: http://localhost:1981 (or open it in the daemon); --port, --lan, --standalone
227
+ kraftwerk daemon # one kraftwerk for every open workspace: http://<slug>.localhost:1980; --port, --lan
163
228
  kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
229
+ kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
164
230
  kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
165
231
  kraftwerk trash # what was deleted; trash restore|purge <id>, trash empty
166
232
  kraftwerk journal <agent> ["<entry>"] # an agent's memory across sessions: print it, add a line (--kind)
@@ -182,19 +248,18 @@ The **trash** screen (`#/trash`) or `kraftwerk trash restore <id>` puts an entry
182
248
  back where it came from. Deleting it there, or `kraftwerk trash empty`, makes it
183
249
  final. A chat's agent transcripts are only removed then, so a restored chat can resume.
184
250
 
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.
251
+ The inspector binds `127.0.0.1`: its chat runs coding agents against the
252
+ repo, so it stays off the network unless you ask (`--lan`), and there only
253
+ paired devices get in. While bound to loopback it only answers requests
254
+ whose `Host` is a loopback name or `<slug>.localhost` (so a page on another
255
+ site cannot reach it by re-pointing its own DNS at 127.0.0.1). Inside a
256
+ container it binds all interfaces instead, because the port mapping is the
257
+ boundary there (`deploy-starter/` publishes to localhost, and its traefik
258
+ override adds basic-auth). `KRAFTWERK_UI_HOST` overrides the default either
259
+ way. A reverse proxy in front of it must set `X-Forwarded-Host` to the host
260
+ the browser addressed (Caddy and traefik do by default; nginx needs
261
+ `proxy_set_header X-Forwarded-Host $host;`), and its visitors pair like any
262
+ other device.
198
263
 
199
264
  ### The kraftwerk.yml project config
200
265
 
@@ -203,10 +268,15 @@ fields are optional. `workflows:` sets the workflows root, `output:` the
203
268
  run-artifact directory (default `output/`), `knowledge:` the OKF bundle root
204
269
  (default `knowledge/`), and `agents:` the agent-definition root (default
205
270
  `agents/`). `repos:` turns on the [repositories](#repositories) folder.
206
- `cloud:`
207
- tunes (or turns off) the registration with the
271
+ `cloud:` tunes (or turns off) the registration with the
208
272
  [kraftwerk cloud](#inspector-in-the-kraftwerk-cloud), which is on by default.
209
273
 
274
+ `name:` is the title shown in the UI and can change freely. `slug:` is the
275
+ workspace's address on this machine. It defaults to the folder name
276
+ (`~/Sites/team-blau` becomes `team-blau`). It must be lowercase letters,
277
+ digits and dashes, and unique among the workspaces on the machine.
278
+ `kraftwerk ui` and `kraftwerk doctor` warn when two workspaces share one.
279
+
210
280
  A `.env` next to kraftwerk.yml (`KEY=value` lines, `#` comments, quotes)
211
281
  is loaded into every kraftwerk process at start: `kraftwerk ui` and the
212
282
  server it supervises, so a restart from the UI re-reads the file; the chat
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;
package/dist/config.js CHANGED
@@ -16,6 +16,7 @@ import { parse } from "yaml";
16
16
  * name: my-project # display name (inspector header, "environment")
17
17
  * icon: "⚡" # emoji shown as inspector favicon
18
18
  * color: "#c2410c" # accent for this workspace in the switcher (default: derived from the root)
19
+ * slug: team-blau # this workspace's address (team-blau.localhost); default: the folder name
19
20
  * port: 1981 # port `kraftwerk ui` listens on
20
21
  * workflows: src/workflows # workflows root, relative to the file
21
22
  * output: output # run-artifact directory, relative to the file
@@ -194,7 +195,25 @@ export async function resolveWorkspace(cwd) {
194
195
  }
195
196
  /** @deprecated Renamed: a kraftwerk.yml root is a workspace (projects are the folders inside). Use `resolveWorkspace`. */
196
197
  export const resolveProject = resolveWorkspace;
197
- const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "cloud"];
198
+ /** A workspace slug: a DNS label, so `<slug>.localhost` is a valid host name. */
199
+ const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
200
+ /** A folder name as a slug: "Team Blau (2024)" → "team-blau-2024". */
201
+ export function slugFromFolder(folder) {
202
+ const s = folder
203
+ .normalize("NFKD")
204
+ .replace(/[\u0300-\u036f]/g, "")
205
+ .toLowerCase()
206
+ .replace(/[^a-z0-9]+/g, "-")
207
+ .replace(/^-+|-+$/g, "")
208
+ .slice(0, 63)
209
+ .replace(/-+$/, "");
210
+ return s || "workspace";
211
+ }
212
+ /** The workspace's slug: kraftwerk.yml `slug`, else its folder name made into one. */
213
+ export function workspaceSlug(ws) {
214
+ return ws.config.slug ?? slugFromFolder(path.basename(ws.root));
215
+ }
216
+ const KNOWN_KEYS = ["name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "cloud"];
198
217
  /**
199
218
  * Keys an older kraftwerk understood and this one ignores, so the files
200
219
  * that still carry them keep loading: `public:` and `tunnel:` (the
@@ -224,6 +243,9 @@ async function loadConfig(configPath) {
224
243
  if (!known.includes(key)) {
225
244
  throw new Error(`${path.basename(configPath)}: unknown key "${key}" (allowed: ${known.join(", ")})`);
226
245
  }
246
+ if (key === "slug" && (typeof config[key] !== "string" || !SLUG_RE.test(config[key]))) {
247
+ throw new Error(`${path.basename(configPath)}: slug must be lowercase letters, digits and dashes, starting with a letter or digit (at most 63)`);
248
+ }
227
249
  if (key === "port") {
228
250
  if (typeof config[key] !== "number" || !Number.isInteger(config[key])) {
229
251
  throw new Error(`${path.basename(configPath)}: port must be an integer`);
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { workspaceEnv } from "../env.js";
3
4
  const clip = (raw) => {
4
5
  const oneLine = String(raw ?? "").replace(/\s+/g, " ").trim();
5
6
  return oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine;
@@ -23,7 +24,7 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
23
24
  const proc = spawn("pi", ["-p", "--mode", "json", "--session-id", sessionId, ...tuningArgs], {
24
25
  cwd,
25
26
  stdio: ["pipe", "pipe", "pipe"],
26
- env: process.env,
27
+ env: workspaceEnv(),
27
28
  });
28
29
  child = proc;
29
30
  proc.stdin.write(text);