@netnodeag/kraftwerk 0.72.0 → 0.73.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
@@ -70,8 +70,13 @@ login:
70
70
 
71
71
  ```bash
72
72
  kraftwerk daemon # the machine's kraftwerk, on http://localhost:1980
73
+ kraftwerk daemon start # or in the background (`kraftwerk daemon -d`)
73
74
  kraftwerk daemon install # or: start it at login from now on (macOS; `uninstall` undoes it)
74
75
 
76
+ kraftwerk daemon status # does it run? pid, uptime, version, workspaces, remote access
77
+ kraftwerk daemon logs -f # what it says (a daemon in the background writes ~/.kraftwerk/logs/daemon.log)
78
+ kraftwerk daemon restart # relaunch it with the current install; `daemon stop` stops it
79
+
75
80
  cd ~/work/team-blau && kraftwerk ui # opens this workspace in the daemon:
76
81
  # http://team-blau.localhost:1980
77
82
  cd ~/work/relaunch && kraftwerk ui # http://relaunch.localhost:1980
@@ -246,6 +251,7 @@ kraftwerk knowledge # Knowledge: OKF bundles (list/get/put/v
246
251
  kraftwerk ui # start this workspace: http://localhost:1981 (or open it in the daemon); --port, --lan, --standalone
247
252
  kraftwerk daemon # one kraftwerk for every open workspace: http://<slug>.localhost:1980; --port, --lan
248
253
  kraftwerk daemon install # start the daemon at login (macOS LaunchAgent); --lan, --port; `daemon uninstall` removes it
254
+ kraftwerk daemon status # whether it runs (exit 3 when not; --json); daemon start|stop|restart; daemon logs [-n N] [-f]
249
255
  kraftwerk workspaces # every workspace on this machine; workspaces start|stop|forget <ref>
250
256
  kraftwerk devices # paired devices (a phone, another computer); devices pair|revoke
251
257
  kraftwerk remote on|off|pair # reach this machine from anywhere through the relay, end to end encrypted
@@ -0,0 +1,15 @@
1
+ export declare function daemonStatus(opts: {
2
+ json?: boolean;
3
+ }): Promise<void>;
4
+ export declare function startDaemon(opts: {
5
+ port?: string;
6
+ }): Promise<void>;
7
+ export declare function stopDaemon(): Promise<void>;
8
+ /** Restart: the running daemon relaunches itself with the current install (its supervisor stays); none running — start one. */
9
+ export declare function restartDaemon(opts: {
10
+ port?: string;
11
+ }): Promise<void>;
12
+ export declare function daemonLogs(opts: {
13
+ lines?: string;
14
+ follow?: boolean;
15
+ }): Promise<void>;
@@ -0,0 +1,228 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, openSync, promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import chalk from "chalk";
5
+ import { daemonLogPath, findDaemon, readDaemonFile } from "../core/daemon.js";
6
+ import { tildify } from "../core/instances.js";
7
+ import { selfCommand } from "../core/self-command.js";
8
+ import { getPkgVersion } from "../server/version.js";
9
+ import { daemonLoginItem, startLoginItem } from "./daemon-login.js";
10
+ /**
11
+ * `kraftwerk daemon status | start | stop | restart | logs`: the daemon from
12
+ * the outside, without a terminal of its own. `start` (and `kraftwerk daemon
13
+ * -d`) runs it in the background — through the login item when one is
14
+ * installed, else as a detached process — writing to the daemon log, which
15
+ * `logs` shows (`-f` follows). `status` asks the daemon itself, never the
16
+ * file alone, and exits 3 when none runs (as `systemctl status` does), so a
17
+ * script can tell.
18
+ */
19
+ const NOT_RUNNING = 3;
20
+ const alive = (pid) => {
21
+ if (!pid)
22
+ return false;
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ };
31
+ const ago = (iso) => {
32
+ const ms = iso ? Date.now() - Date.parse(iso) : NaN;
33
+ if (!Number.isFinite(ms) || ms < 0)
34
+ return "";
35
+ const m = Math.floor(ms / 60_000);
36
+ if (m < 1)
37
+ return "just now";
38
+ if (m < 60)
39
+ return `${m}m`;
40
+ const h = Math.floor(m / 60);
41
+ return h < 24 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`;
42
+ };
43
+ async function getJson(port, p) {
44
+ try {
45
+ const r = await fetch(`http://127.0.0.1:${port}${p}`, { signal: AbortSignal.timeout(2000) });
46
+ return r.ok ? (await r.json()) : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** The last lines of the daemon log ([] without one). */
53
+ async function tail(n) {
54
+ try {
55
+ const lines = (await fs.readFile(daemonLogPath(), "utf8")).split("\n");
56
+ if (lines.at(-1) === "")
57
+ lines.pop();
58
+ return lines.slice(-n);
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ }
64
+ async function status() {
65
+ const [running, rec, installed] = await Promise.all([findDaemon(), readDaemonFile(), getPkgVersion()]);
66
+ const base = { installed, loginItem: daemonLoginItem() !== null, log: daemonLogPath() };
67
+ if (!running)
68
+ return { running: false, ...base };
69
+ const [hub, relay] = await Promise.all([
70
+ getJson(running.port, "/api/hub"),
71
+ getJson(running.port, "/api/devices/relay"),
72
+ ]);
73
+ return {
74
+ running: true,
75
+ ...base,
76
+ pid: running.pid,
77
+ port: running.port,
78
+ ...(rec?.version ? { version: rec.version } : {}),
79
+ ...(rec?.startedAt ? { startedAt: rec.startedAt } : {}),
80
+ workspaces: (hub?.workspaces ?? []).map(({ slug, name, url }) => ({ slug, name, url })),
81
+ ...(relay?.enabled ? { remote: { state: relay.state ?? "unknown", ...(relay.url ? { url: relay.url } : {}), clients: relay.clients ?? 0 } } : {}),
82
+ };
83
+ }
84
+ export async function daemonStatus(opts) {
85
+ const s = await status();
86
+ if (opts.json) {
87
+ console.log(JSON.stringify(s, null, 2));
88
+ process.exit(s.running ? 0 : NOT_RUNNING);
89
+ }
90
+ if (s.running) {
91
+ const up = ago(s.startedAt);
92
+ const behind = s.version && s.version !== s.installed ? chalk.yellow(` — ${s.installed} is installed: \`kraftwerk daemon restart\``) : "";
93
+ console.log(`${chalk.green("●")} kraftwerk daemon ${chalk.green("running")} ${chalk.dim(`pid ${s.pid}${up ? `, up ${up}` : ""}${s.version ? `, ${s.version}` : ""}`)}${behind}`);
94
+ console.log(` ${chalk.cyan(`http://localhost:${s.port}`)} · ${s.workspaces?.length ?? 0} workspace${s.workspaces?.length === 1 ? "" : "s"} open`);
95
+ for (const w of s.workspaces ?? [])
96
+ console.log(` ${w.name} ${chalk.dim(w.url)}`);
97
+ if (s.remote) {
98
+ const ok = s.remote.state === "connected";
99
+ const devices = ok ? chalk.dim(` · ${s.remote.clients} device connection${s.remote.clients === 1 ? "" : "s"} now`) : "";
100
+ console.log(` remote access: ${ok ? chalk.green("connected") : chalk.yellow(s.remote.state)}${s.remote.url ? chalk.dim(` through ${s.remote.url}`) : ""}${devices}`);
101
+ }
102
+ }
103
+ else {
104
+ console.log(`${chalk.red("○")} kraftwerk daemon ${chalk.red("not running")}`);
105
+ const last = await tail(400);
106
+ const crash = [...last].reverse().find((l) => /crashed:|stopped unexpectedly/.test(l));
107
+ if (crash)
108
+ console.log(chalk.dim(` last trouble in the log: ${crash.trim().slice(0, 200)}`));
109
+ console.log(chalk.dim(" `kraftwerk daemon start` starts it in the background"));
110
+ }
111
+ console.log(chalk.dim(` login item: ${s.loginItem ? "starts at login" : "not installed (`kraftwerk daemon install` starts it at login)"}`));
112
+ console.log(chalk.dim(` log: ${tildify(s.log)} · \`kraftwerk daemon logs -f\` follows it`));
113
+ if (!s.running)
114
+ process.exit(NOT_RUNNING);
115
+ }
116
+ /** Wait until `ok` holds (polling), up to `ms`; whether it did. */
117
+ async function until(ok, ms) {
118
+ const end = Date.now() + ms;
119
+ while (Date.now() < end) {
120
+ if (await ok())
121
+ return true;
122
+ await new Promise((r) => setTimeout(r, 300));
123
+ }
124
+ return ok();
125
+ }
126
+ /** Start it in the background: the login item when installed (launchd owns it then), else a detached process writing to the log. */
127
+ async function launch(port) {
128
+ if (daemonLoginItem() && !port && startLoginItem())
129
+ return "login item";
130
+ const log = daemonLogPath();
131
+ mkdirSync(path.dirname(log), { recursive: true });
132
+ const out = openSync(log, "a");
133
+ const { cmd, args } = selfCommand(["daemon", ...(port ? ["--port", port] : [])]);
134
+ const child = spawn(cmd, args, { detached: true, stdio: ["ignore", out, out], cwd: process.env.HOME, env: { ...process.env, FORCE_COLOR: "0" } });
135
+ child.unref();
136
+ return "background";
137
+ }
138
+ export async function startDaemon(opts) {
139
+ const running = await findDaemon();
140
+ if (running) {
141
+ console.log(`${chalk.green("✔")} the daemon already runs ${chalk.dim(`(pid ${running.pid}, http://localhost:${running.port})`)}`);
142
+ return;
143
+ }
144
+ const how = await launch(opts.port);
145
+ process.stdout.write(chalk.dim(`starting the daemon (${how}) …`));
146
+ const up = await until(async () => (await findDaemon()) !== null, 45_000);
147
+ process.stdout.write("\n");
148
+ if (!up) {
149
+ console.error(chalk.red("✖ the daemon did not come up — its log says:"));
150
+ for (const l of await tail(15))
151
+ console.error(chalk.dim(` ${l}`));
152
+ process.exit(1);
153
+ }
154
+ await daemonStatus({});
155
+ }
156
+ /** Stop it: its supervisor first (else it would start the daemon again), then wait until it is gone. */
157
+ async function stopRunning() {
158
+ const [running, rec] = await Promise.all([findDaemon(), readDaemonFile()]);
159
+ if (!running)
160
+ return false;
161
+ const target = rec?.pid === running.pid && alive(rec.supervisor) ? rec.supervisor : running.pid;
162
+ process.kill(target, "SIGTERM");
163
+ const gone = await until(async () => !alive(running.pid) && !alive(target) && (await findDaemon()) === null, 20_000);
164
+ if (!gone) {
165
+ for (const pid of [target, running.pid])
166
+ if (alive(pid))
167
+ process.kill(pid, "SIGKILL");
168
+ await until(async () => (await findDaemon()) === null, 5_000);
169
+ }
170
+ return true;
171
+ }
172
+ export async function stopDaemon() {
173
+ if (!(await stopRunning())) {
174
+ console.log(chalk.dim("No daemon running."));
175
+ return;
176
+ }
177
+ console.log(`${chalk.green("✔")} daemon stopped`);
178
+ if (daemonLoginItem())
179
+ console.log(chalk.dim(" it starts again at the next login (`kraftwerk daemon uninstall` stops that)"));
180
+ }
181
+ /** Restart: the running daemon relaunches itself with the current install (its supervisor stays); none running — start one. */
182
+ export async function restartDaemon(opts) {
183
+ const running = await findDaemon();
184
+ if (!running)
185
+ return startDaemon(opts);
186
+ // The restart route lives in every workspace (the daemon's root answers machine routes only); any open one will do.
187
+ const hub = await getJson(running.port, "/api/hub");
188
+ const slug = hub?.workspaces?.[0]?.slug;
189
+ const asked = !!slug &&
190
+ (await fetch(`http://127.0.0.1:${running.port}/w/${encodeURIComponent(slug)}/api/restart`, { method: "POST", signal: AbortSignal.timeout(5000) }).then((r) => r.ok, () => false));
191
+ if (!asked) {
192
+ // Not supervised (an old daemon, a foreground one started oddly): stop it and start a new one.
193
+ await stopRunning();
194
+ return startDaemon(opts);
195
+ }
196
+ process.stdout.write(chalk.dim("restarting the daemon …"));
197
+ const back = await until(async () => {
198
+ const d = await findDaemon();
199
+ return d !== null && d.pid !== running.pid;
200
+ }, 45_000);
201
+ process.stdout.write("\n");
202
+ if (!back) {
203
+ console.error(chalk.red("✖ the daemon did not come back — its log says:"));
204
+ for (const l of await tail(15))
205
+ console.error(chalk.dim(` ${l}`));
206
+ process.exit(1);
207
+ }
208
+ await daemonStatus({});
209
+ }
210
+ export async function daemonLogs(opts) {
211
+ const log = daemonLogPath();
212
+ const n = Math.max(1, Number(opts.lines ?? 50) || 50);
213
+ if (!existsSync(log)) {
214
+ console.log(chalk.dim(`No daemon log yet (${tildify(log)}): a daemon in the background writes it — one in a terminal writes there instead.`));
215
+ if (!opts.follow)
216
+ return;
217
+ mkdirSync(path.dirname(log), { recursive: true });
218
+ await fs.appendFile(log, "");
219
+ }
220
+ if (!opts.follow) {
221
+ for (const l of await tail(n))
222
+ console.log(l);
223
+ return;
224
+ }
225
+ // tail -F keeps following when the file is replaced; Ctrl-C ends it.
226
+ const child = spawn("tail", ["-n", String(n), "-F", log], { stdio: "inherit" });
227
+ await new Promise((resolve) => child.once("exit", () => resolve()));
228
+ }
@@ -15,5 +15,7 @@ export declare function installDaemon(opts: {
15
15
  lan?: boolean;
16
16
  }): Promise<void>;
17
17
  export declare function uninstallDaemon(): Promise<void>;
18
+ /** Start the login item's daemon now (`kraftwerk daemon start`): load the item, or kick it when loaded; false when launchctl refused. */
19
+ export declare function startLoginItem(): boolean;
18
20
  /** For doctor: whether the login item is installed. */
19
21
  export declare const daemonLoginItem: () => string | null;
@@ -3,7 +3,7 @@ import { existsSync, promises as fs } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import chalk from "chalk";
6
- import { findDaemon } from "../core/daemon.js";
6
+ import { daemonLogPath, findDaemon } from "../core/daemon.js";
7
7
  import { selfCommand } from "../core/self-command.js";
8
8
  import { tildify } from "../core/instances.js";
9
9
  /**
@@ -17,7 +17,7 @@ import { tildify } from "../core/instances.js";
17
17
  */
18
18
  export const LAUNCH_LABEL = "ch.netnode.kraftwerk.daemon";
19
19
  const plistPath = () => path.join(os.homedir(), "Library", "LaunchAgents", `${LAUNCH_LABEL}.plist`);
20
- const logPath = () => path.join(os.homedir(), ".kraftwerk", "logs", "daemon.log");
20
+ const logPath = daemonLogPath;
21
21
  /** A stand-in launchctl (tests); the real one otherwise. */
22
22
  const launchctl = () => process.env.KRAFTWERK_LAUNCHCTL || "launchctl";
23
23
  const domain = () => `gui/${os.userInfo().uid}`;
@@ -102,5 +102,12 @@ export async function uninstallDaemon() {
102
102
  await fs.rm(file, { force: true });
103
103
  console.log(`${chalk.green("✔")} login item removed ${chalk.dim(`(${tildify(file)})`)}`);
104
104
  }
105
+ /** Start the login item's daemon now (`kraftwerk daemon start`): load the item, or kick it when loaded; false when launchctl refused. */
106
+ export function startLoginItem() {
107
+ const kick = spawnSync(launchctl(), ["kickstart", `${domain()}/${LAUNCH_LABEL}`], { stdio: "ignore" });
108
+ if (kick.status === 0)
109
+ return true;
110
+ return spawnSync(launchctl(), ["bootstrap", domain(), plistPath()], { stdio: "ignore" }).status === 0;
111
+ }
105
112
  /** For doctor: whether the login item is installed. */
106
113
  export const daemonLoginItem = () => (existsSync(plistPath()) ? plistPath() : null);
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { DAEMON_PORT, findDaemon, lastOpenRoots, writeDaemonFile } from "../core/daemon.js";
2
+ import { DAEMON_PORT, findDaemon, lastOpenRoots, setDaemonVersion, writeDaemonFile } from "../core/daemon.js";
3
3
  import { tildify } from "../core/instances.js";
4
4
  import { networkUrls } from "../server/bind.js";
5
5
  import { startPushWatcher } from "../server/push.js";
@@ -8,6 +8,7 @@ import { startHub } from "../server/server.js";
8
8
  import { getPkgVersion } from "../server/version.js";
9
9
  import { ensureBuilt, supervise } from "./ui.js";
10
10
  import { installDaemon, uninstallDaemon } from "./daemon-login.js";
11
+ import { daemonLogs, daemonStatus, restartDaemon, startDaemon, stopDaemon } from "./daemon-control.js";
11
12
  /**
12
13
  * `kraftwerk daemon` — the machine's kraftwerk: one process serving every
13
14
  * open workspace, each at `<slug>.localhost:<port>` (and its own port, when
@@ -27,6 +28,7 @@ export async function runDaemon(opts) {
27
28
  process.exit(1);
28
29
  }
29
30
  const port = Number(opts.port ?? process.env.KRAFTWERK_DAEMON_PORT ?? DAEMON_PORT);
31
+ setDaemonVersion(await getPkgVersion());
30
32
  const reopen = await lastOpenRoots();
31
33
  const hub = await startHub({
32
34
  port,
@@ -43,7 +45,7 @@ export async function runDaemon(opts) {
43
45
  console.log(`${chalk.yellow("⚠")} not reopened: ${tildify(root)} — ${err.message}`);
44
46
  }
45
47
  }
46
- console.log(`${chalk.green("✔")} kraftwerk daemon on ${chalk.cyan(`http://localhost:${hub.port}`)} ${chalk.dim(`(pid ${process.pid})`)}`);
48
+ console.log(`${chalk.green("✔")} kraftwerk daemon on ${chalk.cyan(`http://localhost:${hub.port}`)} ${chalk.dim(`(pid ${process.pid}, ${new Date().toISOString()})`)}`);
47
49
  for (const o of hub.list())
48
50
  console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
49
51
  if (!hub.list().length)
@@ -64,9 +66,53 @@ export function registerDaemonCommand(program) {
64
66
  .description("Run the machine's kraftwerk: one process serving every open workspace at <slug>.localhost; install it as a login item")
65
67
  .option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
66
68
  .option("--lan", "Listen on the network too, for paired devices")
69
+ .option("-d, --detach", "Run in the background (same as `kraftwerk daemon start`)")
70
+ .addHelpText("after", `
71
+ In the background:
72
+ kraftwerk daemon start start it (the login item when installed), output to the daemon log
73
+ kraftwerk daemon status whether it runs: pid, uptime, version, workspaces, remote access
74
+ kraftwerk daemon logs -f its log, followed
75
+ kraftwerk daemon restart relaunch it with the current install
76
+ kraftwerk daemon stop stop it`)
67
77
  .action(async (opts) => {
78
+ if (opts.detach)
79
+ return startDaemon(opts);
68
80
  await runDaemon(opts);
69
81
  });
82
+ daemon
83
+ .command("status")
84
+ .description("Whether the daemon runs: pid, uptime, version, workspaces, remote access (exit 3 when not)")
85
+ .option("--json", "As JSON")
86
+ .action(async (opts) => {
87
+ await daemonStatus(opts);
88
+ });
89
+ daemon
90
+ .command("start")
91
+ .description("Start the daemon in the background (the login item when installed), writing to the daemon log")
92
+ .option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
93
+ .action(async (opts) => {
94
+ await startDaemon({ port: opts.port ?? daemon.opts().port });
95
+ });
96
+ daemon
97
+ .command("stop")
98
+ .description("Stop the running daemon")
99
+ .action(async () => {
100
+ await stopDaemon();
101
+ });
102
+ daemon
103
+ .command("restart")
104
+ .description("Relaunch the daemon with the current install (starts one when none runs)")
105
+ .action(async () => {
106
+ await restartDaemon({});
107
+ });
108
+ daemon
109
+ .command("logs")
110
+ .description("Show the daemon log (a daemon in the background writes it)")
111
+ .option("-n, --lines <n>", "How many lines", "50")
112
+ .option("-f, --follow", "Keep following it (Ctrl-C ends)")
113
+ .action(async (opts) => {
114
+ await daemonLogs(opts);
115
+ });
70
116
  daemon
71
117
  .command("install")
72
118
  .description("Start the daemon at login (macOS LaunchAgent), and now unless one runs")
@@ -217,7 +217,9 @@ export async function runDoctor(cwd) {
217
217
  const daemon = await findDaemon();
218
218
  const loginItem = daemonLoginItem();
219
219
  report("info", daemon ? `kraftwerk daemon running on :${daemon.port}` : "no kraftwerk daemon running", loginItem
220
- ? "starts at login (`kraftwerk daemon uninstall` removes that)"
220
+ ? daemon
221
+ ? "starts at login · `kraftwerk daemon status` for more"
222
+ : "starts at login, but not running now · `kraftwerk daemon start`, `kraftwerk daemon logs` says why it stopped"
221
223
  : process.platform === "darwin"
222
224
  ? "`kraftwerk daemon install` starts it at login"
223
225
  : daemon
package/dist/cli/ui.js CHANGED
@@ -98,8 +98,9 @@ export async function runUi(cwd, opts) {
98
98
  /**
99
99
  * Respawn loop around the real server. Spawns this same bin script via the
100
100
  * current node (selfCommand) — works identically for global installs,
101
- * local node_modules, npx, and a file: dev checkout. Any exit other than
102
- * RESTART_EXIT_CODE ends the loop. A SIGTERM/SIGINT to the supervisor is
101
+ * local node_modules, npx, and a file: dev checkout. RESTART_EXIT_CODE
102
+ * relaunches; so does a crash once the server was up (with a growing
103
+ * pause); any other exit ends the loop. A SIGTERM/SIGINT to the supervisor is
103
104
  * forwarded to the server, so `kill <supervisor pid>` (what `kraftwerk
104
105
  * workspaces start` reports) takes the whole UI down; Ctrl-C signals the
105
106
  * foreground group and reaches both anyway.
@@ -110,12 +111,18 @@ async function superviseUi(opts) {
110
111
  /** The respawn loop for any long-running kraftwerk process (`ui`, `daemon`): see superviseUi. */
111
112
  export async function supervise(argv, what) {
112
113
  const { cmd, args } = selfCommand(argv);
114
+ let backoff = 1000;
113
115
  for (;;) {
116
+ const started = Date.now();
114
117
  const child = spawn(cmd, args, {
115
118
  stdio: "inherit",
116
- env: { ...process.env, KRAFTWERK_UI_SUPERVISED: "1" },
119
+ env: { ...process.env, KRAFTWERK_UI_SUPERVISED: "1", KRAFTWERK_SUPERVISOR_PID: String(process.pid) },
117
120
  });
118
- const forward = (sig) => () => child.kill(sig);
121
+ let stopping = false;
122
+ const forward = (sig) => () => {
123
+ stopping = true;
124
+ child.kill(sig);
125
+ };
119
126
  const handlers = { SIGTERM: forward("SIGTERM"), SIGINT: forward("SIGINT") };
120
127
  process.on("SIGTERM", handlers.SIGTERM);
121
128
  process.on("SIGINT", handlers.SIGINT);
@@ -124,13 +131,28 @@ export async function supervise(argv, what) {
124
131
  });
125
132
  process.off("SIGTERM", handlers.SIGTERM);
126
133
  process.off("SIGINT", handlers.SIGINT);
127
- if (result.code !== RESTART_EXIT_CODE) {
128
- // A signal-killed server is not a clean exit — say so in the exit code
129
- // (128 + signal, the shell convention) so callers can tell.
130
- if (result.signal)
131
- process.exit(128 + (os.constants.signals[result.signal] ?? 1));
132
- process.exit(result.code ?? 1);
134
+ if (result.code === RESTART_EXIT_CODE) {
135
+ console.log(chalk.dim(`↻ relaunching ${what} with the current install ...`));
136
+ backoff = 1000;
137
+ continue;
133
138
  }
134
- console.log(chalk.dim(`↻ relaunching ${what} with the current install ...`));
139
+ // A crash after it was up (not asked to stop, not a clean exit): start it again, waiting longer each time it keeps crashing.
140
+ // One that fails right at start (a taken port, a second daemon) would only fail again — that exits, as before.
141
+ const ran = Date.now() - started;
142
+ const crashed = !stopping && result.code !== 0 && ran >= Number(process.env.KRAFTWERK_RESTART_AFTER_MS ?? 10_000);
143
+ if (crashed) {
144
+ if (ran > 120_000)
145
+ backoff = 1000;
146
+ const how = result.signal ? `signal ${result.signal}` : `exit ${result.code}`;
147
+ console.error(chalk.yellow(`↻ ${new Date().toISOString()} ${what} stopped unexpectedly (${how}) — starting it again in ${backoff / 1000}s`));
148
+ await new Promise((r) => setTimeout(r, backoff));
149
+ backoff = Math.min(backoff * 2, 60_000);
150
+ continue;
151
+ }
152
+ // A signal-killed server is not a clean exit — say so in the exit code
153
+ // (128 + signal, the shell convention) so callers can tell.
154
+ if (result.signal)
155
+ process.exit(128 + (os.constants.signals[result.signal] ?? 1));
156
+ process.exit(result.code ?? 1);
135
157
  }
136
158
  }
@@ -9,9 +9,25 @@
9
9
  * asking it (findDaemon), never by the file alone.
10
10
  */
11
11
  export declare const DAEMON_PORT = 1980;
12
+ export interface DaemonFile {
13
+ pid: number;
14
+ port: number;
15
+ startedAt: string;
16
+ /** The supervisor around it (`kraftwerk daemon stop` signals that one, so it does not start the daemon again). */
17
+ supervisor?: number;
18
+ version?: string;
19
+ /** The roots it serves — reopened when the daemon starts again (a restart, a self-update). */
20
+ open?: string[];
21
+ }
22
+ /** The version the running daemon writes into its file (status shows it: the install may be newer than what runs). */
23
+ export declare const setDaemonVersion: (v: string) => void;
24
+ /** Where a daemon in the background (`kraftwerk daemon start`, the login item) writes what it says. */
25
+ export declare const daemonLogPath: () => string;
12
26
  export declare function writeDaemonFile(port: number, open: string[]): Promise<void>;
13
27
  /** What the last daemon served, to open again; [] without a file. */
14
28
  export declare function lastOpenRoots(): Promise<string[]>;
29
+ /** The daemon file as the last daemon left it; null without one. */
30
+ export declare function readDaemonFile(): Promise<DaemonFile | null>;
15
31
  /** The running daemon (its file, and a hub that answers on its port), or null. */
16
32
  export declare function findDaemon(): Promise<{
17
33
  pid: number;
@@ -14,10 +14,18 @@ import path from "node:path";
14
14
  export const DAEMON_PORT = 1980;
15
15
  const daemonFile = () => path.join(os.homedir(), ".kraftwerk", "daemon.json");
16
16
  const startedAt = new Date().toISOString();
17
+ let version;
18
+ /** The version the running daemon writes into its file (status shows it: the install may be newer than what runs). */
19
+ export const setDaemonVersion = (v) => {
20
+ version = v;
21
+ };
22
+ /** Where a daemon in the background (`kraftwerk daemon start`, the login item) writes what it says. */
23
+ export const daemonLogPath = () => path.join(os.homedir(), ".kraftwerk", "logs", "daemon.log");
17
24
  export async function writeDaemonFile(port, open) {
18
25
  await fs.mkdir(path.dirname(daemonFile()), { recursive: true });
19
26
  const tmp = `${daemonFile()}.${process.pid}.tmp`;
20
- await fs.writeFile(tmp, JSON.stringify({ pid: process.pid, port, startedAt, open }));
27
+ const supervisor = Number(process.env.KRAFTWERK_SUPERVISOR_PID) || undefined;
28
+ await fs.writeFile(tmp, JSON.stringify({ pid: process.pid, port, startedAt, open, ...(supervisor ? { supervisor } : {}), ...(version ? { version } : {}) }));
21
29
  await fs.rename(tmp, daemonFile());
22
30
  }
23
31
  /** What the last daemon served, to open again; [] without a file. */
@@ -30,6 +38,15 @@ export async function lastOpenRoots() {
30
38
  return [];
31
39
  }
32
40
  }
41
+ /** The daemon file as the last daemon left it; null without one. */
42
+ export async function readDaemonFile() {
43
+ try {
44
+ return JSON.parse(await fs.readFile(daemonFile(), "utf8"));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
33
50
  /** The running daemon (its file, and a hub that answers on its port), or null. */
34
51
  export async function findDaemon() {
35
52
  let rec;
@@ -73,10 +73,12 @@ export async function listAgentSkills(slug) {
73
73
  * shadows project shadows user (global).
74
74
  */
75
75
  export async function listSkills(agent) {
76
+ // First, before anything starts: outside a workspace this throws here, not in one of the reads below with the others left unhandled.
77
+ const root = workspaceRoot();
76
78
  const [own, workspace, project, user] = await Promise.all([
77
79
  agent ? listAgentSkills(agent).catch(() => []) : [],
78
- skillsRoot().then((root) => scanRoot(root, "workspace")),
79
- scanRoot(path.join(workspaceRoot(), ".claude", "skills"), "project"),
80
+ skillsRoot().then((r) => scanRoot(r, "workspace")),
81
+ scanRoot(path.join(root, ".claude", "skills"), "project"),
80
82
  scanRoot(path.join(os.homedir(), ".claude", "skills"), "user"),
81
83
  ]);
82
84
  const out = [...own];
@@ -9,8 +9,10 @@
9
9
  * - and on the watch's interval, for changes nobody announces (an agent
10
10
  * writing files, a run progressing on disk).
11
11
  *
12
- * Evaluation runs in the watch's workspace. This module knows nothing about
13
- * routes: the socket hands it an `evaluate` closure.
12
+ * Evaluation runs in the watch's workspace — bound when the watch is made,
13
+ * so a re-evaluation whatever triggers it (a timer, a change elsewhere)
14
+ * never runs without one. This module knows nothing about routes: the
15
+ * socket hands it an `evaluate` closure.
14
16
  */
15
17
  interface Watcher {
16
18
  interval: number;
@@ -1,5 +1,5 @@
1
1
  import { onChanged } from "../core/changes.js";
2
- import { perWorkspace } from "../core/workspace.js";
2
+ import { currentWorkspace, perWorkspace } from "../core/workspace.js";
3
3
  const MIN_INTERVAL = 1000;
4
4
  const TOUCH_DEBOUNCE_MS = 60;
5
5
  const state = perWorkspace(() => ({ watches: new Map(), touchTimer: undefined }));
@@ -52,7 +52,8 @@ export function watch(key, evaluate, interval, push) {
52
52
  const { watches } = state();
53
53
  let w = watches.get(key);
54
54
  if (!w) {
55
- w = { evaluate, watchers: new Set() };
55
+ const ws = currentWorkspace();
56
+ w = { evaluate: () => ws.run(evaluate), watchers: new Set() };
56
57
  watches.set(key, w);
57
58
  }
58
59
  const watcher = { interval, push };
@@ -136,6 +136,7 @@ function installExitHandlers() {
136
136
  // Something still running for a workspace that was closed (a late callback) finds its state gone and throws
137
137
  // "workspace closed". That must not take the other workspaces down with it; anything else still crashes, as by default.
138
138
  const closedOnly = (err) => err instanceof Error && err.message === "workspace closed";
139
+ // Logged with the time, so `kraftwerk daemon logs` says when; the supervisor starts the server again.
139
140
  process.on("unhandledRejection", (err) => {
140
141
  if (closedOnly(err))
141
142
  return;
@@ -144,7 +145,7 @@ function installExitHandlers() {
144
145
  process.on("uncaughtException", (err) => {
145
146
  if (closedOnly(err))
146
147
  return;
147
- console.error(err);
148
+ console.error(`✖ ${new Date().toISOString()} crashed:`, err);
148
149
  process.exit(1);
149
150
  });
150
151
  // Chat agent subprocesses must die with the server — signals bypass "exit" handlers, so hook the signals themselves.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.72.0",
3
+ "version": "0.73.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",