@netnodeag/kraftwerk 0.72.0 → 0.74.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 +6 -0
- package/dist/cli/daemon-control.d.ts +15 -0
- package/dist/cli/daemon-control.js +228 -0
- package/dist/cli/daemon-login.d.ts +2 -0
- package/dist/cli/daemon-login.js +9 -2
- package/dist/cli/daemon.js +48 -2
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/ui.js +33 -11
- package/dist/client/routes.d.ts +1 -0
- package/dist/client/routes.js +1 -0
- package/dist/core/chat/sessions.d.ts +13 -0
- package/dist/core/chat/sessions.js +22 -0
- package/dist/core/daemon.d.ts +16 -0
- package/dist/core/daemon.js +18 -1
- package/dist/core/skills.js +4 -2
- package/dist/server/api/chats.d.ts +4 -1
- package/dist/server/api/chats.js +29 -3
- package/dist/server/api/index.d.ts +4 -1
- package/dist/server/live.d.ts +4 -2
- package/dist/server/live.js +3 -2
- package/dist/server/server.js +2 -1
- package/package.json +1 -1
- package/web/dist/assets/{dist-LUiVqSl6.js → dist-BJQuUDA0.js} +1 -1
- package/web/dist/assets/{dist-Bu0rxkzF.js → dist-CESw77ha.js} +1 -1
- package/web/dist/assets/dist-CMMQorId.js +1 -0
- package/web/dist/assets/{dist-CYnXUgv0.js → dist-CRrnyH8Z.js} +1 -1
- package/web/dist/assets/{dist-BLd_aA1g.js → dist-CaGkA8bH.js} +1 -1
- package/web/dist/assets/{dist-C8xoYPYE.js → dist-Cnm1S16W.js} +1 -1
- package/web/dist/assets/{dist-CVuec2Mm.js → dist-D746Qhyu.js} +1 -1
- package/web/dist/assets/{dist-H3OFzCSU.js → dist-Df4EJMUZ.js} +1 -1
- package/web/dist/assets/{dist-GJo81x50.js → dist-INPpBMuI.js} +1 -1
- package/web/dist/assets/{dist-WCCGMh5F.js → dist-XZFWBcL9.js} +1 -1
- package/web/dist/assets/{dist-DFZgINkL.js → dist-r61B6qLC.js} +1 -1
- package/web/dist/assets/{editor-6D-WNjZY.js → editor-ByOSpxTX.js} +3 -3
- package/web/dist/assets/{index-D8-2MY0Q.js → index-CJTkdJMe.js} +3 -3
- package/web/dist/index.html +1 -1
- package/web/dist/assets/dist-D-uSPljm.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
|
|
@@ -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;
|
package/dist/cli/daemon-login.js
CHANGED
|
@@ -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 =
|
|
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);
|
package/dist/cli/daemon.js
CHANGED
|
@@ -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")
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
-
?
|
|
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.
|
|
102
|
-
*
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/client/routes.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare const ROUTES: {
|
|
|
40
40
|
readonly "chats.permission": readonly ["POST", "/api/chats/:id/permission"];
|
|
41
41
|
readonly "chats.rename": readonly ["PATCH", "/api/chats/:id"];
|
|
42
42
|
readonly "chats.resetSession": readonly ["POST", "/api/chats/:id/reset-session"];
|
|
43
|
+
readonly "chats.setProject": readonly ["POST", "/api/chats/:id/project"];
|
|
43
44
|
readonly "chats.setVibeable": readonly ["POST", "/api/chats/:id/vibeable"];
|
|
44
45
|
readonly "chats.steer": readonly ["POST", "/api/chats/:id/steer"];
|
|
45
46
|
readonly "chats.stopTask": readonly ["POST", "/api/chats/:id/task-stop"];
|
package/dist/client/routes.js
CHANGED
|
@@ -41,6 +41,7 @@ export const ROUTES = {
|
|
|
41
41
|
"chats.permission": ["POST", "/api/chats/:id/permission"],
|
|
42
42
|
"chats.rename": ["PATCH", "/api/chats/:id"],
|
|
43
43
|
"chats.resetSession": ["POST", "/api/chats/:id/reset-session"],
|
|
44
|
+
"chats.setProject": ["POST", "/api/chats/:id/project"],
|
|
44
45
|
"chats.setVibeable": ["POST", "/api/chats/:id/vibeable"],
|
|
45
46
|
"chats.steer": ["POST", "/api/chats/:id/steer"],
|
|
46
47
|
"chats.stopTask": ["POST", "/api/chats/:id/task-stop"],
|
|
@@ -16,6 +16,8 @@ export declare function createChat(opts: {
|
|
|
16
16
|
title?: string;
|
|
17
17
|
/** Continue one of the agent's own sessions (from listAgentSessionsFor) instead of starting fresh. */
|
|
18
18
|
resume?: string;
|
|
19
|
+
/** The project an agent's chat works in (meta.project); the caller checks that it exists. */
|
|
20
|
+
project?: string;
|
|
19
21
|
}): Promise<ChatMeta>;
|
|
20
22
|
/** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
|
|
21
23
|
export declare function chatAwaitingApproval(id: string): boolean;
|
|
@@ -153,6 +155,17 @@ export declare function renameChat(id: string, title: unknown): Promise<{
|
|
|
153
155
|
meta?: ChatMeta;
|
|
154
156
|
error?: string;
|
|
155
157
|
}>;
|
|
158
|
+
/**
|
|
159
|
+
* File an agent's chat under a project, or under none (`null`). The chat is
|
|
160
|
+
* then listed with the project, and the agent reads the project's context
|
|
161
|
+
* with its next message. Project chats and channels belong where
|
|
162
|
+
* their scope says and cannot move; the caller checks that the project exists.
|
|
163
|
+
*/
|
|
164
|
+
export declare function setChatProject(id: string, project: string | null): Promise<{
|
|
165
|
+
meta?: ChatMeta;
|
|
166
|
+
error?: string;
|
|
167
|
+
status?: number;
|
|
168
|
+
}>;
|
|
156
169
|
export declare function deleteChat(id: string): Promise<{
|
|
157
170
|
error?: string;
|
|
158
171
|
}>;
|
|
@@ -43,6 +43,7 @@ export async function createChat(opts) {
|
|
|
43
43
|
title: opts.title ?? "",
|
|
44
44
|
cwd,
|
|
45
45
|
scope: opts.scope,
|
|
46
|
+
...(opts.project && opts.scope.kind === "agent" ? { project: opts.project } : {}),
|
|
46
47
|
...(opts.resume && opts.agent !== "pi" ? { sessions: { [MAIN]: opts.resume } } : {}),
|
|
47
48
|
createdAt: now,
|
|
48
49
|
updatedAt: now,
|
|
@@ -478,6 +479,27 @@ export async function renameChat(id, title) {
|
|
|
478
479
|
await writeMeta(state.meta);
|
|
479
480
|
return { meta: state.meta };
|
|
480
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* File an agent's chat under a project, or under none (`null`). The chat is
|
|
484
|
+
* then listed with the project, and the agent reads the project's context
|
|
485
|
+
* with its next message. Project chats and channels belong where
|
|
486
|
+
* their scope says and cannot move; the caller checks that the project exists.
|
|
487
|
+
*/
|
|
488
|
+
export async function setChatProject(id, project) {
|
|
489
|
+
const state = await loadState(id);
|
|
490
|
+
if (!state)
|
|
491
|
+
return { error: "not found", status: 404 };
|
|
492
|
+
if (state.meta.scope.kind !== "agent")
|
|
493
|
+
return { error: "only an agent's chat can be filed under a project", status: 409 };
|
|
494
|
+
if (project)
|
|
495
|
+
state.meta.project = project;
|
|
496
|
+
else
|
|
497
|
+
delete state.meta.project;
|
|
498
|
+
for (const seat of state.seats.values())
|
|
499
|
+
seat.needsContext = true;
|
|
500
|
+
await writeMeta(state.meta);
|
|
501
|
+
return { meta: state.meta };
|
|
502
|
+
}
|
|
481
503
|
export async function deleteChat(id) {
|
|
482
504
|
const state = await loadState(id);
|
|
483
505
|
if (!state)
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/daemon.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/skills.js
CHANGED
|
@@ -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((
|
|
79
|
-
scanRoot(path.join(
|
|
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];
|