@netnodeag/kraftwerk 0.71.1 → 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.
Files changed (41) hide show
  1. package/README.md +13 -0
  2. package/dist/cli/daemon-control.d.ts +15 -0
  3. package/dist/cli/daemon-control.js +228 -0
  4. package/dist/cli/daemon-login.d.ts +2 -0
  5. package/dist/cli/daemon-login.js +9 -2
  6. package/dist/cli/daemon.js +52 -3
  7. package/dist/cli/doctor.js +3 -1
  8. package/dist/cli/ui.js +33 -11
  9. package/dist/client/routes.d.ts +2 -0
  10. package/dist/client/routes.js +2 -0
  11. package/dist/core/daemon.d.ts +16 -0
  12. package/dist/core/daemon.js +18 -1
  13. package/dist/core/devices.d.ts +21 -0
  14. package/dist/core/devices.js +26 -1
  15. package/dist/core/skills.js +4 -2
  16. package/dist/server/api/devices.d.ts +13 -2
  17. package/dist/server/api/devices.js +26 -1
  18. package/dist/server/api/index.d.ts +13 -2
  19. package/dist/server/live.d.ts +4 -2
  20. package/dist/server/live.js +3 -2
  21. package/dist/server/push.d.ts +52 -0
  22. package/dist/server/push.js +67 -0
  23. package/dist/server/relay.d.ts +5 -0
  24. package/dist/server/relay.js +15 -0
  25. package/dist/server/server.js +2 -1
  26. package/package.json +1 -1
  27. package/web/dist/assets/{dist-BcEOC-ps.js → dist-BLd_aA1g.js} +1 -1
  28. package/web/dist/assets/{dist-5bpaJOp6.js → dist-Bu0rxkzF.js} +1 -1
  29. package/web/dist/assets/{dist-BFetH-PC.js → dist-C8xoYPYE.js} +1 -1
  30. package/web/dist/assets/{dist-4mt46bwa.js → dist-CVuec2Mm.js} +1 -1
  31. package/web/dist/assets/{dist-Coej_0FF.js → dist-CYnXUgv0.js} +1 -1
  32. package/web/dist/assets/dist-D-uSPljm.js +1 -0
  33. package/web/dist/assets/{dist-CANEcq7F.js → dist-DFZgINkL.js} +1 -1
  34. package/web/dist/assets/{dist-BmsKU6ix.js → dist-GJo81x50.js} +1 -1
  35. package/web/dist/assets/{dist-Du3myOTO.js → dist-H3OFzCSU.js} +1 -1
  36. package/web/dist/assets/{dist-8As2tNSv.js → dist-LUiVqSl6.js} +1 -1
  37. package/web/dist/assets/{dist-FeMkWwZl.js → dist-WCCGMh5F.js} +1 -1
  38. package/web/dist/assets/{editor-B3am-j9U.js → editor-6D-WNjZY.js} +3 -3
  39. package/web/dist/assets/{index-CrBIiOwc.js → index-D8-2MY0Q.js} +3 -3
  40. package/web/dist/index.html +1 -1
  41. package/web/dist/assets/dist-CWrRm3RK.js +0 -1
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
@@ -452,6 +458,13 @@ Everything between the phone and the machine is end to end encrypted:
452
458
  - One trust remains: the remote page's code comes from the cloud that serves
453
459
  it. A native app will remove that.
454
460
 
461
+ **Push notifications.** The kraftwerk iOS app gets a notification when
462
+ something new needs you: an approval, a question from an agent, a failed run
463
+ or routine. Each paired phone gives the machine a key of its own, and the
464
+ machine seals every notification with it. The relay and Apple pass along only
465
+ "Something needs you" and the badge count. The app opens the sealed text on
466
+ the phone. Turning notifications off on the phone, or unpairing it, stops them.
467
+
455
468
  The machine's key and its relay secret live in `~/.kraftwerk/relay.json`
456
469
  (owner only). The daemon must run (`kraftwerk daemon install`), and the Mac
457
470
  must be awake.
@@ -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,12 +1,14 @@
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
- import { applyRelay } from "../server/relay.js";
5
+ import { startPushWatcher } from "../server/push.js";
6
+ import { applyRelay, relayPush } from "../server/relay.js";
6
7
  import { startHub } from "../server/server.js";
7
8
  import { getPkgVersion } from "../server/version.js";
8
9
  import { ensureBuilt, supervise } from "./ui.js";
9
10
  import { installDaemon, uninstallDaemon } from "./daemon-login.js";
11
+ import { daemonLogs, daemonStatus, restartDaemon, startDaemon, stopDaemon } from "./daemon-control.js";
10
12
  /**
11
13
  * `kraftwerk daemon` — the machine's kraftwerk: one process serving every
12
14
  * open workspace, each at `<slug>.localhost:<port>` (and its own port, when
@@ -26,6 +28,7 @@ export async function runDaemon(opts) {
26
28
  process.exit(1);
27
29
  }
28
30
  const port = Number(opts.port ?? process.env.KRAFTWERK_DAEMON_PORT ?? DAEMON_PORT);
31
+ setDaemonVersion(await getPkgVersion());
29
32
  const reopen = await lastOpenRoots();
30
33
  const hub = await startHub({
31
34
  port,
@@ -42,13 +45,15 @@ export async function runDaemon(opts) {
42
45
  console.log(`${chalk.yellow("⚠")} not reopened: ${tildify(root)} — ${err.message}`);
43
46
  }
44
47
  }
45
- 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()})`)}`);
46
49
  for (const o of hub.list())
47
50
  console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
48
51
  if (!hub.list().length)
49
52
  console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
50
53
  // The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
51
54
  const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
55
+ // What needs you, pushed to the phones that asked (through the relay, sealed per device).
56
+ startPushWatcher(hub, relayPush);
52
57
  if (relay.state !== "off")
53
58
  console.log(`${chalk.green("✔")} Reachable from anywhere through ${chalk.cyan(relay.url ?? "")} ${chalk.dim("(`kraftwerk remote pair` pairs a device)")}`);
54
59
  const lan = networkUrls(hub.port);
@@ -61,9 +66,53 @@ export function registerDaemonCommand(program) {
61
66
  .description("Run the machine's kraftwerk: one process serving every open workspace at <slug>.localhost; install it as a login item")
62
67
  .option("--port <port>", `Port (default: KRAFTWERK_DAEMON_PORT, else ${DAEMON_PORT})`)
63
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`)
64
77
  .action(async (opts) => {
78
+ if (opts.detach)
79
+ return startDaemon(opts);
65
80
  await runDaemon(opts);
66
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
+ });
67
116
  daemon
68
117
  .command("install")
69
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
  }
@@ -44,11 +44,13 @@ export declare const ROUTES: {
44
44
  readonly "chats.steer": readonly ["POST", "/api/chats/:id/steer"];
45
45
  readonly "chats.stopTask": readonly ["POST", "/api/chats/:id/task-stop"];
46
46
  readonly "devices.code": readonly ["POST", "/api/devices/code"];
47
+ readonly "devices.dropPush": readonly ["DELETE", "/api/devices/push"];
47
48
  readonly "devices.list": readonly ["GET", "/api/devices"];
48
49
  readonly "devices.pair": readonly ["POST", "/api/pair"];
49
50
  readonly "devices.relay": readonly ["GET", "/api/devices/relay"];
50
51
  readonly "devices.revoke": readonly ["DELETE", "/api/devices/:id"];
51
52
  readonly "devices.self": readonly ["GET", "/api/devices/self"];
53
+ readonly "devices.setPush": readonly ["POST", "/api/devices/push"];
52
54
  readonly "devices.setRelay": readonly ["POST", "/api/devices/relay"];
53
55
  readonly "files.delete": readonly ["DELETE", "/api/files"];
54
56
  readonly "files.list": readonly ["GET", "/api/files/list"];
@@ -45,11 +45,13 @@ export const ROUTES = {
45
45
  "chats.steer": ["POST", "/api/chats/:id/steer"],
46
46
  "chats.stopTask": ["POST", "/api/chats/:id/task-stop"],
47
47
  "devices.code": ["POST", "/api/devices/code"],
48
+ "devices.dropPush": ["DELETE", "/api/devices/push"],
48
49
  "devices.list": ["GET", "/api/devices"],
49
50
  "devices.pair": ["POST", "/api/pair"],
50
51
  "devices.relay": ["GET", "/api/devices/relay"],
51
52
  "devices.revoke": ["DELETE", "/api/devices/:id"],
52
53
  "devices.self": ["GET", "/api/devices/self"],
54
+ "devices.setPush": ["POST", "/api/devices/push"],
53
55
  "devices.setRelay": ["POST", "/api/devices/relay"],
54
56
  "files.delete": ["DELETE", "/api/files"],
55
57
  "files.list": ["GET", "/api/files/list"],
@@ -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;
@@ -15,6 +15,18 @@ export interface Device {
15
15
  name: string;
16
16
  createdAt: string;
17
17
  lastSeenAt?: string;
18
+ /** Whether the device gets push notifications (its app registered for them). */
19
+ push?: boolean;
20
+ }
21
+ /**
22
+ * Where a device's push notifications go: its APNs token and environment, and
23
+ * the key (32 bytes, base64) the device made for this machine — what is pushed
24
+ * is sealed with it, so the relay and Apple carry it without reading it.
25
+ */
26
+ export interface PushTarget {
27
+ token: string;
28
+ environment: "sandbox" | "production";
29
+ key: string;
18
30
  }
19
31
  /** How long a pairing code works. */
20
32
  export declare const PAIR_CODE_TTL_MS: number;
@@ -33,3 +45,12 @@ export declare function deviceForToken(token: string): Promise<Device | null>;
33
45
  export declare function listDevices(): Promise<Device[]>;
34
46
  /** Unpair a device: its token stops working at once. */
35
47
  export declare function revokeDevice(id: string): Promise<boolean>;
48
+ /** Register (or, with null, drop) where a device's push notifications go. */
49
+ export declare function setDevicePush(id: string, target: PushTarget | null): Promise<boolean>;
50
+ /** Every device that gets push notifications, with where they go. */
51
+ export declare function pushTargets(): Promise<Array<{
52
+ id: string;
53
+ name: string;
54
+ } & PushTarget>>;
55
+ /** Apple says a token is gone (the app was deleted): stop pushing to it. */
56
+ export declare function dropPushToken(token: string): Promise<void>;