@netnodeag/kraftwerk 0.75.0 → 0.75.1

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.
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, openSync, promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
- import { daemonLogPath, findDaemon, readDaemonFile } from "../core/daemon.js";
5
+ import { daemonLogPath, daemonState, findDaemon, readDaemonFile } from "../core/daemon.js";
6
6
  import { tildify } from "../core/instances.js";
7
7
  import { selfCommand } from "../core/self-command.js";
8
8
  import { getPkgVersion } from "../server/version.js";
@@ -42,7 +42,7 @@ const ago = (iso) => {
42
42
  };
43
43
  async function getJson(port, p) {
44
44
  try {
45
- const r = await fetch(`http://127.0.0.1:${port}${p}`, { signal: AbortSignal.timeout(2000) });
45
+ const r = await fetch(`http://127.0.0.1:${port}${p}`, { signal: AbortSignal.timeout(3000) });
46
46
  return r.ok ? (await r.json()) : null;
47
47
  }
48
48
  catch {
@@ -62,10 +62,14 @@ async function tail(n) {
62
62
  }
63
63
  }
64
64
  async function status() {
65
- const [running, rec, installed] = await Promise.all([findDaemon(), readDaemonFile(), getPkgVersion()]);
65
+ const [found, rec, installed] = await Promise.all([daemonState(), readDaemonFile(), getPkgVersion()]);
66
66
  const base = { installed, loginItem: daemonLoginItem() !== null, log: daemonLogPath() };
67
- if (!running)
67
+ if (found.state === "stopped")
68
68
  return { running: false, ...base };
69
+ const running = found;
70
+ if (found.state === "busy") {
71
+ return { running: true, busy: true, ...base, pid: found.pid, port: found.port, ...(rec?.version ? { version: rec.version } : {}), ...(rec?.startedAt ? { startedAt: rec.startedAt } : {}) };
72
+ }
69
73
  const [hub, relay] = await Promise.all([
70
74
  getJson(running.port, "/api/hub"),
71
75
  getJson(running.port, "/api/devices/relay"),
@@ -87,7 +91,12 @@ export async function daemonStatus(opts) {
87
91
  console.log(JSON.stringify(s, null, 2));
88
92
  process.exit(s.running ? 0 : NOT_RUNNING);
89
93
  }
90
- if (s.running) {
94
+ if (s.busy) {
95
+ const up = ago(s.startedAt);
96
+ console.log(`${chalk.yellow("●")} kraftwerk daemon ${chalk.yellow("running, but slow to answer")} ${chalk.dim(`pid ${s.pid}, port ${s.port}${up ? `, up ${up}` : ""}${s.version ? `, ${s.version}` : ""}`)}`);
97
+ console.log(chalk.dim(" its process lives and holds its port — busy (agents at work, a loaded machine); ask again in a moment"));
98
+ }
99
+ else if (s.running) {
91
100
  const up = ago(s.startedAt);
92
101
  const behind = s.version && s.version !== s.installed ? chalk.yellow(` — ${s.installed} is installed: \`kraftwerk daemon restart\``) : "";
93
102
  console.log(`${chalk.green("●")} kraftwerk daemon ${chalk.green("running")} ${chalk.dim(`pid ${s.pid}${up ? `, up ${up}` : ""}${s.version ? `, ${s.version}` : ""}`)}${behind}`);
@@ -138,9 +147,10 @@ async function launch(port) {
138
147
  return "background";
139
148
  }
140
149
  export async function startDaemon(opts) {
141
- const running = await findDaemon();
142
- if (running) {
143
- console.log(`${chalk.green("✔")} the daemon already runs ${chalk.dim(`(pid ${running.pid}, http://localhost:${running.port})`)}`);
150
+ const running = await daemonState();
151
+ if (running.state !== "stopped") {
152
+ const busy = running.state === "busy" ? ", busy — slow to answer right now" : "";
153
+ console.log(`${chalk.green("✔")} the daemon already runs ${chalk.dim(`(pid ${running.pid}, http://localhost:${running.port}${busy})`)}`);
144
154
  return;
145
155
  }
146
156
  const how = await launch(opts.port);
@@ -157,9 +167,10 @@ export async function startDaemon(opts) {
157
167
  }
158
168
  /** Stop it: its supervisor first (else it would start the daemon again), then wait until it is gone. */
159
169
  async function stopRunning() {
160
- const [running, rec] = await Promise.all([findDaemon(), readDaemonFile()]);
161
- if (!running)
170
+ const [found, rec] = await Promise.all([daemonState(), readDaemonFile()]);
171
+ if (found.state === "stopped")
162
172
  return false;
173
+ const running = found;
163
174
  const target = rec?.pid === running.pid && alive(rec.supervisor) ? rec.supervisor : running.pid;
164
175
  process.kill(target, "SIGTERM");
165
176
  const gone = await until(async () => !alive(running.pid) && !alive(target) && (await findDaemon()) === null, 20_000);
@@ -182,9 +193,15 @@ export async function stopDaemon() {
182
193
  }
183
194
  /** Restart: the running daemon relaunches itself with the current install (its supervisor stays); none running — start one. */
184
195
  export async function restartDaemon(opts) {
185
- const running = await findDaemon();
186
- if (!running)
196
+ const found = await daemonState();
197
+ if (found.state === "stopped")
187
198
  return startDaemon(opts);
199
+ if (found.state === "busy") {
200
+ // It does not answer, so it cannot be asked to relaunch: stopping it is the only way (its agents are interrupted).
201
+ await stopRunning();
202
+ return startDaemon(opts);
203
+ }
204
+ const running = found;
188
205
  // The restart route lives in every workspace (the daemon's root answers machine routes only); any open one will do.
189
206
  const hub = await getJson(running.port, "/api/hub");
190
207
  const slug = hub?.workspaces?.[0]?.slug;
@@ -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 { daemonLogPath, findDaemon } from "../core/daemon.js";
6
+ import { daemonLogPath, daemonState } from "../core/daemon.js";
7
7
  import { selfCommand } from "../core/self-command.js";
8
8
  import { tildify } from "../core/instances.js";
9
9
  /**
@@ -72,8 +72,8 @@ export async function installDaemon(opts) {
72
72
  await fs.writeFile(file, renderPlist([cmd, ...args], env, logPath()));
73
73
  // An older version of the item is replaced: unload it first (an error just means it was not loaded).
74
74
  spawnSync(launchctl(), ["bootout", `${domain()}/${LAUNCH_LABEL}`], { stdio: "ignore" });
75
- const running = await findDaemon();
76
- if (running) {
75
+ const running = await daemonState();
76
+ if (running.state !== "stopped") {
77
77
  // Loading now would start a second daemon, which refuses to run. launchd loads the file at the next login.
78
78
  console.log(`${chalk.green("✔")} login item installed: ${chalk.dim(tildify(file))}`);
79
79
  console.log(chalk.dim(` a daemon already runs (pid ${running.pid}); from the next login on, launchd starts it`));
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { DAEMON_PORT, findDaemon, lastOpenRoots, setDaemonVersion, writeDaemonFile } from "../core/daemon.js";
2
+ import { DAEMON_PORT, daemonState, 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";
@@ -22,9 +22,10 @@ export async function runDaemon(opts) {
22
22
  process.env.KRAFTWERK_UI_HOST ||= "0.0.0.0";
23
23
  if (process.env.KRAFTWERK_UI_SUPERVISED !== "1")
24
24
  return supervise(["daemon", ...(opts.port ? ["--port", opts.port] : [])], "the daemon");
25
- const running = await findDaemon();
26
- if (running && running.pid !== process.pid) {
27
- console.error(chalk.red(`✖ a kraftwerk daemon already runs (pid ${running.pid}, port ${running.port})`));
25
+ // A busy one counts too: a second daemon would only crash on its port.
26
+ const running = await daemonState();
27
+ if (running.state !== "stopped" && running.pid !== process.pid) {
28
+ console.error(chalk.red(`✖ a kraftwerk daemon already runs (pid ${running.pid}, port ${running.port}${running.state === "busy" ? ", busy right now" : ""})`));
28
29
  process.exit(1);
29
30
  }
30
31
  const port = Number(opts.port ?? process.env.KRAFTWERK_DAEMON_PORT ?? DAEMON_PORT);
@@ -9,7 +9,7 @@ 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";
12
+ import { daemonState } from "../core/daemon.js";
13
13
  import { daemonLoginItem } from "./daemon-login.js";
14
14
  import { readRelaySettings } from "../server/relay.js";
15
15
  const ICONS = {
@@ -214,9 +214,10 @@ export async function runDoctor(cwd) {
214
214
  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`)");
215
215
  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`");
216
216
  // The daemon: whether one runs, and whether it starts at login.
217
- const daemon = await findDaemon();
217
+ const state = await daemonState();
218
+ const daemon = state.state === "stopped" ? null : state;
218
219
  const loginItem = daemonLoginItem();
219
- report("info", daemon ? `kraftwerk daemon running on :${daemon.port}` : "no kraftwerk daemon running", loginItem
220
+ report("info", !daemon ? "no kraftwerk daemon running" : daemon.state === "busy" ? `kraftwerk daemon running on :${daemon.port}, but slow to answer (busy)` : `kraftwerk daemon running on :${daemon.port}`, loginItem
220
221
  ? daemon
221
222
  ? "starts at login · `kraftwerk daemon status` for more"
222
223
  : "starts at login, but not running now · `kraftwerk daemon start`, `kraftwerk daemon logs` says why it stopped"
@@ -29,10 +29,24 @@ export declare function lastOpenRoots(): Promise<string[]>;
29
29
  /** The daemon file as the last daemon left it; null without one. */
30
30
  export declare function readDaemonFile(): Promise<DaemonFile | null>;
31
31
  /** The running daemon (its file, and a hub that answers on its port), or null. */
32
- export declare function findDaemon(): Promise<{
32
+ export declare function findDaemon(timeoutMs?: number): Promise<{
33
33
  pid: number;
34
34
  port: number;
35
35
  } | null>;
36
+ export type DaemonState = {
37
+ state: "running" | "busy";
38
+ pid: number;
39
+ port: number;
40
+ } | {
41
+ state: "stopped";
42
+ };
43
+ /**
44
+ * Whether a daemon runs, telling a slow one from a stopped one: "busy" is a
45
+ * daemon whose process lives and whose port is taken but that did not answer
46
+ * in time. A busy daemon is never "stopped" — starting another would only
47
+ * crash on the taken port, and restarting it would interrupt its agents.
48
+ */
49
+ export declare function daemonState(timeoutMs?: number): Promise<DaemonState>;
36
50
  /** Ask the daemon to open or close a workspace (this machine's request: local trust). */
37
51
  export declare function daemonCall<T>(port: number, verb: "open" | "close", root: string): Promise<{
38
52
  ok: boolean;
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from "node:fs";
2
+ import net from "node:net";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  /**
@@ -47,18 +48,20 @@ export async function readDaemonFile() {
47
48
  return null;
48
49
  }
49
50
  }
51
+ /**
52
+ * How long a daemon may take to answer before it counts as not there. A
53
+ * daemon under load (agents at work, a busy machine) can take a second or
54
+ * two; 0.8 s once read such a one as stopped.
55
+ */
56
+ const ANSWER_MS = 3000;
50
57
  /** The running daemon (its file, and a hub that answers on its port), or null. */
51
- export async function findDaemon() {
52
- let rec;
53
- try {
54
- rec = JSON.parse(await fs.readFile(daemonFile(), "utf8"));
55
- }
56
- catch {
58
+ export async function findDaemon(timeoutMs = ANSWER_MS) {
59
+ const rec = await readDaemonFile();
60
+ if (!rec)
57
61
  return null;
58
- }
59
62
  try {
60
63
  // Every kraftwerk server answers /api/hub; only the daemon says it is one (a standalone `kraftwerk ui` may sit on that port now).
61
- const r = await fetch(`http://127.0.0.1:${rec.port}/api/hub`, { signal: AbortSignal.timeout(800) });
64
+ const r = await fetch(`http://127.0.0.1:${rec.port}/api/hub`, { signal: AbortSignal.timeout(timeoutMs) });
62
65
  const body = r.ok ? (await r.json()) : null;
63
66
  return body?.daemon === true ? { pid: rec.pid, port: rec.port } : null;
64
67
  }
@@ -66,6 +69,41 @@ export async function findDaemon() {
66
69
  return null;
67
70
  }
68
71
  }
72
+ const pidAlive = (pid) => {
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ }
77
+ catch (err) {
78
+ // EPERM: it exists, it is just not ours to signal.
79
+ return err.code === "EPERM";
80
+ }
81
+ };
82
+ const portTaken = (port) => new Promise((resolve) => {
83
+ const sock = net.connect({ host: "127.0.0.1", port });
84
+ const done = (taken) => {
85
+ sock.destroy();
86
+ resolve(taken);
87
+ };
88
+ sock.setTimeout(1000, () => done(true)); // accepted nothing yet, refused nothing either: something holds it
89
+ sock.once("connect", () => done(true));
90
+ sock.once("error", () => done(false));
91
+ });
92
+ /**
93
+ * Whether a daemon runs, telling a slow one from a stopped one: "busy" is a
94
+ * daemon whose process lives and whose port is taken but that did not answer
95
+ * in time. A busy daemon is never "stopped" — starting another would only
96
+ * crash on the taken port, and restarting it would interrupt its agents.
97
+ */
98
+ export async function daemonState(timeoutMs = ANSWER_MS) {
99
+ const found = await findDaemon(timeoutMs);
100
+ if (found)
101
+ return { state: "running", ...found };
102
+ const rec = await readDaemonFile();
103
+ if (rec && pidAlive(rec.pid) && (await portTaken(rec.port)))
104
+ return { state: "busy", pid: rec.pid, port: rec.port };
105
+ return { state: "stopped" };
106
+ }
69
107
  /** Ask the daemon to open or close a workspace (this machine's request: local trust). */
70
108
  export async function daemonCall(port, verb, root) {
71
109
  const r = await fetch(`http://127.0.0.1:${port}/api/hub/${verb}`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.75.0",
3
+ "version": "0.75.1",
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",