@netnodeag/kraftwerk 0.66.0 → 0.68.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 (31) hide show
  1. package/README.md +25 -8
  2. package/dist/cli/daemon-login.d.ts +18 -0
  3. package/dist/cli/daemon-login.js +104 -0
  4. package/dist/cli/daemon.js +17 -2
  5. package/dist/cli/doctor.js +12 -0
  6. package/dist/core/instances.d.ts +2 -0
  7. package/dist/core/instances.js +6 -2
  8. package/dist/core/search.d.ts +2 -0
  9. package/dist/core/search.js +1 -0
  10. package/dist/server/api/index.d.ts +11 -2
  11. package/dist/server/api/workspace.d.ts +15 -2
  12. package/dist/server/api/workspace.js +43 -4
  13. package/dist/server/server.d.ts +2 -0
  14. package/dist/server/server.js +47 -12
  15. package/package.json +1 -1
  16. package/web/dist/assets/{dist-XyTHWtoN.js → dist-BMZBs9W4.js} +1 -1
  17. package/web/dist/assets/{dist-C0M2aITo.js → dist-BkZKygT6.js} +1 -1
  18. package/web/dist/assets/{dist-CYRrdlae.js → dist-CZjwFXw-.js} +1 -1
  19. package/web/dist/assets/{dist-DCYVKU-H.js → dist-D7BYSDIr.js} +1 -1
  20. package/web/dist/assets/dist-DZLlNafq.js +1 -0
  21. package/web/dist/assets/{dist-Bk3f-J5U.js → dist-D_nqqdyh.js} +1 -1
  22. package/web/dist/assets/{dist-COEgBKG6.js → dist-IO8GeoYh.js} +1 -1
  23. package/web/dist/assets/{dist-CbEFnSCD.js → dist-LUKHNpC7.js} +1 -1
  24. package/web/dist/assets/{dist-Bx99Puvv.js → dist-Y0KLvK_g.js} +1 -1
  25. package/web/dist/assets/{dist-C0z19Zge.js → dist-aMqhK50K.js} +1 -1
  26. package/web/dist/assets/{dist-1bNkaWWA.js → dist-jyZ9dxfk.js} +1 -1
  27. package/web/dist/assets/{editor-DPcGvSPy.js → editor-DQouCIUq.js} +3 -3
  28. package/web/dist/assets/index-D57IrV_V.js +229 -0
  29. package/web/dist/index.html +1 -1
  30. package/web/dist/assets/dist-BU1SOB5S.js +0 -1
  31. package/web/dist/assets/index-DKLJdwYE.js +0 -229
package/README.md CHANGED
@@ -65,11 +65,12 @@ foreground; Ctrl-C stops it. That is all most people need.
65
65
 
66
66
  **Several workspaces: `kraftwerk daemon`.** With more than one workspace,
67
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):
68
+ Start the daemon once and leave it running, or let your Mac start it at
69
+ login:
70
70
 
71
71
  ```bash
72
72
  kraftwerk daemon # the machine's kraftwerk, on http://localhost:1980
73
+ kraftwerk daemon install # or: start it at login from now on (macOS; `uninstall` undoes it)
73
74
 
74
75
  cd ~/work/team-blau && kraftwerk ui # opens this workspace in the daemon:
75
76
  # http://team-blau.localhost:1980
@@ -87,7 +88,10 @@ reaches the same workspace. `http://localhost:1980` lists what is open.
87
88
  A workspace with a `port:` of its own is served there too, so old links
88
89
  keep working. The daemon remembers what was open and opens it again when it
89
90
  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
+ inside it, with its own `.env`, routines, git sync and chats. The
92
+ workspace menu in the header switches between them, and starts a stopped
93
+ one right in the daemon. The Kraftwerk desktop app starts the daemon for
94
+ you when it opens.
91
95
  `kraftwerk ui` hands the workspace to the daemon whenever one is running;
92
96
  `kraftwerk ui --standalone` starts a server of its own anyway.
93
97
 
@@ -95,7 +99,9 @@ inside it, with its own `.env`, routines, git sync and chats.
95
99
  or `kraftwerk daemon --lan`) and kraftwerk also listens on your network.
96
100
  Anyone who opens it there gets a pairing screen, nothing else, until you
97
101
  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)).
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)).
99
105
 
100
106
  **What to do first in the inspector.** Talk to **Ralv**, the workspace's
101
107
  general chat, in plain words: it knows what is in the workspace and helps
@@ -225,6 +231,7 @@ kraftwerk runs # past runs from output/*/trace.jsonl; r
225
231
  kraftwerk knowledge # Knowledge: OKF bundles (list/get/put/verify/search/...)
226
232
  kraftwerk ui # start this workspace: http://localhost:1981 (or open it in the daemon); --port, --lan, --standalone
227
233
  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
228
235
  kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
229
236
  kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
230
237
  kraftwerk projects # goal-scoped project folders; projects create|show|link|log|remove
@@ -286,8 +293,14 @@ workspace's inspector gets its own `.env`, not the other one's. The file is
286
293
  never synced (it is on the workspace git's deny list and in the .gitignore
287
294
  `kraftwerk init` writes); `kraftwerk doctor` lists the names it loaded.
288
295
 
289
- `switcher:` links other kraftwerk workspaces from the inspector header. The
290
- workspace name becomes a dropdown listing them:
296
+ The workspace name in the inspector header is the **workspace menu**. It
297
+ lists every workspace this machine knows. Running ones open with a click,
298
+ and a stopped one starts from its row (in the daemon, when one runs). Each
299
+ link uses the address you opened this page with: `<slug>.localhost` on this
300
+ machine, `/w/<slug>/` from a paired device on the network, the path form
301
+ when you are in it.
302
+ `switcher:` adds workspaces kraftwerk cannot discover, such as one on
303
+ another machine:
291
304
 
292
305
  ```yaml
293
306
  switcher:
@@ -371,8 +384,12 @@ follows to author the workflow folder and validate and smoke it with this CLI.
371
384
 
372
385
  ### Inspector on your phone (paired devices)
373
386
 
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
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
390
+ (`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
376
393
  pairing screen, nothing else. To let a device in, make a one-time code on
377
394
  the machine kraftwerk runs on, with `kraftwerk devices pair` or settings →
378
395
  devices → pair a device, and enter it on the device. The code works once,
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `kraftwerk daemon install` / `uninstall`: the daemon as a macOS login item
3
+ * (a LaunchAgent), so it runs after a reboot without a terminal or the
4
+ * desktop app. launchd starts the same `kraftwerk daemon` a terminal would —
5
+ * this node, this install — with the PATH of the shell that installed it,
6
+ * because launchd's own PATH would not find the agents (claude, codex, pi).
7
+ * The daemon's supervisor restarts it after a self-update; launchd only
8
+ * starts it at login.
9
+ */
10
+ export declare const LAUNCH_LABEL = "ch.netnode.kraftwerk.daemon";
11
+ /** The LaunchAgent: `program` at login, `env` set, output appended to `log`. */
12
+ export declare function renderPlist(program: string[], env: Record<string, string>, log: string): string;
13
+ export declare function installDaemon(opts: {
14
+ port?: string;
15
+ }): Promise<void>;
16
+ export declare function uninstallDaemon(): Promise<void>;
17
+ /** For doctor: whether the login item is installed. */
18
+ export declare const daemonLoginItem: () => string | null;
@@ -0,0 +1,104 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import chalk from "chalk";
6
+ import { findDaemon } from "../core/daemon.js";
7
+ import { selfCommand } from "../core/self-command.js";
8
+ import { tildify } from "../core/instances.js";
9
+ /**
10
+ * `kraftwerk daemon install` / `uninstall`: the daemon as a macOS login item
11
+ * (a LaunchAgent), so it runs after a reboot without a terminal or the
12
+ * desktop app. launchd starts the same `kraftwerk daemon` a terminal would —
13
+ * this node, this install — with the PATH of the shell that installed it,
14
+ * because launchd's own PATH would not find the agents (claude, codex, pi).
15
+ * The daemon's supervisor restarts it after a self-update; launchd only
16
+ * starts it at login.
17
+ */
18
+ export const LAUNCH_LABEL = "ch.netnode.kraftwerk.daemon";
19
+ const plistPath = () => path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCH_LABEL}.plist`);
20
+ const logPath = () => path.join(os.homedir(), ".kraftwerk", "logs", "daemon.log");
21
+ /** A stand-in launchctl (tests); the real one otherwise. */
22
+ const launchctl = () => process.env.KRAFTWERK_LAUNCHCTL || "launchctl";
23
+ const domain = () => `gui/${os.userInfo().uid}`;
24
+ const xml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]);
25
+ /** The LaunchAgent: `program` at login, `env` set, output appended to `log`. */
26
+ export function renderPlist(program, env, log) {
27
+ const strings = (xs) => xs.map((x) => `\t\t<string>${xml(x)}</string>`).join("\n");
28
+ const vars = Object.entries(env)
29
+ .map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`)
30
+ .join("\n");
31
+ return `<?xml version="1.0" encoding="UTF-8"?>
32
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
+ <plist version="1.0">
34
+ <dict>
35
+ \t<key>Label</key>
36
+ \t<string>${LAUNCH_LABEL}</string>
37
+ \t<key>ProgramArguments</key>
38
+ \t<array>
39
+ ${strings(program)}
40
+ \t</array>
41
+ \t<key>EnvironmentVariables</key>
42
+ \t<dict>
43
+ ${vars}
44
+ \t</dict>
45
+ \t<key>WorkingDirectory</key>
46
+ \t<string>${xml(os.homedir())}</string>
47
+ \t<key>RunAtLoad</key>
48
+ \t<true/>
49
+ \t<key>StandardOutPath</key>
50
+ \t<string>${xml(log)}</string>
51
+ \t<key>StandardErrorPath</key>
52
+ \t<string>${xml(log)}</string>
53
+ </dict>
54
+ </plist>
55
+ `;
56
+ }
57
+ function requireMac() {
58
+ if (process.platform !== "darwin") {
59
+ console.error(chalk.red("✖ a login item is macOS only for now — run `kraftwerk daemon` from your system's startup instead"));
60
+ process.exit(2);
61
+ }
62
+ }
63
+ export async function installDaemon(opts) {
64
+ requireMac();
65
+ const { cmd, args } = selfCommand(["daemon", ...(opts.port ? ["--port", opts.port] : [])]);
66
+ const env = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
67
+ if (process.env.KRAFTWERK_DAEMON_PORT && !opts.port)
68
+ env.KRAFTWERK_DAEMON_PORT = process.env.KRAFTWERK_DAEMON_PORT;
69
+ const file = plistPath();
70
+ await fs.mkdir(path.dirname(file), { recursive: true });
71
+ await fs.mkdir(path.dirname(logPath()), { recursive: true });
72
+ await fs.writeFile(file, renderPlist([cmd, ...args], env, logPath()));
73
+ // An older version of the item is replaced: unload it first (an error just means it was not loaded).
74
+ spawnSync(launchctl(), ["bootout", `${domain()}/${LAUNCH_LABEL}`], { stdio: "ignore" });
75
+ const running = await findDaemon();
76
+ if (running) {
77
+ // Loading now would start a second daemon, which refuses to run. launchd loads the file at the next login.
78
+ console.log(`${chalk.green("✔")} login item installed: ${chalk.dim(tildify(file))}`);
79
+ console.log(chalk.dim(` a daemon already runs (pid ${running.pid}); from the next login on, launchd starts it`));
80
+ return;
81
+ }
82
+ const r = spawnSync(launchctl(), ["bootstrap", domain(), file], { encoding: "utf8" });
83
+ if (r.status !== 0) {
84
+ console.error(chalk.red(`✖ launchctl bootstrap failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`));
85
+ console.error(chalk.dim(` the file is in place (${tildify(file)}) and loads at the next login`));
86
+ process.exit(1);
87
+ }
88
+ console.log(`${chalk.green("✔")} login item installed and started: ${chalk.dim(tildify(file))}`);
89
+ console.log(chalk.dim(` log: ${tildify(logPath())} · \`kraftwerk daemon uninstall\` removes it`));
90
+ }
91
+ export async function uninstallDaemon() {
92
+ requireMac();
93
+ const file = plistPath();
94
+ if (!existsSync(file)) {
95
+ console.log(chalk.dim("No login item installed."));
96
+ return;
97
+ }
98
+ // Unloading stops the daemon launchd started; one started from a terminal keeps running.
99
+ spawnSync(launchctl(), ["bootout", `${domain()}/${LAUNCH_LABEL}`], { stdio: "ignore" });
100
+ await fs.rm(file, { force: true });
101
+ console.log(`${chalk.green("✔")} login item removed ${chalk.dim(`(${tildify(file)})`)}`);
102
+ }
103
+ /** For doctor: whether the login item is installed. */
104
+ export const daemonLoginItem = () => (existsSync(plistPath()) ? plistPath() : null);
@@ -4,6 +4,7 @@ import { tildify } from "../core/instances.js";
4
4
  import { networkUrls } from "../server/bind.js";
5
5
  import { startHub } from "../server/server.js";
6
6
  import { ensureBuilt, supervise } from "./ui.js";
7
+ import { installDaemon, uninstallDaemon } from "./daemon-login.js";
7
8
  /**
8
9
  * `kraftwerk daemon` — the machine's kraftwerk: one process serving every
9
10
  * open workspace, each at `<slug>.localhost:<port>` (and its own port, when
@@ -49,12 +50,26 @@ export async function runDaemon(opts) {
49
50
  console.log(`${chalk.green("✔")} On your network: ${lan.map((u) => chalk.cyan(u)).join(", ")} ${chalk.dim("(add /w/<slug>/; devices pair first)")}`);
50
51
  }
51
52
  export function registerDaemonCommand(program) {
52
- program
53
+ const daemon = program
53
54
  .command("daemon")
54
- .description("Run the machine's kraftwerk: one process serving every open workspace at <slug>.localhost")
55
+ .description("Run the machine's kraftwerk: one process serving every open workspace at <slug>.localhost; install it as a login item")
55
56
  .option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
56
57
  .option("--lan", "Listen on the network too, for paired devices")
57
58
  .action(async (opts) => {
58
59
  await runDaemon(opts);
59
60
  });
61
+ daemon
62
+ .command("install")
63
+ .description("Start the daemon at login (macOS LaunchAgent), and now unless one runs")
64
+ .option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
65
+ .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 });
68
+ });
69
+ daemon
70
+ .command("uninstall")
71
+ .description("Remove the login item (stops a daemon launchd started)")
72
+ .action(async () => {
73
+ await uninstallDaemon();
74
+ });
60
75
  }
@@ -9,6 +9,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
11
  import { slugClashes } from "../core/instances.js";
12
+ import { findDaemon } from "../core/daemon.js";
13
+ import { daemonLoginItem } from "./daemon-login.js";
12
14
  const ICONS = {
13
15
  ok: chalk.green("✔"),
14
16
  warn: chalk.yellow("⚠"),
@@ -210,6 +212,16 @@ export async function runDoctor(cwd) {
210
212
  if (process.env.KRAFTWERK_UI_TRUST_NETWORK === "1")
211
213
  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
214
  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`");
215
+ // The daemon: whether one runs, and whether it starts at login.
216
+ const daemon = await findDaemon();
217
+ const loginItem = daemonLoginItem();
218
+ report("info", daemon ? `kraftwerk daemon running on :${daemon.port}` : "no kraftwerk daemon running", loginItem
219
+ ? "starts at login (`kraftwerk daemon uninstall` removes that)"
220
+ : process.platform === "darwin"
221
+ ? "`kraftwerk daemon install` starts it at login"
222
+ : daemon
223
+ ? undefined
224
+ : "`kraftwerk daemon` serves every workspace in one process");
213
225
  // Legacy: what older versions left behind, and what still runs or is installed at one.
214
226
  for (const f of await legacyFindings(project, pkg.version))
215
227
  report(f.level, f.label, f.detail);
@@ -53,6 +53,8 @@ export interface WorkspaceEntry {
53
53
  color?: string;
54
54
  /** True when the name comes from kraftwerk.yml, false when it is just the folder name. */
55
55
  named?: boolean;
56
+ /** Its slug (kraftwerk.yml `slug`, else the folder name): how a daemon addresses it. */
57
+ slug?: string;
56
58
  live: boolean;
57
59
  root?: string;
58
60
  /** Root with ~ for the home dir — what the UI prints. */
@@ -335,7 +335,7 @@ export async function forgetWorkspace(root) {
335
335
  * so without this check a stale record would wear its parent's identity.
336
336
  */
337
337
  async function describeRoot(root) {
338
- const fallback = { name: path.basename(root), named: false, port: 1981, exists: false };
338
+ const fallback = { name: path.basename(root), named: false, slug: slugFromFolder(path.basename(root)), port: 1981, exists: false };
339
339
  if (!(await isDir(root)))
340
340
  return fallback;
341
341
  try {
@@ -345,6 +345,7 @@ async function describeRoot(root) {
345
345
  return {
346
346
  name: project.config.name ?? path.basename(project.root),
347
347
  named: !!project.config.name,
348
+ slug: workspaceSlug(project),
348
349
  icon: project.config.icon || undefined,
349
350
  color: project.config.color || undefined,
350
351
  port: project.config.port ?? 1981,
@@ -396,11 +397,12 @@ export async function discoverWorkspaces() {
396
397
  icon: running.icon,
397
398
  color: running.color ?? d?.color,
398
399
  named: running.named ?? d?.named,
400
+ slug: running.slug ?? recordSlug(p),
399
401
  live: true,
400
402
  exists: true,
401
403
  };
402
404
  }
403
- return { ...base, name: d.name, url: `http://localhost:${d.port}`, icon: d.icon, color: d.color, named: d.named, live: false, exists: d.exists };
405
+ return { ...base, name: d.name, url: `http://localhost:${d.port}`, icon: d.icon, color: d.color, named: d.named, slug: d.slug, live: false, exists: d.exists };
404
406
  }))).concat(await Promise.all(live
405
407
  .filter((i) => !consumed.has(i))
406
408
  .map(async (i) => ({
@@ -409,6 +411,7 @@ export async function discoverWorkspaces() {
409
411
  icon: i.icon,
410
412
  color: i.color,
411
413
  named: i.named,
414
+ slug: i.slug,
412
415
  live: true,
413
416
  root: i.root,
414
417
  rootLabel: i.root ? tildify(i.root) : undefined,
@@ -461,6 +464,7 @@ export async function listWorkspacesDetailed() {
461
464
  icon: d.icon,
462
465
  color: d.color,
463
466
  named: d.named,
467
+ slug: d.slug,
464
468
  url: mine.url,
465
469
  live: true,
466
470
  root: me,
@@ -14,6 +14,8 @@ export interface WorkspaceAgents {
14
14
  icon?: string;
15
15
  /** Where the workspace's UI lives — hits link to `${url}/#/agents/<slug>`. */
16
16
  url: string;
17
+ /** Its slug: how a daemon addresses it. */
18
+ slug?: string;
17
19
  /** Project root (what /api/projects/start takes for a stopped workspace). */
18
20
  root?: string;
19
21
  /** The instance answering this request: hits navigate in place. */
@@ -26,6 +26,7 @@ export async function searchAgents() {
26
26
  name: w.name,
27
27
  icon: w.icon,
28
28
  url: w.url,
29
+ slug: w.slug,
29
30
  root: w.root,
30
31
  current: false,
31
32
  live: w.live,
@@ -481,7 +481,11 @@ export declare const routes: (Route<"agents.addJournal", {
481
481
  runs: import("../../core/runs.js").RunListItem[];
482
482
  }, "/api/runs", undefined> | Route<"runs.stop", {
483
483
  stopped: boolean;
484
- }, "/api/runs/:id/stop", undefined> | Route<"search.agents", import("../../core/search.js").AgentSearch, "/api/search/agents", undefined> | Route<"server.restart", {
484
+ }, "/api/runs/:id/stop", undefined> | Route<"search.agents", {
485
+ workspaces: (import("../../core/search.js").WorkspaceAgents & {
486
+ here?: true;
487
+ })[];
488
+ }, "/api/search/agents", undefined> | Route<"server.restart", {
485
489
  ok: boolean;
486
490
  }, "/api/restart", undefined> | Route<"settings.get", import("../../core/settings.js").SettingsView, "/api/settings", undefined> | Route<"settings.save", import("../../core/settings.js").SettingsView, "/api/settings", z.ZodObject<{
487
491
  name: z.ZodOptional<z.ZodString>;
@@ -577,7 +581,12 @@ export declare const routes: (Route<"agents.addJournal", {
577
581
  ok: boolean;
578
582
  }, "/api/workspaces/forget", z.ZodObject<{
579
583
  root: z.ZodOptional<z.ZodString>;
580
- }, z.core.$strip>> | Route<"workspaces.list", import("../../core/instances.js").WorkspaceDetail[], "/api/workspaces", undefined> | Route<"workspaces.start", import("./router.js").Reply<import("../../core/instances.js").StartResult>, "/api/workspaces/start", z.ZodObject<{
584
+ }, z.core.$strip>> | Route<"workspaces.list", (import("../../core/instances.js").WorkspaceDetail & {
585
+ here?: true;
586
+ })[], "/api/workspaces", undefined> | Route<"workspaces.start", import("./router.js").Reply<import("../../core/instances.js").StartResult & {
587
+ slug?: string;
588
+ here?: true;
589
+ }>, "/api/workspaces/start", z.ZodObject<{
581
590
  root: z.ZodOptional<z.ZodString>;
582
591
  }, z.core.$strip>> | Route<"workspaces.stop", import("./router.js").Reply<{
583
592
  ok: boolean;
@@ -1,4 +1,10 @@
1
1
  import * as z from "zod";
2
+ import { type StartResult } from "../../core/instances.js";
3
+ /** A start's answer; `here` (with its slug) when the hub answering serves it now. */
4
+ type StartReply = StartResult & {
5
+ slug?: string;
6
+ here?: true;
7
+ };
2
8
  /** The workspace itself: identity and features, settings, the other workspaces, updates. */
3
9
  export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
4
10
  workspaceName: string;
@@ -26,7 +32,11 @@ export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
26
32
  publicUrl: string;
27
33
  cloud: import("../cloud.js").CloudStatus;
28
34
  switcher: import("../../config.js").SwitcherEntry[];
29
- }, "/api/meta", undefined> | import("./router.js").Route<"search.agents", import("../../core/search.js").AgentSearch, "/api/search/agents", undefined> | import("./router.js").Route<"server.restart", {
35
+ }, "/api/meta", undefined> | import("./router.js").Route<"search.agents", {
36
+ workspaces: (import("../../core/search.js").WorkspaceAgents & {
37
+ here?: true;
38
+ })[];
39
+ }, "/api/search/agents", undefined> | import("./router.js").Route<"server.restart", {
30
40
  ok: boolean;
31
41
  }, "/api/restart", undefined> | import("./router.js").Route<"settings.get", import("../../core/settings.js").SettingsView, "/api/settings", undefined> | import("./router.js").Route<"settings.save", import("../../core/settings.js").SettingsView, "/api/settings", z.ZodObject<{
32
42
  name: z.ZodOptional<z.ZodString>;
@@ -91,7 +101,9 @@ export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
91
101
  ok: boolean;
92
102
  }, "/api/workspaces/forget", z.ZodObject<{
93
103
  root: z.ZodOptional<z.ZodString>;
94
- }, z.core.$strip>> | import("./router.js").Route<"workspaces.list", import("../../core/instances.js").WorkspaceDetail[], "/api/workspaces", undefined> | import("./router.js").Route<"workspaces.start", import("./router.js").Reply<import("../../core/instances.js").StartResult>, "/api/workspaces/start", z.ZodObject<{
104
+ }, z.core.$strip>> | import("./router.js").Route<"workspaces.list", (import("../../core/instances.js").WorkspaceDetail & {
105
+ here?: true;
106
+ })[], "/api/workspaces", undefined> | import("./router.js").Route<"workspaces.start", import("./router.js").Reply<StartReply>, "/api/workspaces/start", z.ZodObject<{
95
107
  root: z.ZodOptional<z.ZodString>;
96
108
  }, z.core.$strip>> | import("./router.js").Route<"workspaces.stop", import("./router.js").Reply<{
97
109
  ok: boolean;
@@ -100,3 +112,4 @@ export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
100
112
  root: z.ZodOptional<z.ZodString>;
101
113
  url: z.ZodOptional<z.ZodString>;
102
114
  }, z.core.$strip>>)[];
115
+ export {};
@@ -1,10 +1,11 @@
1
1
  import path from "node:path";
2
2
  import * as z from "zod";
3
- import { resolveWorkspace, slugFromFolder, workspaceSlug } from "../../config.js";
3
+ import { absolutePath, resolveWorkspace, slugFromFolder, workspaceSlug } from "../../config.js";
4
4
  import { disposeAllBackends } from "../../core/chat/sessions.js";
5
5
  import { cloudStatus } from "../cloud.js";
6
6
  import { workspaceRoot } from "../../core/context.js";
7
7
  import { discoverWorkspaces, forgetWorkspace, listWorkspacesDetailed, startWorkspace, stopWorkspace, tildify } from "../../core/instances.js";
8
+ import { currentHub } from "../hub-context.js";
8
9
  import { searchAgents } from "../../core/search.js";
9
10
  import { getSettings, saveSettings } from "../../core/settings.js";
10
11
  import { canSelfUpdate, startUpdate, updateStatus } from "../../core/update.js";
@@ -32,6 +33,26 @@ const SettingsFields = z.looseObject({
32
33
  vibeables: RootBlock.optional(),
33
34
  projects: RootBlock.optional(),
34
35
  });
36
+ /**
37
+ * Mark the workspaces the hub answering this request serves — or, a daemon,
38
+ * would serve once started (it opens a stopped one next to the others, at
39
+ * <slug>.localhost:<its port>) — as `here`. The UI links those in the form
40
+ * the page itself was reached by: a paired phone on the network cannot
41
+ * resolve <slug>.localhost, so it gets /w/<slug>/ on the address it used.
42
+ */
43
+ function placeInHub(entries) {
44
+ const hub = currentHub();
45
+ const served = new Set(hub.list().map((o) => o.root));
46
+ return entries.map((e) => {
47
+ if (!e.root || !e.slug)
48
+ return e;
49
+ if (served.has(e.root))
50
+ return { ...e, here: true };
51
+ if (hub.daemon && !e.live && e.exists !== false)
52
+ return { ...e, here: true, url: `http://${e.slug}.localhost:${hub.port}` };
53
+ return e;
54
+ });
55
+ }
35
56
  /** The workspace itself: identity and features, settings, the other workspaces, updates. */
36
57
  export const workspaceRoutes = [
37
58
  // ?probe=1 marks a discovery probe from another instance: answer identity
@@ -42,7 +63,7 @@ export const workspaceRoutes = [
42
63
  const name = ws?.config.name ?? (ws ? path.basename(ws.root) : "");
43
64
  const root = ws?.root ?? workspaceRoot();
44
65
  const manual = ws?.config.switcher ?? [];
45
- const discovered = c.query.get("probe") === "1" ? [] : await discoverWorkspaces();
66
+ const discovered = c.query.get("probe") === "1" ? [] : placeInHub(await discoverWorkspaces());
46
67
  // Manual switcher entries keep their configured name/icon; discovered
47
68
  // workspaces that duplicate one (same url, running) only contribute
48
69
  // the live flag. Stopped workspaces never collide — they carry a root,
@@ -89,12 +110,23 @@ export const workspaceRoutes = [
89
110
  }),
90
111
  route({ name: "settings.get", method: "GET", path: "/api/settings", summary: "kraftwerk.yml, parsed, with resolved paths" }, async () => getSettings()),
91
112
  route({ name: "settings.save", method: "PUT", path: "/api/settings", summary: "write the UI-editable subset of kraftwerk.yml", errors: 400, body: SettingsFields }, async (c) => saveSettings((await c.body()))),
92
- route({ name: "workspaces.list", method: "GET", path: "/api/workspaces", summary: "every known workspace incl. this one, with state and counts" }, async () => listWorkspacesDetailed()),
113
+ route({ name: "workspaces.list", method: "GET", path: "/api/workspaces", summary: "every known workspace incl. this one, with state and counts" }, async () => placeInHub(await listWorkspacesDetailed())),
93
114
  // Launches `kraftwerk ui` for a known workspace as a detached process (the switcher's Start button).
94
115
  route({ name: "workspaces.start", method: "POST", path: "/api/workspaces/start", summary: "start a known workspace {root}", errors: 400, body: z.object({ root: z.string().optional() }) }, async (c) => {
95
116
  const { root } = await c.body();
96
117
  if (!root)
97
118
  fail(400, "root required");
119
+ // A daemon opens it itself, next to the workspace asking.
120
+ const hub = currentHub();
121
+ if (hub.daemon) {
122
+ try {
123
+ const o = await hub.open(absolutePath(root));
124
+ return reply(200, { ok: true, url: o.url, slug: o.slug, here: true, live: true, pid: process.pid });
125
+ }
126
+ catch (err) {
127
+ return reply(409, { ok: false, error: err.message });
128
+ }
129
+ }
98
130
  const result = await startWorkspace(root);
99
131
  return reply(result.ok ? 200 : 409, result);
100
132
  }),
@@ -102,6 +134,13 @@ export const workspaceRoutes = [
102
134
  const target = await c.body();
103
135
  if (!target.root && !target.url)
104
136
  fail(400, "root or url required");
137
+ // One the hub answering serves (not the one asking): closed right here.
138
+ const hub = currentHub();
139
+ const open = target.root ? hub.list().find((o) => o.root === absolutePath(target.root)) : undefined;
140
+ if (hub.daemon && open && open.root !== workspaceRoot()) {
141
+ await hub.close(open.root);
142
+ return reply(200, { ok: true });
143
+ }
105
144
  const result = await stopWorkspace(target);
106
145
  return reply(result.ok ? 200 : 409, result);
107
146
  }),
@@ -111,7 +150,7 @@ export const workspaceRoutes = [
111
150
  fail(400, "root required");
112
151
  return { ok: await forgetWorkspace(root) };
113
152
  }),
114
- route({ name: "search.agents", method: "GET", path: "/api/search/agents", summary: "active agents and channels of every reachable workspace (⌘K)" }, async () => searchAgents()),
153
+ route({ name: "search.agents", method: "GET", path: "/api/search/agents", summary: "active agents and channels of every reachable workspace (⌘K)" }, async () => searchAgents().then((r) => ({ workspaces: placeInHub(r.workspaces) }))),
115
154
  route({ name: "update.check", method: "GET", path: "/api/update-check", summary: "the latest version on the npm registry" }, async () => {
116
155
  const name = await getPkgName();
117
156
  const current = await getDiskVersion();
@@ -25,6 +25,8 @@ export interface OpenWorkspace {
25
25
  /** Its own port too (kraftwerk.yml `port`), when the hub listens there for it — old links keep working. */
26
26
  aliasPort?: number;
27
27
  ws: Workspace;
28
+ /** Its instance file being written (see openNow): closing waits for it, so no file lands after the close. */
29
+ registered?: Promise<void>;
28
30
  }
29
31
  /**
30
32
  * One HTTP server serving workspaces by slug (see target.ts): a daemon
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import http from "node:http";
3
+ import net from "node:net";
3
4
  import path from "node:path";
4
5
  import { setDefaultWorkspace, Workspace } from "../core/workspace.js";
5
6
  import { cloudGoodbye, cloudGoodbyeAll, startCloudSync, stopCloudSync } from "./cloud.js";
@@ -167,19 +168,52 @@ function installExitHandlers() {
167
168
  unregisterInstance();
168
169
  });
169
170
  }
170
- const listen = (server, port) => new Promise((resolve, reject) => {
171
- server.once("error", reject);
172
- server.listen(port, INSPECTOR_HOST, () => {
173
- server.off("error", reject);
174
- const addr = server.address();
175
- resolve(typeof addr === "object" && addr ? addr.port : port);
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
174
+ * ::1 first, and a client that does not fall back to IPv4 (a native app's
175
+ * HTTP library) would find nothing there. Best effort — without IPv6, or
176
+ * with ::1:port taken, it stays IPv4 only, as before.
177
+ */
178
+ async function listen(server, port) {
179
+ const bound = await new Promise((resolve, reject) => {
180
+ server.once("error", reject);
181
+ server.listen(port, INSPECTOR_HOST, () => {
182
+ server.off("error", reject);
183
+ const addr = server.address();
184
+ resolve(typeof addr === "object" && addr ? addr.port : port);
185
+ });
186
+ });
187
+ if (INSPECTOR_HOST !== "127.0.0.1")
188
+ return bound;
189
+ const twin = net.createServer((socket) => server.emit("connection", socket));
190
+ const ok = await new Promise((resolve) => {
191
+ twin.once("error", () => resolve(false));
192
+ twin.listen(bound, "::1", () => resolve(true));
176
193
  });
177
- });
178
- /** A small page at a daemon's own address: the open workspaces, each a link. */
179
- function hubPage(res, open) {
194
+ if (!ok)
195
+ return bound;
196
+ twin.on("error", () => { });
197
+ // Closing the server stops the twin from taking connections at once (its "close" event waits for open ones).
198
+ const close = server.close.bind(server);
199
+ server.close = ((cb) => {
200
+ twin.close();
201
+ return close(cb);
202
+ });
203
+ return bound;
204
+ }
205
+ /**
206
+ * A small page at a daemon's own address: the open workspaces, each a link —
207
+ * <slug>.localhost on this machine, the path form on the address a paired
208
+ * device used (it cannot resolve <slug>.localhost).
209
+ */
210
+ function hubPage(req, res, open) {
180
211
  const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
212
+ // Behind a proxy on this machine the browser's name is the forwarded one.
213
+ const host = hostnameOf(forwardedHost(req) ?? req.headers.host ?? "");
214
+ const local = LOOPBACK_NAMES.has(host) || host.endsWith(".localhost");
181
215
  const rows = open.length
182
- ? open.map((o) => `<li><a href="${esc(o.url)}/">${esc(o.name)}</a> <code>${esc(o.slug)}</code></li>`).join("")
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("")
183
217
  : "<li>No workspace is open. Run <code>kraftwerk ui</code> in a workspace folder.</li>";
184
218
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
185
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>`);
@@ -226,7 +260,7 @@ export async function startHub(opts) {
226
260
  }
227
261
  // A daemon's own address lists its workspaces; anywhere else, the web UI's shell (public: an unpaired browser needs it to pair).
228
262
  if (!open && (url.pathname === "/" || url.pathname === "/index.html"))
229
- return hubPage(res, [...bySlug.values()]);
263
+ return hubPage(req, res, [...bySlug.values()]);
230
264
  await serveStatic(res, opts.staticDir, url.pathname);
231
265
  }
232
266
  catch (err) {
@@ -354,7 +388,7 @@ export async function startHub(opts) {
354
388
  startRoutineScheduler();
355
389
  startGitSync();
356
390
  // Best-effort, in the background: none of them may hold up (or, failing, break) the open.
357
- void registerInstance(entry.aliasPort ?? port, abs, opts.daemon ? { hub: true, slug, url: entry.url } : {}).catch(() => { });
391
+ entry.registered = registerInstance(entry.aliasPort ?? port, abs, opts.daemon ? { hub: true, slug, url: entry.url } : {}).catch(() => { });
358
392
  void registerWorkspace(abs).catch(() => { });
359
393
  void startCloudSync(entry.url, version).catch(() => { });
360
394
  }));
@@ -385,6 +419,7 @@ export async function startHub(opts) {
385
419
  await cloudGoodbye();
386
420
  stopCloudSync();
387
421
  }));
422
+ await entry.registered;
388
423
  unregisterInstance(abs);
389
424
  markWorkspaceStopped(abs);
390
425
  entry.ws.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.66.0",
3
+ "version": "0.68.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",