@netnodeag/kraftwerk 0.68.0 → 0.69.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.
package/README.md CHANGED
@@ -95,13 +95,18 @@ you when it opens.
95
95
  `kraftwerk ui` hands the workspace to the daemon whenever one is running;
96
96
  `kraftwerk ui --standalone` starts a server of its own anyway.
97
97
 
98
- **From your phone or another computer.** Add `--lan` (`kraftwerk ui --lan`,
99
- or `kraftwerk daemon --lan`) and kraftwerk also listens on your network.
100
- Anyone who opens it there gets a pairing screen, nothing else, until you
101
- pair that device: `kraftwerk devices pair` on this machine prints a
102
- one-time code. A paired device reaches the daemon's workspaces at
103
- `/w/<slug>/` on the address it used, and the workspace menu links them
104
- that way (see [Inspector on your phone](#inspector-on-your-phone-paired-devices)).
98
+ **From your phone or another computer** on the same network:
99
+
100
+ 1. Let the daemon listen on your network: `kraftwerk daemon install --lan`
101
+ (or run `kraftwerk daemon --lan`). It prints the address, for example
102
+ `http://192.168.1.20:1980`.
103
+ 2. Open that address on the phone. It shows a pairing form, nothing else.
104
+ 3. On this computer, run `kraftwerk devices pair` (or settings → devices →
105
+ pair a device) and type the code on the phone.
106
+
107
+ The phone now sees your workspaces, each at `/w/<slug>/` on that address,
108
+ and the workspace menu switches between them. One pairing covers all of them
109
+ (see [Inspector on your phone](#inspector-on-your-phone-paired-devices)).
105
110
 
106
111
  **What to do first in the inspector.** Talk to **Ralv**, the workspace's
107
112
  general chat, in plain words: it knows what is in the workspace and helps
@@ -231,7 +236,7 @@ kraftwerk runs # past runs from output/*/trace.jsonl; r
231
236
  kraftwerk knowledge # Knowledge: OKF bundles (list/get/put/verify/search/...)
232
237
  kraftwerk ui # start this workspace: http://localhost:1981 (or open it in the daemon); --port, --lan, --standalone
233
238
  kraftwerk daemon # one kraftwerk for every open workspace: http://<slug>.localhost:1980; --port, --lan
234
- kraftwerk daemon install # start the daemon at login (macOS LaunchAgent); `daemon uninstall` removes it
239
+ kraftwerk daemon install # start the daemon at login (macOS LaunchAgent); --lan, --port; `daemon uninstall` removes it
235
240
  kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
236
241
  kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
237
242
  kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
@@ -384,13 +389,15 @@ follows to author the workflow folder and validate and smoke it with this CLI.
384
389
 
385
390
  ### Inspector on your phone (paired devices)
386
391
 
387
- `kraftwerk ui --lan` (or `kraftwerk daemon --lan`) also listens on your
388
- network and prints the addresses (for example `http://192.168.1.20:1981`).
389
- On the daemon, a device opens a workspace at `/w/<slug>/` on that address
392
+ `kraftwerk ui --lan` (or `kraftwerk daemon --lan`, `kraftwerk daemon install
393
+ --lan` for the login item) also listens on your network and prints the
394
+ addresses (for example `http://192.168.1.20:1981`). On the daemon, a device
395
+ opens a workspace at `/w/<slug>/` on that address
390
396
  (`http://192.168.1.20:1980/w/team-blau/`), because `<slug>.localhost` only
391
- works on this machine. The daemon's own address lists them, and the
392
- workspace menu links the others the same way. Anyone who opens one gets a
393
- pairing screen, nothing else. To let a device in, make a one-time code on
397
+ works on this machine. The daemon's own address lists them once the device
398
+ is paired, and pairs it when it is not, and the workspace menu links the
399
+ others the same way. Anyone who opens one gets a pairing screen, nothing
400
+ else. To let a device in, make a one-time code on
394
401
  the machine kraftwerk runs on, with `kraftwerk devices pair` or settings →
395
402
  devices → pair a device, and enter it on the device. The code works once,
396
403
  for 10 minutes. The device stays paired until you unpair it in the same
@@ -12,6 +12,7 @@ export declare const LAUNCH_LABEL = "ch.netnode.kraftwerk.daemon";
12
12
  export declare function renderPlist(program: string[], env: Record<string, string>, log: string): string;
13
13
  export declare function installDaemon(opts: {
14
14
  port?: string;
15
+ lan?: boolean;
15
16
  }): Promise<void>;
16
17
  export declare function uninstallDaemon(): Promise<void>;
17
18
  /** For doctor: whether the login item is installed. */
@@ -62,7 +62,7 @@ function requireMac() {
62
62
  }
63
63
  export async function installDaemon(opts) {
64
64
  requireMac();
65
- const { cmd, args } = selfCommand(["daemon", ...(opts.port ? ["--port", opts.port] : [])]);
65
+ const { cmd, args } = selfCommand(["daemon", ...(opts.port ? ["--port", opts.port] : []), ...(opts.lan ? ["--lan"] : [])]);
66
66
  const env = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
67
67
  if (process.env.KRAFTWERK_DAEMON_PORT && !opts.port)
68
68
  env.KRAFTWERK_DAEMON_PORT = process.env.KRAFTWERK_DAEMON_PORT;
@@ -86,6 +86,8 @@ export async function installDaemon(opts) {
86
86
  process.exit(1);
87
87
  }
88
88
  console.log(`${chalk.green("✔")} login item installed and started: ${chalk.dim(tildify(file))}`);
89
+ if (opts.lan)
90
+ console.log(chalk.dim(" on your network too: devices pair first (`kraftwerk devices pair`)"));
89
91
  console.log(chalk.dim(` log: ${tildify(logPath())} · \`kraftwerk daemon uninstall\` removes it`));
90
92
  }
91
93
  export async function uninstallDaemon() {
@@ -62,9 +62,11 @@ export function registerDaemonCommand(program) {
62
62
  .command("install")
63
63
  .description("Start the daemon at login (macOS LaunchAgent), and now unless one runs")
64
64
  .option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
65
+ .option("--lan", "Listen on the network too, for paired devices (a phone on the same Wi-Fi)")
65
66
  .action(async (opts) => {
66
- // `--port` after `install` is taken by `daemon` itself, which has the same option.
67
- await installDaemon({ port: opts.port ?? daemon.opts().port });
67
+ // `--port`/`--lan` after `install` are taken by `daemon` itself, which has the same options.
68
+ const parent = daemon.opts();
69
+ await installDaemon({ port: opts.port ?? parent.port, lan: opts.lan ?? parent.lan });
68
70
  });
69
71
  daemon
70
72
  .command("uninstall")
@@ -169,8 +169,8 @@ function installExitHandlers() {
169
169
  });
170
170
  }
171
171
  /**
172
- * Listen on `port`. On the default bind (127.0.0.1) the same port on ::1
173
- * feeds the same server: macOS resolves localhost and <slug>.localhost to
172
+ * Listen on `port`. On the default bind (127.0.0.1), and on 0.0.0.0
173
+ * (`--lan`, IPv4 only), the same port on ::1 feeds the same server: macOS resolves localhost and <slug>.localhost to
174
174
  * ::1 first, and a client that does not fall back to IPv4 (a native app's
175
175
  * HTTP library) would find nothing there. Best effort — without IPv6, or
176
176
  * with ::1:port taken, it stays IPv4 only, as before.
@@ -184,7 +184,7 @@ async function listen(server, port) {
184
184
  resolve(typeof addr === "object" && addr ? addr.port : port);
185
185
  });
186
186
  });
187
- if (INSPECTOR_HOST !== "127.0.0.1")
187
+ if (INSPECTOR_HOST !== "127.0.0.1" && INSPECTOR_HOST !== "0.0.0.0")
188
188
  return bound;
189
189
  const twin = net.createServer((socket) => server.emit("connection", socket));
190
190
  const ok = await new Promise((resolve) => {
@@ -205,18 +205,35 @@ async function listen(server, port) {
205
205
  /**
206
206
  * A small page at a daemon's own address: the open workspaces, each a link —
207
207
  * <slug>.localhost on this machine, the path form on the address a paired
208
- * device used (it cannot resolve <slug>.localhost).
208
+ * device used (it cannot resolve <slug>.localhost). A device that is not
209
+ * paired yet gets a pairing form instead, and no workspace names: the code
210
+ * comes from this machine (`kraftwerk devices pair`), and the cookie it earns
211
+ * covers every workspace on this address.
209
212
  */
210
- function hubPage(req, res, open) {
213
+ function hubPage(req, res, open, trust) {
211
214
  const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
212
215
  // Behind a proxy on this machine the browser's name is the forwarded one.
213
216
  const host = hostnameOf(forwardedHost(req) ?? req.headers.host ?? "");
214
217
  const local = LOOPBACK_NAMES.has(host) || host.endsWith(".localhost");
218
+ const head = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>kraftwerk</title>` +
219
+ `<style>:root{color-scheme:light dark}body{font:16px/1.6 system-ui,sans-serif;max-width:560px;margin:40px auto;padding:0 16px}` +
220
+ `code{opacity:.7;font-size:13px}ul{list-style:none;padding:0}li{border-bottom:1px solid #8884}li a{display:block;padding:12px 0;font-weight:600;text-decoration:none;color:inherit}` +
221
+ `input,button{font:inherit;padding:10px 12px;border-radius:10px;border:1px solid #8888;width:100%;box-sizing:border-box;margin:6px 0}` +
222
+ `input[name=code]{letter-spacing:.2em;text-transform:uppercase}button{background:#5b4bd5;color:#fff;border:0;font-weight:600}#err{color:#c62828}</style><h1>kraftwerk</h1>`;
223
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
224
+ if (trust.kind === "none") {
225
+ return void res.end(head +
226
+ `<p>Pair this device to use kraftwerk on this computer. On the computer, run <code>kraftwerk devices pair</code> (or settings → devices → pair a device) and enter the code here.</p>` +
227
+ `<form id="pair"><input name="code" placeholder="code" autocomplete="one-time-code" autocapitalize="characters" required>` +
228
+ `<input name="name" placeholder="this device's name, e.g. Lukas' iPhone"><button>pair</button><p id="err"></p></form>` +
229
+ `<script>document.getElementById("pair").onsubmit=async(e)=>{e.preventDefault();const f=new FormData(e.target);` +
230
+ `const r=await fetch("/api/pair",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({code:String(f.get("code")).trim(),name:String(f.get("name")||navigator.platform||"device")})});` +
231
+ `if(r.ok)location.reload();else document.getElementById("err").textContent=(await r.json().catch(()=>({}))).error||"pairing failed"}</script>`);
232
+ }
215
233
  const rows = open.length
216
- ? open.map((o) => `<li><a href="${esc(local ? `${o.url}/` : `/w/${o.slug}/`)}">${esc(o.name)}</a> <code>${esc(o.slug)}</code></li>`).join("")
234
+ ? open.map((o) => `<li><a href="${esc(local ? `${o.url}/` : `/w/${o.slug}/`)}">${esc(o.name)} <code>${esc(o.slug)}</code></a></li>`).join("")
217
235
  : "<li>No workspace is open. Run <code>kraftwerk ui</code> in a workspace folder.</li>";
218
- res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
219
- res.end(`<!doctype html><meta charset="utf-8"><title>kraftwerk</title><style>body{font:15px/1.6 system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px;color:#1b1b1f}code{color:#46464f}</style><h1>kraftwerk</h1><ul>${rows}</ul>`);
236
+ res.end(`${head}<ul>${rows}</ul>`);
220
237
  }
221
238
  export async function startHub(opts) {
222
239
  const trustOpts = { trustLoopback: opts.trustLoopback !== false && !REQUIRE_PAIRING, trustNetwork: TRUST_NETWORK };
@@ -260,7 +277,7 @@ export async function startHub(opts) {
260
277
  }
261
278
  // A daemon's own address lists its workspaces; anywhere else, the web UI's shell (public: an unpaired browser needs it to pair).
262
279
  if (!open && (url.pathname === "/" || url.pathname === "/index.html"))
263
- return hubPage(req, res, [...bySlug.values()]);
280
+ return hubPage(req, res, [...bySlug.values()], trust);
264
281
  await serveStatic(res, opts.staticDir, url.pathname);
265
282
  }
266
283
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
5
5
  "keywords": [
6
6
  "agents",