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