@netnodeag/kraftwerk 0.71.0 → 0.72.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 (32) hide show
  1. package/README.md +7 -0
  2. package/dist/cli/daemon.js +4 -1
  3. package/dist/client/relay.js +2 -1
  4. package/dist/client/routes.d.ts +2 -0
  5. package/dist/client/routes.js +2 -0
  6. package/dist/core/devices.d.ts +21 -0
  7. package/dist/core/devices.js +26 -1
  8. package/dist/server/api/devices.d.ts +13 -2
  9. package/dist/server/api/devices.js +26 -1
  10. package/dist/server/api/index.d.ts +13 -2
  11. package/dist/server/push.d.ts +52 -0
  12. package/dist/server/push.js +67 -0
  13. package/dist/server/relay.d.ts +7 -0
  14. package/dist/server/relay.js +35 -0
  15. package/dist/server/ws.js +3 -0
  16. package/package.json +1 -1
  17. package/web/dist/assets/{dist-BcEOC-ps.js → dist-BLd_aA1g.js} +1 -1
  18. package/web/dist/assets/{dist-5bpaJOp6.js → dist-Bu0rxkzF.js} +1 -1
  19. package/web/dist/assets/{dist-BFetH-PC.js → dist-C8xoYPYE.js} +1 -1
  20. package/web/dist/assets/{dist-4mt46bwa.js → dist-CVuec2Mm.js} +1 -1
  21. package/web/dist/assets/{dist-Coej_0FF.js → dist-CYnXUgv0.js} +1 -1
  22. package/web/dist/assets/dist-D-uSPljm.js +1 -0
  23. package/web/dist/assets/{dist-CANEcq7F.js → dist-DFZgINkL.js} +1 -1
  24. package/web/dist/assets/{dist-BmsKU6ix.js → dist-GJo81x50.js} +1 -1
  25. package/web/dist/assets/{dist-Du3myOTO.js → dist-H3OFzCSU.js} +1 -1
  26. package/web/dist/assets/{dist-8As2tNSv.js → dist-LUiVqSl6.js} +1 -1
  27. package/web/dist/assets/{dist-FeMkWwZl.js → dist-WCCGMh5F.js} +1 -1
  28. package/web/dist/assets/{editor-B3am-j9U.js → editor-6D-WNjZY.js} +3 -3
  29. package/web/dist/assets/{index-CrBIiOwc.js → index-D8-2MY0Q.js} +3 -3
  30. package/web/dist/index.html +1 -1
  31. package/web/dist-remote/sw.js +1 -1
  32. package/web/dist/assets/dist-CWrRm3RK.js +0 -1
package/README.md CHANGED
@@ -452,6 +452,13 @@ Everything between the phone and the machine is end to end encrypted:
452
452
  - One trust remains: the remote page's code comes from the cloud that serves
453
453
  it. A native app will remove that.
454
454
 
455
+ **Push notifications.** The kraftwerk iOS app gets a notification when
456
+ something new needs you: an approval, a question from an agent, a failed run
457
+ or routine. Each paired phone gives the machine a key of its own, and the
458
+ machine seals every notification with it. The relay and Apple pass along only
459
+ "Something needs you" and the badge count. The app opens the sealed text on
460
+ the phone. Turning notifications off on the phone, or unpairing it, stops them.
461
+
455
462
  The machine's key and its relay secret live in `~/.kraftwerk/relay.json`
456
463
  (owner only). The daemon must run (`kraftwerk daemon install`), and the Mac
457
464
  must be awake.
@@ -2,7 +2,8 @@ import chalk from "chalk";
2
2
  import { DAEMON_PORT, findDaemon, lastOpenRoots, 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";
@@ -49,6 +50,8 @@ export async function runDaemon(opts) {
49
50
  console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
50
51
  // The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
51
52
  const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
53
+ // What needs you, pushed to the phones that asked (through the relay, sealed per device).
54
+ startPushWatcher(hub, relayPush);
52
55
  if (relay.state !== "off")
53
56
  console.log(`${chalk.green("✔")} Reachable from anywhere through ${chalk.cyan(relay.url ?? "")} ${chalk.dim("(`kraftwerk remote pair` pairs a device)")}`);
54
57
  const lan = networkUrls(hub.port);
@@ -158,8 +158,9 @@ const OPEN = 1;
158
158
  export async function connectTunnel(opts) {
159
159
  const id = await relayId(opts.publicKey);
160
160
  const url = `${opts.relayUrl}${opts.relayUrl.includes("?") ? "&" : "?"}id=${encodeURIComponent(id)}`;
161
- const ws = opts.connect ? opts.connect(url) : new WebSocket(url);
161
+ // The keys first: once the socket exists, its events may fire at any await — the listeners below must already be there.
162
162
  const hs = await clientHandshake(opts.publicKey);
163
+ const ws = opts.connect ? opts.connect(url) : new WebSocket(url);
163
164
  let channel = null;
164
165
  let closedWhy = "";
165
166
  let resolveClosed;
@@ -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"],
@@ -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>;
@@ -88,7 +88,7 @@ function update(change) {
88
88
  chain = run.catch(() => { });
89
89
  return run;
90
90
  }
91
- const view = ({ tokenHash: _h, ...d }) => d;
91
+ const view = ({ tokenHash: _h, pushTarget, ...d }) => ({ ...d, ...(pushTarget ? { push: true } : {}) });
92
92
  /** A new one-time pairing code, valid for PAIR_CODE_TTL_MS. Shown once; the store keeps its hash. */
93
93
  export async function createPairCode() {
94
94
  let code = "";
@@ -154,3 +154,28 @@ export async function revokeDevice(id) {
154
154
  return store.devices.length < before;
155
155
  });
156
156
  }
157
+ /** Register (or, with null, drop) where a device's push notifications go. */
158
+ export async function setDevicePush(id, target) {
159
+ return update((store) => {
160
+ const d = store.devices.find((x) => x.id === id);
161
+ if (!d)
162
+ return false;
163
+ if (target)
164
+ d.pushTarget = target;
165
+ else
166
+ delete d.pushTarget;
167
+ return true;
168
+ });
169
+ }
170
+ /** Every device that gets push notifications, with where they go. */
171
+ export async function pushTargets() {
172
+ return (await read()).devices.filter((d) => d.pushTarget).map((d) => ({ id: d.id, name: d.name, ...d.pushTarget }));
173
+ }
174
+ /** Apple says a token is gone (the app was deleted): stop pushing to it. */
175
+ export async function dropPushToken(token) {
176
+ await update((store) => {
177
+ for (const d of store.devices)
178
+ if (d.pushTarget?.token === token)
179
+ delete d.pushTarget;
180
+ });
181
+ }
@@ -5,7 +5,9 @@ export declare const deviceRoutes: (import("./router.js").Route<"devices.code",
5
5
  expiresAt: string;
6
6
  urls: string[];
7
7
  remote?: string | undefined;
8
- }, "/api/devices/code", undefined> | import("./router.js").Route<"devices.list", {
8
+ }, "/api/devices/code", undefined> | import("./router.js").Route<"devices.dropPush", {
9
+ ok: boolean;
10
+ }, "/api/devices/push", undefined> | import("./router.js").Route<"devices.list", {
9
11
  devices: import("../../core/devices.js").Device[];
10
12
  }, "/api/devices", undefined> | import("./router.js").Route<"devices.pair", import("./router.js").Reply<{
11
13
  token: string;
@@ -29,7 +31,16 @@ export declare const deviceRoutes: (import("./router.js").Route<"devices.code",
29
31
  }, "/api/devices/:id", undefined> | import("./router.js").Route<"devices.self", {
30
32
  trust: "device" | "local" | "none";
31
33
  device?: import("../../core/devices.js").Device | undefined;
32
- }, "/api/devices/self", undefined> | import("./router.js").Route<"devices.setRelay", {
34
+ }, "/api/devices/self", undefined> | import("./router.js").Route<"devices.setPush", {
35
+ ok: boolean;
36
+ }, "/api/devices/push", z.ZodObject<{
37
+ token: z.ZodString;
38
+ environment: z.ZodEnum<{
39
+ production: "production";
40
+ sandbox: "sandbox";
41
+ }>;
42
+ key: z.ZodString;
43
+ }, z.core.$strip>> | import("./router.js").Route<"devices.setRelay", {
33
44
  state: import("../relay.js").RelayState;
34
45
  id?: string;
35
46
  key?: string;
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { createPairCode, listDevices, redeemPairCode, revokeDevice } from "../../core/devices.js";
2
+ import { createPairCode, listDevices, redeemPairCode, revokeDevice, setDevicePush } from "../../core/devices.js";
3
3
  import { networkUrls } from "../bind.js";
4
4
  import { currentHub } from "../hub-context.js";
5
5
  import { applyRelay, keyFingerprint, readRelaySettings, relayStatus, remoteLink, setRelay } from "../relay.js";
@@ -48,6 +48,31 @@ export const deviceRoutes = [
48
48
  trust: c.trust.kind,
49
49
  ...(c.trust.kind === "device" ? { device: c.trust.device } : {}),
50
50
  })),
51
+ // A paired device's app says where its push notifications go (and the key it seals them with); see server/push.ts.
52
+ route({
53
+ name: "devices.setPush",
54
+ method: "POST",
55
+ path: "/api/devices/push",
56
+ summary: "this device's push notifications {token, environment, key}",
57
+ errors: 400,
58
+ body: z.object({
59
+ token: z.string().regex(/^[0-9a-fA-F]{32,200}$/),
60
+ environment: z.enum(["sandbox", "production"]),
61
+ key: z.string().regex(/^[A-Za-z0-9+/]{43}=$/),
62
+ }),
63
+ }, async (c) => {
64
+ if (c.trust.kind !== "device")
65
+ fail(400, "only a paired device gets push notifications");
66
+ const { token, environment, key } = await c.body();
67
+ await setDevicePush(c.trust.device.id, { token: token.toLowerCase(), environment, key });
68
+ return { ok: true };
69
+ }),
70
+ route({ name: "devices.dropPush", method: "DELETE", path: "/api/devices/push", summary: "no more push notifications for this device", errors: 400 }, async (c) => {
71
+ if (c.trust.kind !== "device")
72
+ fail(400, "only a paired device gets push notifications");
73
+ await setDevicePush(c.trust.device.id, null);
74
+ return { ok: true };
75
+ }),
51
76
  route({ name: "devices.list", method: "GET", path: "/api/devices", summary: "every paired device", access: "local" }, async () => ({ devices: await listDevices() })),
52
77
  // The code is shown once; the addresses are where a device on the network opens kraftwerk (none while bound to this machine only),
53
78
  // and with the relay on, `remote` is the link that pairs a device from anywhere (code and machine key in its fragment).
@@ -289,7 +289,9 @@ export declare const routes: (Route<"agents.addJournal", {
289
289
  expiresAt: string;
290
290
  urls: string[];
291
291
  remote?: string | undefined;
292
- }, "/api/devices/code", undefined> | Route<"devices.list", {
292
+ }, "/api/devices/code", undefined> | Route<"devices.dropPush", {
293
+ ok: boolean;
294
+ }, "/api/devices/push", undefined> | Route<"devices.list", {
293
295
  devices: import("../../core/devices.js").Device[];
294
296
  }, "/api/devices", undefined> | Route<"devices.pair", import("./router.js").Reply<{
295
297
  token: string;
@@ -313,7 +315,16 @@ export declare const routes: (Route<"agents.addJournal", {
313
315
  }, "/api/devices/:id", undefined> | Route<"devices.self", {
314
316
  trust: "device" | "local" | "none";
315
317
  device?: import("../../core/devices.js").Device | undefined;
316
- }, "/api/devices/self", undefined> | Route<"devices.setRelay", {
318
+ }, "/api/devices/self", undefined> | Route<"devices.setPush", {
319
+ ok: boolean;
320
+ }, "/api/devices/push", z.ZodObject<{
321
+ token: z.ZodString;
322
+ environment: z.ZodEnum<{
323
+ production: "production";
324
+ sandbox: "sandbox";
325
+ }>;
326
+ key: z.ZodString;
327
+ }, z.core.$strip>> | Route<"devices.setRelay", {
317
328
  state: import("../relay.js").RelayState;
318
329
  id?: string;
319
330
  key?: string;
@@ -0,0 +1,52 @@
1
+ import { type AttentionItem } from "../core/attention.js";
2
+ import type { Hub } from "./server.js";
3
+ /**
4
+ * Push notifications for what needs you: the daemon looks at every open
5
+ * workspace's attention list (approvals, questions, failures) and, for each
6
+ * item that is new, sends every device that asked for pushes a notification
7
+ * through the relay — sealed with the key that device made for this machine
8
+ * (AES-256-GCM), so the relay and Apple carry it without reading it. On the
9
+ * phone, the app's notification extension opens it and shows the text; what
10
+ * travels in the clear is only "something needs you" and the badge count.
11
+ *
12
+ * → relay {t:"push", pushes:[{token, environment, sealed, badge, thread}]}
13
+ * ← relay {t:"push_invalid", token} Apple says the token is gone
14
+ *
15
+ * Items present when the daemon starts are not pushed (a restart must not
16
+ * ring every phone); a resolved item that comes back is new again.
17
+ */
18
+ /** What a notification says, before it is sealed. The phone reads these names. */
19
+ export interface PushContent {
20
+ title: string;
21
+ body: string;
22
+ subtitle?: string;
23
+ /** The workspace (slug) and the chat to open on tap. */
24
+ ws: string;
25
+ chat?: string;
26
+ kind: AttentionItem["kind"];
27
+ }
28
+ export interface PushMessage {
29
+ token: string;
30
+ environment: "sandbox" | "production";
31
+ /** base64(nonce ‖ ciphertext ‖ tag) of the JSON PushContent. */
32
+ sealed: string;
33
+ badge: number;
34
+ /** Groups a workspace's notifications on the phone. */
35
+ thread: string;
36
+ }
37
+ /** Seal a notification's content with a device's key (base64, 32 bytes). */
38
+ export declare function sealPush(keyBase64: string, content: PushContent): string;
39
+ export declare function contentOf(item: AttentionItem, workspace: {
40
+ slug: string;
41
+ name: string;
42
+ }): PushContent;
43
+ export interface PushWatcher {
44
+ /** Look now (tests; the timer does it every `everyMs`). */
45
+ scan(): Promise<number>;
46
+ stop(): void;
47
+ }
48
+ /**
49
+ * Watch the hub's workspaces and hand each new item's notifications to `send`
50
+ * (the relay link); returns how many were sent per scan.
51
+ */
52
+ export declare function startPushWatcher(hub: Hub, send: (pushes: PushMessage[]) => boolean, everyMs?: number): PushWatcher;
@@ -0,0 +1,67 @@
1
+ import { createCipheriv, randomBytes } from "node:crypto";
2
+ import { listAttention } from "../core/attention.js";
3
+ import { pushTargets } from "../core/devices.js";
4
+ /** Seal a notification's content with a device's key (base64, 32 bytes). */
5
+ export function sealPush(keyBase64, content) {
6
+ const key = Buffer.from(keyBase64, "base64");
7
+ if (key.length !== 32)
8
+ throw new Error("a push key is 32 bytes");
9
+ const nonce = randomBytes(12);
10
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
11
+ const ct = Buffer.concat([cipher.update(JSON.stringify(content), "utf8"), cipher.final()]);
12
+ return Buffer.concat([nonce, ct, cipher.getAuthTag()]).toString("base64");
13
+ }
14
+ const LABEL = { approval: "Approval", question: "Question", failed: "Failed" };
15
+ export function contentOf(item, workspace) {
16
+ return {
17
+ title: `${LABEL[item.kind] ?? "Needs you"} · ${workspace.name}`,
18
+ body: item.title,
19
+ ...(item.chatTitle ? { subtitle: item.chatTitle } : {}),
20
+ ws: workspace.slug,
21
+ ...(item.chatId ? { chat: item.chatId } : {}),
22
+ kind: item.kind,
23
+ };
24
+ }
25
+ /**
26
+ * Watch the hub's workspaces and hand each new item's notifications to `send`
27
+ * (the relay link); returns how many were sent per scan.
28
+ */
29
+ export function startPushWatcher(hub, send, everyMs = 10_000) {
30
+ let seen = null;
31
+ let running = false;
32
+ const scan = async () => {
33
+ if (running)
34
+ return 0;
35
+ running = true;
36
+ try {
37
+ const now = new Map();
38
+ for (const open of hub.list()) {
39
+ const view = await open.ws.run(() => listAttention()).catch(() => null);
40
+ for (const item of view?.items ?? [])
41
+ now.set(`${open.slug}\n${item.id}`, { item, ws: { slug: open.slug, name: open.name } });
42
+ }
43
+ const first = seen === null;
44
+ const fresh = first ? [] : [...now.entries()].filter(([k]) => !seen.has(k)).map(([, v]) => v);
45
+ seen = new Set(now.keys());
46
+ if (!fresh.length)
47
+ return 0;
48
+ const targets = await pushTargets();
49
+ if (!targets.length)
50
+ return 0;
51
+ const pushes = [];
52
+ for (const { item, ws } of fresh) {
53
+ const content = contentOf(item, ws);
54
+ for (const t of targets) {
55
+ pushes.push({ token: t.token, environment: t.environment, sealed: sealPush(t.key, content), badge: now.size, thread: ws.slug });
56
+ }
57
+ }
58
+ return send(pushes) ? pushes.length : 0;
59
+ }
60
+ finally {
61
+ running = false;
62
+ }
63
+ };
64
+ const timer = setInterval(() => void scan().catch(() => { }), everyMs);
65
+ timer.unref?.();
66
+ return { scan, stop: () => clearInterval(timer) };
67
+ }
@@ -1,3 +1,4 @@
1
+ import type { PushMessage } from "./push.js";
1
2
  /**
2
3
  * The daemon's end of the relay (see src/client/relay.ts for the protocol):
3
4
  * an outgoing socket to the relay, one encrypted channel per client behind
@@ -53,6 +54,8 @@ export declare const relayEndpoint: (cloudUrl: string, role: "server" | "client"
53
54
  export declare function remoteLink(cloudUrl: string, key: string, code?: string, remoteUrl?: string | undefined): string;
54
55
  export interface RelayLink {
55
56
  status(): RelayStatus;
57
+ /** Hand push notifications to the relay (server/push.ts); false while not connected. */
58
+ push(pushes: PushMessage[]): boolean;
56
59
  stop(): void;
57
60
  }
58
61
  export interface RelayOptions {
@@ -62,6 +65,8 @@ export interface RelayOptions {
62
65
  version: string;
63
66
  /** Opens the relay socket (default: Node's global WebSocket) — tests pass their own. */
64
67
  connect?: (url: string) => WebSocket;
68
+ /** Give up on a connection the relay has not accepted (`ok`) within this long, and try again (default 15 s). */
69
+ handshakeTimeoutMs?: number;
65
70
  log?: (line: string) => void;
66
71
  }
67
72
  /** Hold the daemon's socket to the relay, reconnecting with backoff, and answer every client behind it. */
@@ -70,6 +75,8 @@ export declare function startRelay(opts: RelayOptions): Promise<RelayLink>;
70
75
  export declare const keyFingerprint: (key: string) => string;
71
76
  /** Start, restart or stop this daemon's relay link to match ~/.kraftwerk/relay.json. */
72
77
  export declare function applyRelay(port: number, version: string, log?: (line: string) => void): Promise<RelayStatus>;
78
+ /** Hand push notifications to this daemon's relay link; false without one. */
79
+ export declare const relayPush: (pushes: PushMessage[]) => boolean;
73
80
  /** The link's state; "off" when this process holds none (not the daemon, or the relay is off). */
74
81
  export declare const relayStatus: () => RelayStatus;
75
82
  export declare function stopRelay(): void;
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, } from "../client/relay.js";
7
7
  import { CLOUD_DEFAULT_URL } from "../config.js";
8
+ import { dropPushToken } from "../core/devices.js";
8
9
  const settingsFile = () => path.join(os.homedir(), ".kraftwerk", "relay.json");
9
10
  export async function readRelaySettings() {
10
11
  try {
@@ -189,6 +190,22 @@ export async function startRelay(opts) {
189
190
  return schedule();
190
191
  }
191
192
  ws = socket;
193
+ // A relay that restarts can leave a new socket hanging half-open, neither opening nor
194
+ // closing: without this, the link would wait for it forever.
195
+ const handshake = setTimeout(() => {
196
+ if (ws !== socket || status.state === "connected")
197
+ return;
198
+ ws = null;
199
+ try {
200
+ socket.close();
201
+ }
202
+ catch { }
203
+ status.state = "error";
204
+ status.error = "the relay did not answer";
205
+ log("relay: no answer from the relay — trying again");
206
+ schedule();
207
+ }, opts.handshakeTimeoutMs ?? 15_000);
208
+ handshake.unref?.();
192
209
  socket.addEventListener("open", () => {
193
210
  lastHeard = Date.now();
194
211
  socket.send(JSON.stringify({ t: "auth", id, key: opts.settings.publicKey, secret: opts.settings.secret, version: opts.version }));
@@ -203,6 +220,7 @@ export async function startRelay(opts) {
203
220
  return;
204
221
  }
205
222
  if (msg.t === "ok") {
223
+ clearTimeout(handshake);
206
224
  attempt = 0;
207
225
  status.state = "connected";
208
226
  status.error = undefined;
@@ -224,8 +242,13 @@ export async function startRelay(opts) {
224
242
  }
225
243
  else if (msg.t === "close" && msg.c)
226
244
  dropConn(msg.c);
245
+ else if (msg.t === "push_invalid" && typeof msg.token === "string") {
246
+ // Apple no longer knows the token (the app was deleted, or pushes turned off): stop sending to it.
247
+ void dropPushToken(msg.token).catch(() => { });
248
+ }
227
249
  });
228
250
  socket.addEventListener("close", (ev) => {
251
+ clearTimeout(handshake);
229
252
  if (ws !== socket)
230
253
  return;
231
254
  ws = null;
@@ -233,6 +256,8 @@ export async function startRelay(opts) {
233
256
  dropConn(c);
234
257
  if (stopped)
235
258
  return;
259
+ if (status.state === "connected")
260
+ log("relay: connection lost — reconnecting");
236
261
  status.state = "error";
237
262
  status.error = ev.code === 4401 ? `the relay refused this machine: ${ev.reason || "wrong secret"}` : ev.reason || "the relay connection closed";
238
263
  if (ev.code === 4401)
@@ -263,6 +288,14 @@ export async function startRelay(opts) {
263
288
  connect();
264
289
  return {
265
290
  status: () => ({ ...status }),
291
+ push(pushes) {
292
+ if (status.state !== "connected" || !pushes.length)
293
+ return false;
294
+ // At most 100 per message: a burst (a routine failing in every workspace) stays well under the relay's message limit.
295
+ for (let i = 0; i < pushes.length; i += 100)
296
+ send({ t: "push", pushes: pushes.slice(i, i + 100) });
297
+ return true;
298
+ },
266
299
  stop() {
267
300
  stopped = true;
268
301
  if (retry)
@@ -293,6 +326,8 @@ export async function applyRelay(port, version, log) {
293
326
  link = await startRelay({ port, settings, version, log });
294
327
  return relayStatus();
295
328
  }
329
+ /** Hand push notifications to this daemon's relay link; false without one. */
330
+ export const relayPush = (pushes) => link?.push(pushes) ?? false;
296
331
  /** The link's state; "off" when this process holds none (not the daemon, or the relay is off). */
297
332
  export const relayStatus = () => link?.status() ?? { state: "off", clients: 0 };
298
333
  export function stopRelay() {
package/dist/server/ws.js CHANGED
@@ -9,6 +9,7 @@ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
9
9
  /** One message (all its fragments) may not exceed this. */
10
10
  const MAX_MESSAGE = 4_000_000;
11
11
  const PING_MS = 25_000;
12
+ const CLOSE_GRACE_MS = 2_000;
12
13
  /** Complete the upgrade, or answer 400 and return null when the request is not a WebSocket handshake. */
13
14
  export function acceptWebSocket(req, socket, head) {
14
15
  const key = req.headers["sec-websocket-key"];
@@ -55,6 +56,8 @@ export function acceptWebSocket(req, socket, head) {
55
56
  payload.writeUInt16BE(code, 0);
56
57
  payload.write(reason, 2);
57
58
  socket.end(frame(0x8, payload));
59
+ // The peer should close its side now; one that does not (or never saw the frame) must not keep the connection — or us — waiting.
60
+ setTimeout(() => socket.destroy(), CLOSE_GRACE_MS).unref?.();
58
61
  finish();
59
62
  };
60
63
  const parse = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.71.0",
3
+ "version": "0.72.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",