@netnodeag/kraftwerk 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +126 -138
  2. package/dist/acp.js +3 -1
  3. package/dist/cli/daemon.d.ts +14 -0
  4. package/dist/cli/daemon.js +60 -0
  5. package/dist/cli/devices.d.ts +14 -0
  6. package/dist/cli/devices.js +53 -0
  7. package/dist/cli/doctor-legacy.js +9 -1
  8. package/dist/cli/doctor.js +17 -30
  9. package/dist/cli/init.js +1 -6
  10. package/dist/cli/kraftwerk.js +8 -3
  11. package/dist/cli/ui.d.ts +9 -0
  12. package/dist/cli/ui.js +47 -23
  13. package/dist/cli/workspaces.js +5 -3
  14. package/dist/client/index.d.ts +17 -0
  15. package/dist/client/index.js +10 -2
  16. package/dist/client/live.d.ts +1 -2
  17. package/dist/client/live.js +4 -2
  18. package/dist/client/routes.d.ts +8 -0
  19. package/dist/client/routes.js +8 -0
  20. package/dist/config.d.ts +18 -55
  21. package/dist/config.js +39 -92
  22. package/dist/core/chat/context.js +1 -1
  23. package/dist/core/chat/pi.js +2 -1
  24. package/dist/core/chat/sessions.d.ts +1 -1
  25. package/dist/core/chat/sessions.js +1 -1
  26. package/dist/core/chat/state.d.ts +2 -0
  27. package/dist/core/chat/state.js +5 -0
  28. package/dist/core/daemon.d.ts +27 -0
  29. package/dist/core/daemon.js +61 -0
  30. package/dist/core/devices.d.ts +35 -0
  31. package/dist/core/devices.js +156 -0
  32. package/dist/core/env.d.ts +16 -0
  33. package/dist/core/env.js +45 -0
  34. package/dist/core/git.d.ts +2 -0
  35. package/dist/core/git.js +20 -4
  36. package/dist/core/instances.d.ts +32 -12
  37. package/dist/core/instances.js +136 -64
  38. package/dist/core/routines.d.ts +6 -0
  39. package/dist/core/routines.js +11 -0
  40. package/dist/core/runner.js +7 -3
  41. package/dist/core/vibeables.d.ts +2 -0
  42. package/dist/core/vibeables.js +17 -5
  43. package/dist/core/workspace.d.ts +13 -0
  44. package/dist/core/workspace.js +25 -0
  45. package/dist/dotenv.d.ts +2 -2
  46. package/dist/dotenv.js +2 -2
  47. package/dist/server/api/devices.d.ts +20 -0
  48. package/dist/server/api/devices.js +55 -0
  49. package/dist/server/api/hub.d.ts +25 -0
  50. package/dist/server/api/hub.js +32 -0
  51. package/dist/server/api/index.d.ts +42 -2
  52. package/dist/server/api/index.js +5 -1
  53. package/dist/server/api/router.d.ts +21 -6
  54. package/dist/server/api/router.js +37 -9
  55. package/dist/server/api/socket.d.ts +15 -1
  56. package/dist/server/api/socket.js +59 -8
  57. package/dist/server/api/workspace.d.ts +2 -0
  58. package/dist/server/api/workspace.js +4 -2
  59. package/dist/server/bind.d.ts +16 -0
  60. package/dist/server/bind.js +25 -0
  61. package/dist/server/cloud.d.ts +5 -1
  62. package/dist/server/cloud.js +89 -56
  63. package/dist/server/hub-context.d.ts +7 -0
  64. package/dist/server/hub-context.js +18 -0
  65. package/dist/server/live.d.ts +2 -0
  66. package/dist/server/live.js +9 -0
  67. package/dist/server/preview.js +2 -1
  68. package/dist/server/server.d.ts +58 -1
  69. package/dist/server/server.js +267 -103
  70. package/dist/server/target.d.ts +20 -0
  71. package/dist/server/target.js +18 -0
  72. package/dist/server/trust.d.ts +41 -0
  73. package/dist/server/trust.js +42 -0
  74. package/package.json +1 -1
  75. package/schema/kraftwerk.schema.json +7 -46
  76. package/web/dist/assets/{dist-Cgcwvf4j.js → dist-1bNkaWWA.js} +1 -1
  77. package/web/dist/assets/dist-BU1SOB5S.js +1 -0
  78. package/web/dist/assets/{dist-CboP0-T1.js → dist-Bk3f-J5U.js} +1 -1
  79. package/web/dist/assets/{dist-2kswkIH2.js → dist-Bx99Puvv.js} +1 -1
  80. package/web/dist/assets/{dist-D2PvEzo7.js → dist-C0M2aITo.js} +1 -1
  81. package/web/dist/assets/{dist-D_BcTzz-.js → dist-C0z19Zge.js} +1 -1
  82. package/web/dist/assets/{dist-36MjITWa.js → dist-COEgBKG6.js} +1 -1
  83. package/web/dist/assets/{dist-Jdx6DRrm.js → dist-CYRrdlae.js} +1 -1
  84. package/web/dist/assets/{dist-DDkd_zrP.js → dist-CbEFnSCD.js} +1 -1
  85. package/web/dist/assets/{dist-BsuA2pgO.js → dist-DCYVKU-H.js} +1 -1
  86. package/web/dist/assets/{dist-B8gPjMPP.js → dist-XyTHWtoN.js} +1 -1
  87. package/web/dist/assets/{editor-ByRUOfrX.js → editor-DPcGvSPy.js} +3 -3
  88. package/web/dist/assets/index-BAYs-2WC.css +2 -0
  89. package/web/dist/assets/index-DKLJdwYE.js +229 -0
  90. package/web/dist/index.html +2 -2
  91. package/dist/cli/tunnel.d.ts +0 -10
  92. package/dist/cli/tunnel.js +0 -280
  93. package/dist/server/access.d.ts +0 -32
  94. package/dist/server/access.js +0 -121
  95. package/web/dist/assets/dist-CVqmJrYd.js +0 -1
  96. package/web/dist/assets/index-BR7HvDzD.js +0 -229
  97. package/web/dist/assets/index-Bra74Nxe.css +0 -2
package/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)
@@ -171,8 +237,6 @@ kraftwerk create "was der Workflow tun soll" # for LLM agents: prints a build
171
237
  kraftwerk runner build # build the Docker sandbox image (once)
172
238
  kraftwerk run --sandbox website-check "https://..." # isolated container per run; --ssh forwards the agent
173
239
  kraftwerk runner ps / stop <run-id> # see / stop running sandbox containers
174
- kraftwerk tunnel setup kw.example.com # Cloudflare Tunnel to the inspector: login, create, route dns, kraftwerk.yml
175
- kraftwerk tunnel # run that tunnel alone (the UI runs elsewhere)
176
240
  ```
177
241
 
178
242
  Nothing is deleted at once. Removing an agent, a project, a workflow, a knowledge
@@ -184,20 +248,18 @@ The **trash** screen (`#/trash`) or `kraftwerk trash restore <id>` puts an entry
184
248
  back where it came from. Deleting it there, or `kraftwerk trash empty`, makes it
185
249
  final. A chat's agent transcripts are only removed then, so a restored chat can resume.
186
250
 
187
- The inspector binds `127.0.0.1` — it has no authentication of its own and
188
- its chat runs coding agents against the repo, so it stays off the LAN, and
189
- while bound there it only answers requests whose `Host` is a loopback name
190
- (so a page on another site cannot reach it by re-pointing its own DNS at
191
- 127.0.0.1). Inside a container it binds all interfaces instead, because the
192
- port mapping is the boundary there (`deploy-starter/` publishes to
193
- localhost, and its traefik override adds basic-auth). `KRAFTWERK_UI_HOST`
194
- overrides the default either way. A reverse proxy in front of it must set
195
- `X-Forwarded-Host` to the host the browser addressed (Caddy and traefik do
196
- by default; nginx needs `proxy_set_header X-Forwarded-Host $host;`): the
197
- loopback bind answers a non-loopback `Host` only when that header is
198
- present or the name is the project's `public:` hostname, and state-changing
199
- requests are refused when `Origin` names a different host. See
200
- [Inspector through a Cloudflare Tunnel](#inspector-through-a-cloudflare-tunnel).
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.
201
263
 
202
264
  ### The kraftwerk.yml project config
203
265
 
@@ -206,16 +268,19 @@ fields are optional. `workflows:` sets the workflows root, `output:` the
206
268
  run-artifact directory (default `output/`), `knowledge:` the OKF bundle root
207
269
  (default `knowledge/`), and `agents:` the agent-definition root (default
208
270
  `agents/`). `repos:` turns on the [repositories](#repositories) folder.
209
- `public:` and `tunnel:` expose the inspector through a
210
- [Cloudflare Tunnel](#inspector-through-a-cloudflare-tunnel). `cloud:`
211
- tunes (or turns off) the registration with the
271
+ `cloud:` tunes (or turns off) the registration with the
212
272
  [kraftwerk cloud](#inspector-in-the-kraftwerk-cloud), which is on by default.
213
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
+
214
280
  A `.env` next to kraftwerk.yml (`KEY=value` lines, `#` comments, quotes)
215
281
  is loaded into every kraftwerk process at start: `kraftwerk ui` and the
216
282
  server it supervises, so a restart from the UI re-reads the file; the chat
217
- agents, routines and workflow runs the inspector spawns; the tunnel
218
- (`TUNNEL_TOKEN`); `requires:` checks in `run` and `doctor`. A variable the
283
+ agents, routines and workflow runs the inspector spawns; `requires:` checks in `run` and `doctor`. A variable the
219
284
  shell already sets wins over the file. A project started from another
220
285
  workspace's inspector gets its own `.env`, not the other one's. The file is
221
286
  never synced (it is on the workspace git's deny list and in the .gitignore
@@ -304,6 +369,33 @@ agent such as Claude Code or Codex. It prints a self-contained brief covering
304
369
  the schema example, gates, harness rules and verify ladder, which the agent
305
370
  follows to author the workflow folder and validate and smoke it with this CLI.
306
371
 
372
+ ### Inspector on your phone (paired devices)
373
+
374
+ `kraftwerk ui --lan` also listens on your network and prints the addresses
375
+ (for example `http://192.168.1.20:1981`). Anyone who opens one gets a
376
+ pairing screen, nothing else. To let a device in, make a one-time code on
377
+ the machine kraftwerk runs on, with `kraftwerk devices pair` or settings →
378
+ devices → pair a device, and enter it on the device. The code works once,
379
+ for 10 minutes. The device stays paired until you unpair it in the same
380
+ place (`kraftwerk devices revoke <id>`), and its access ends at once.
381
+
382
+ One pairing covers every workspace on the machine. The tokens live in
383
+ `~/.kraftwerk/devices.json` as hashes only. A browser keeps its token as
384
+ an HttpOnly cookie. An app sends it as `Authorization: Bearer`, or as
385
+ `?token=` on the socket. A paired device can use everything except
386
+ managing devices, which stays on the machine itself. Requests from the
387
+ machine itself need no pairing.
388
+ Inside a container nothing changes: the port mapping is the boundary.
389
+ `KRAFTWERK_UI_TRUST_NETWORK=1` brings back the old behaviour of trusting
390
+ every network caller, and `kraftwerk doctor` warns while it is set.
391
+ Behind a reverse proxy on the same machine, the proxy must send
392
+ `X-Forwarded-For` (or `X-Forwarded-Host`), so its visitors pair like any
393
+ other device. If it cannot, set `KRAFTWERK_UI_REQUIRE_PAIRING=1`, which
394
+ makes every request pair, this machine's own included.
395
+
396
+ For a client of your own, `@netnodeag/kraftwerk/client` takes the token:
397
+ `createClient({ baseUrl, token })`.
398
+
307
399
  ### Inspector on a server
308
400
 
309
401
  The repo root ships [`deploy-starter/`](../deploy-starter/). Copy it into your
@@ -311,117 +403,13 @@ consumer repo as `deploy/` and it builds a small image, this package from npm
311
403
  plus the claude, codex and pi CLIs, that serves `kraftwerk ui` for that repo
312
404
  bind-mounted at `/work`. `compose.yml` is localhost-only, reachable over an
313
405
  SSH tunnel. `compose.traefik.yml` layers traefik routing and mandatory
314
- basic-auth on top. Treat that auth as load-bearing. The UI has no
315
- authentication of its own and its chat runs coding agents against the mounted
406
+ basic-auth on top. Treat that auth as load-bearing. In a container the UI
407
+ trusts whoever reaches it (the port mapping is the boundary, so device
408
+ pairing does not apply), and its chat runs coding agents against the mounted
316
409
  repo. Agent logins made inside the container persist in the `agent-home`
317
410
  volume. This is a different image from the `kraftwerk-runner` sandbox
318
411
  (`runner/Dockerfile`) used by `run --sandbox`.
319
412
 
320
- ### Inspector through a Cloudflare Tunnel
321
-
322
- The other way to reach a laptop's or server's inspector from elsewhere: no
323
- open port, no reverse proxy, no certificate. `kraftwerk ui` runs
324
- [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)
325
- next to the inspector and the loopback bind becomes reachable at a hostname
326
- on a domain you have on Cloudflare.
327
-
328
- You need a Cloudflare account with a domain added to it (the free plan is
329
- enough), and cloudflared on the machine:
330
-
331
- ```bash
332
- brew install cloudflared # macOS; Linux packages: developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
333
- ```
334
-
335
- **1. Create the tunnel and route a hostname to it.** From the project root:
336
-
337
- ```bash
338
- kraftwerk tunnel setup kw.example.com
339
- ```
340
-
341
- This opens the browser for `cloudflared tunnel login` when there is no
342
- certificate yet (pick the zone `kw.example.com` belongs to), creates a
343
- tunnel named `kraftwerk-<project name>` (or `--name`), adds the CNAME from
344
- the hostname to it, and writes the result into kraftwerk.yml:
345
-
346
- ```yaml
347
- public: https://kw.example.com # the hostname the tunnel routes to
348
- tunnel:
349
- name: kraftwerk-agent-playground
350
- ```
351
-
352
- A second run reuses the login and the tunnel. If the hostname already has a
353
- DNS record, setup says so and `--overwrite-dns` replaces it. If cloudflared
354
- quietly routed `kw.example.com.other-zone.com` instead, because the hostname
355
- is outside the zone you logged in to, setup refuses and names the stray
356
- record to delete before you log in to the right zone and run it again.
357
-
358
- **2. Put a Cloudflare Access policy on the hostname.** The UI has no login
359
- of its own and its chat runs coding agents against the workspace, so the
360
- tunnel must never be the only thing between the internet and it. In the
361
- [Zero Trust dashboard](https://one.dash.cloudflare.com/) go to Access →
362
- Applications → Add an application → Self-hosted, enter `kw.example.com` as
363
- the domain, and add a policy: emails ending in your domain with a one-time
364
- PIN, a Google or GitHub login, whatever fits. Access is free for up to 50
365
- users. From then on Cloudflare shows a login page before anything reaches
366
- the tunnel.
367
-
368
- **3. Let the inspector verify that login.** On the application's overview
369
- page copy the Application Audience (AUD) tag, and take your team name from
370
- the team domain `https://<team>.cloudflareaccess.com`:
371
-
372
- ```yaml
373
- tunnel:
374
- name: kraftwerk-agent-playground
375
- access:
376
- team: my-team
377
- aud: 4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2
378
- ```
379
-
380
- With this block the inspector checks the `Cf-Access-Jwt-Assertion` token
381
- Access adds to every request, against the team's public keys, the audience
382
- tag and the clock. A removed or misconfigured policy then fails closed with
383
- a 401 instead of exposing the UI. Skipping the block works, and
384
- `kraftwerk doctor` warns about it every time.
385
-
386
- **4. Start.** `kraftwerk ui` now starts cloudflared with the inspector,
387
- keeps it across UI restarts and stops it with the UI:
388
-
389
- ```
390
- ✔ Kraftwerk UI: http://localhost:1981
391
- ✔ Public URL: https://kw.example.com
392
- ↗ tunnel: running kraftwerk-agent-playground → https://kw.example.com → http://127.0.0.1:1981
393
- cloudflared │ ... Registered tunnel connection ...
394
- ```
395
-
396
- Open `https://kw.example.com` from anywhere, log in through Access, and you
397
- are in the same inspector. `localhost:1981` on the machine itself keeps
398
- working without a login. `kraftwerk doctor` reports the tunnel, the Access
399
- block and whether cloudflared is installed.
400
-
401
- **Variants.** `kraftwerk tunnel` runs the configured tunnel alone, for an
402
- inspector that already runs elsewhere (started by `kraftwerk workspaces
403
- start`, or in a container). For a tunnel created in the Zero Trust
404
- dashboard instead (Networks → Tunnels), leave `name` out, route the
405
- hostname to `http://localhost:<port>` there and export its token as
406
- `TUNNEL_TOKEN` before `kraftwerk ui`. `public:` on its own, without
407
- `tunnel:`, is for any other proxy that forwards the browser's `Host`
408
- without setting `X-Forwarded-Host`.
409
-
410
- **Troubleshooting.** cloudflared's lines appear prefixed with
411
- `cloudflared │`. "Cannot determine default origin certificate path" means no
412
- login on this machine: run `cloudflared tunnel login` or setup again. "tunnel
413
- not found" means the name in kraftwerk.yml does not exist in the account
414
- that logged in; `cloudflared tunnel list` shows what does. A tunnel that
415
- dies three times right after launch is given up and the UI stays local
416
- until the next start. A 421 "unexpected Host header" in the browser means
417
- `public:` does not match the hostname you opened. A 401 means Access is
418
- configured in kraftwerk.yml but the token is missing or wrong: the
419
- hostname has no Access application, or `team`/`aud` do not match it.
420
-
421
- Quick tunnels (`trycloudflare.com`) are deliberately not supported: Access
422
- cannot be attached to them, which would leave the UI open to anyone with
423
- the URL.
424
-
425
413
  ### Inspector in the kraftwerk cloud
426
414
 
427
415
  The kraftwerk cloud is the `~/.kraftwerk` registry made reachable over the
@@ -429,8 +417,8 @@ internet: every `kraftwerk ui` registers there when it starts and sends a
429
417
  heartbeat while it runs, so you can see from anywhere which of your
430
418
  workspaces are up, on which machine, with which agents — and an admin sees
431
419
  every instance at once. **This is on by default.** The record carries the
432
- workspace name, icon and colour, the machine, the root, the version, the
433
- public URL if there is one, and the agent and channel roster. Registration
420
+ workspace name, icon and colour, the machine, the root, the version, and
421
+ the agent and channel roster. Registration
434
422
  is best-effort: a cloud that is down never affects the UI.
435
423
 
436
424
  No key goes into kraftwerk.yml. The cloud hands the instance an identity
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
+ }
@@ -0,0 +1,14 @@
1
+ import type { Command } from "commander";
2
+ /**
3
+ * `kraftwerk devices` — other machines (a phone, a second laptop, an app)
4
+ * that may use this machine's kraftwerk. One pairing covers every
5
+ * workspace here; the store is ~/.kraftwerk/devices.json.
6
+ *
7
+ * kraftwerk devices list paired devices
8
+ * kraftwerk devices pair a one-time code to pair one (10 minutes)
9
+ * kraftwerk devices revoke <id> unpair one: its token stops working at once
10
+ *
11
+ * A device reaches this machine when the UI listens on the network:
12
+ * `kraftwerk ui --lan` (or through a tunnel, see `kraftwerk tunnel`).
13
+ */
14
+ export declare function registerDevicesCommands(program: Command): void;
@@ -0,0 +1,53 @@
1
+ import chalk from "chalk";
2
+ import Table from "cli-table3";
3
+ import { createPairCode, listDevices, PAIR_CODE_TTL_MS, revokeDevice } from "../core/devices.js";
4
+ import { fmtAgo } from "./workspaces.js";
5
+ /**
6
+ * `kraftwerk devices` — other machines (a phone, a second laptop, an app)
7
+ * that may use this machine's kraftwerk. One pairing covers every
8
+ * workspace here; the store is ~/.kraftwerk/devices.json.
9
+ *
10
+ * kraftwerk devices list paired devices
11
+ * kraftwerk devices pair a one-time code to pair one (10 minutes)
12
+ * kraftwerk devices revoke <id> unpair one: its token stops working at once
13
+ *
14
+ * A device reaches this machine when the UI listens on the network:
15
+ * `kraftwerk ui --lan` (or through a tunnel, see `kraftwerk tunnel`).
16
+ */
17
+ export function registerDevicesCommands(program) {
18
+ const devices = program.command("devices").description("Paired devices: list, pair one, revoke one");
19
+ devices
20
+ .command("list", { isDefault: true })
21
+ .description("List paired devices")
22
+ .option("--json", "Machine-readable output")
23
+ .action(async (opts) => {
24
+ const list = await listDevices();
25
+ if (opts.json)
26
+ return void console.log(JSON.stringify(list, null, 2));
27
+ if (list.length === 0)
28
+ return void console.log(chalk.dim("No paired devices. `kraftwerk devices pair` makes a code for one."));
29
+ const table = new Table({ head: ["id", "name", "paired", "last seen"].map((h) => chalk.bold(h)) });
30
+ for (const d of list)
31
+ table.push([chalk.cyan(d.id), d.name, chalk.dim(fmtAgo(d.createdAt)), chalk.dim(d.lastSeenAt ? fmtAgo(d.lastSeenAt) : "never")]);
32
+ console.log(table.toString());
33
+ });
34
+ devices
35
+ .command("pair")
36
+ .description(`A one-time pairing code, valid ${PAIR_CODE_TTL_MS / 60_000} minutes`)
37
+ .action(async () => {
38
+ const { code } = await createPairCode();
39
+ console.log(`${chalk.green("✔")} pairing code ${chalk.bold.cyan(code)} ${chalk.dim(`— valid ${PAIR_CODE_TTL_MS / 60_000} minutes, once`)}`);
40
+ console.log(chalk.dim("Open kraftwerk on the device (start the UI with `kraftwerk ui --lan` to reach it on your network) and enter the code."));
41
+ });
42
+ devices
43
+ .command("revoke")
44
+ .description("Unpair a device: its token stops working at once")
45
+ .argument("<id>", "Device id as `kraftwerk devices` lists it")
46
+ .action(async (id) => {
47
+ if (!(await revokeDevice(id))) {
48
+ console.error(chalk.red(`No paired device "${id}".`));
49
+ process.exit(1);
50
+ }
51
+ console.log(`${chalk.green("✔")} unpaired ${chalk.cyan(id)}`);
52
+ });
53
+ }
@@ -78,14 +78,22 @@ async function oldRunFolders(outputDir) {
78
78
  ? [{ level: "info", label: `${old.length} run(s) in the old location`, detail: `${tildify(outputDir)}/run-* — still listed; new runs go to runs/, move them there to tidy up` }]
79
79
  : [];
80
80
  }
81
+ /** kraftwerk.yml keys from the Cloudflare Tunnel (removed in 0.65): the loader ignores them (`legacyKeys`). */
82
+ function oldConfigKeys(ws) {
83
+ const keys = ws.legacyKeys ?? [];
84
+ return keys.length
85
+ ? [{ level: "info", label: "kraftwerk.yml still has public:/tunnel:", detail: `${keys.map((k) => `${k}:`).join(", ")} — the Cloudflare Tunnel was removed in 0.65 and these keys are ignored; delete them` }]
86
+ : [];
87
+ }
81
88
  export async function legacyFindings(ws, version) {
82
89
  const output = existsSync(ws.outputDir) ? ws.outputDir : "";
83
90
  const found = [
84
91
  ...(await olderInstances(version)),
85
92
  ...installsOnPath(),
86
93
  ...(await oldRegistry()),
94
+ ...oldConfigKeys(ws),
87
95
  ...(output ? await oldChatScopes(output) : []),
88
96
  ...(output ? await oldRunFolders(output) : []),
89
97
  ];
90
- return found.length ? found : [{ level: "ok", label: "no legacy leftovers", detail: "no older servers, installs, registry or data formats" }];
98
+ return found.length ? found : [{ level: "ok", label: "no legacy leftovers", detail: "no older servers, installs, registry, config keys or data formats" }];
91
99
  }
@@ -2,11 +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, publicHostFor, reposRootFor, resolveWorkspace, tunnelFor } 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
+ import { listDevices } from "../core/devices.js";
11
+ import { slugClashes } from "../core/instances.js";
10
12
  const ICONS = {
11
13
  ok: chalk.green("✔"),
12
14
  warn: chalk.yellow("⚠"),
@@ -71,6 +73,13 @@ export async function runDoctor(cwd) {
71
73
  process.exit(1);
72
74
  }
73
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");
74
83
  // Project config: well-formed (guaranteed by the parse above) and the
75
84
  // configured paths actually exist.
76
85
  if (!project.configPath) {
@@ -79,7 +88,7 @@ export async function runDoctor(cwd) {
79
88
  else {
80
89
  const cfg = project.config;
81
90
  const keys = Object.keys(cfg);
82
- // Blocks (git, repos, vibeables, projects, tunnel) print as their keys, not [object Object].
91
+ // Blocks (git, repos, vibeables, projects, cloud) print as their keys, not [object Object].
83
92
  const show = (v) => typeof v === "object" && v !== null
84
93
  ? Array.isArray(v)
85
94
  ? `[${v.length}]`
@@ -112,7 +121,7 @@ export async function runDoctor(cwd) {
112
121
  report("ok", `${DOTENV_FILE}: ${names.length} variable(s) loaded`, names.join(", ") || "empty");
113
122
  }
114
123
  else {
115
- report("info", `no ${DOTENV_FILE}`, "variables for agents, workflows and the tunnel can live there — loaded on every start and restart");
124
+ report("info", `no ${DOTENV_FILE}`, "variables for agents and workflows can live there — loaded on every start and restart");
116
125
  }
117
126
  // Repositories: the clones root must stay out of the workspace git. git
118
127
  // itself decides — that covers worktrees (.git is a file), a workspace
@@ -131,33 +140,6 @@ export async function runDoctor(cwd) {
131
140
  else
132
141
  report("ok", label, "not inside a git repository");
133
142
  }
134
- // Public hostname + Cloudflare Tunnel. The UI has no login of its own, so
135
- // a tunnel without Access verification is worth a warning every time.
136
- const publicHost = publicHostFor(project);
137
- const tunnel = tunnelFor(project);
138
- if (publicHost && !tunnel)
139
- report("info", `public: ${publicHost}`, "served when a tunnel or reverse proxy delivers that Host");
140
- if (tunnel) {
141
- const cloudflared = cliVersion("cloudflared");
142
- if (cloudflared)
143
- report("ok", `cloudflared`, cloudflared);
144
- else {
145
- report("fail", "cloudflared missing", "needed by tunnel: — brew install cloudflared (or see developers.cloudflare.com)");
146
- failures++;
147
- }
148
- if (tunnel.name)
149
- report("ok", `tunnel: ${tunnel.name} → ${publicHost}`, `cloudflared tunnel run --url http://127.0.0.1:${project.config.port ?? 1981} ${tunnel.name}`);
150
- else if (process.env.TUNNEL_TOKEN)
151
- report("ok", `tunnel: dashboard-managed → ${publicHost}`, "TUNNEL_TOKEN is set");
152
- else {
153
- report("fail", "tunnel: nothing to run", "set tunnel.name (locally-managed) or the TUNNEL_TOKEN env var (dashboard-managed)");
154
- failures++;
155
- }
156
- if (tunnel.access)
157
- report("ok", "tunnel.access", `tokens verified against ${tunnel.access.team}.cloudflareaccess.com`);
158
- else
159
- report("warn", "tunnel without access", "the UI has no login of its own — put a Cloudflare Access policy on the hostname and set tunnel.access so a removed policy fails closed");
160
- }
161
143
  // Cloud manager: registration is best-effort at runtime, so doctor only says what would happen.
162
144
  const cloud = cloudFor(project);
163
145
  if (cloud) {
@@ -223,6 +205,11 @@ export async function runDoctor(cwd) {
223
205
  else
224
206
  report("warn", `${e.workflow.name}: env missing`, missing.join(", "));
225
207
  }
208
+ // Devices: who may use this machine's kraftwerk from elsewhere.
209
+ const devices = await listDevices();
210
+ if (process.env.KRAFTWERK_UI_TRUST_NETWORK === "1")
211
+ report("warn", "KRAFTWERK_UI_TRUST_NETWORK=1", "anyone who reaches the UI over the network uses it without pairing — unset it and pair devices instead (`kraftwerk devices pair`)");
212
+ report("info", devices.length ? `${devices.length} paired device(s)` : "no paired devices", devices.length ? devices.map((d) => d.name).join(", ") : "a phone or another computer can use kraftwerk once paired: `kraftwerk ui --lan`, then `kraftwerk devices pair`");
226
213
  // Legacy: what older versions left behind, and what still runs or is installed at one.
227
214
  for (const f of await legacyFindings(project, pkg.version))
228
215
  report(f.level, f.label, f.detail);
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
@@ -27,12 +28,6 @@ skills: ${DATA_DIR}/skills # workspace skills (shared instruction packag
27
28
  # root: ${DATA_DIR}/repos # clones land here (git-ignored); a bare \`repos:\` uses repos/ instead
28
29
  # vibeables: # small apps built live in a chat with a preview pane (uncomment to enable; one folder per app under ${DATA_DIR}/vibeables, versioned with the workspace)
29
30
  # projects: # goal-scoped folders — brief, systems of record, links to knowledge/vibeables/repos/workflows/agents — that chats work in (uncomment to enable; one folder per project under ${DATA_DIR}/projects)
30
- # public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
31
- # tunnel: # Cloudflare Tunnel run by \`kraftwerk ui\` (needs public:; put a Cloudflare Access policy on the hostname — the UI has no login of its own)
32
- # name: kraftwerk # locally-managed tunnel (cloudflared tunnel create/route dns); omit and export TUNNEL_TOKEN for a dashboard-managed one
33
- # access: # verify the Access login on every request via public: (team = Zero Trust team name, aud = the application's audience tag)
34
- # team: my-team
35
- # aud: 4714c1358e65fe4b408ad6d432a5f878f08194bdb4752441fd56faefa9b2b6f2
36
31
  # cloud: # the kraftwerk cloud is ON BY DEFAULT: \`kraftwerk ui\` registers this workspace there and shows a claim code under Settings → Cloud
37
32
  # enabled: false # uncomment both lines to keep this workspace out of the cloud (or set KRAFTWERK_CLOUD_URL=off for the whole machine)
38
33
  `;