@netnodeag/kraftwerk 0.64.0 → 0.66.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 (97) hide show
  1. package/README.md +126 -138
  2. package/dist/acp.js +3 -1
  3. package/dist/cli/daemon.d.ts +14 -0
  4. package/dist/cli/daemon.js +60 -0
  5. package/dist/cli/devices.d.ts +14 -0
  6. package/dist/cli/devices.js +53 -0
  7. package/dist/cli/doctor-legacy.js +9 -1
  8. package/dist/cli/doctor.js +17 -30
  9. package/dist/cli/init.js +1 -6
  10. package/dist/cli/kraftwerk.js +8 -3
  11. package/dist/cli/ui.d.ts +9 -0
  12. package/dist/cli/ui.js +47 -23
  13. package/dist/cli/workspaces.js +5 -3
  14. package/dist/client/index.d.ts +17 -0
  15. package/dist/client/index.js +10 -2
  16. package/dist/client/live.d.ts +1 -2
  17. package/dist/client/live.js +4 -2
  18. package/dist/client/routes.d.ts +8 -0
  19. package/dist/client/routes.js +8 -0
  20. package/dist/config.d.ts +18 -55
  21. package/dist/config.js +39 -92
  22. package/dist/core/chat/context.js +1 -1
  23. package/dist/core/chat/pi.js +2 -1
  24. package/dist/core/chat/sessions.d.ts +1 -1
  25. package/dist/core/chat/sessions.js +1 -1
  26. package/dist/core/chat/state.d.ts +2 -0
  27. package/dist/core/chat/state.js +5 -0
  28. package/dist/core/daemon.d.ts +27 -0
  29. package/dist/core/daemon.js +61 -0
  30. package/dist/core/devices.d.ts +35 -0
  31. package/dist/core/devices.js +156 -0
  32. package/dist/core/env.d.ts +16 -0
  33. package/dist/core/env.js +45 -0
  34. package/dist/core/git.d.ts +2 -0
  35. package/dist/core/git.js +20 -4
  36. package/dist/core/instances.d.ts +32 -12
  37. package/dist/core/instances.js +136 -64
  38. package/dist/core/routines.d.ts +6 -0
  39. package/dist/core/routines.js +11 -0
  40. package/dist/core/runner.js +7 -3
  41. package/dist/core/vibeables.d.ts +2 -0
  42. package/dist/core/vibeables.js +17 -5
  43. package/dist/core/workspace.d.ts +13 -0
  44. package/dist/core/workspace.js +25 -0
  45. package/dist/dotenv.d.ts +2 -2
  46. package/dist/dotenv.js +2 -2
  47. package/dist/server/api/devices.d.ts +20 -0
  48. package/dist/server/api/devices.js +55 -0
  49. package/dist/server/api/hub.d.ts +25 -0
  50. package/dist/server/api/hub.js +32 -0
  51. package/dist/server/api/index.d.ts +42 -2
  52. package/dist/server/api/index.js +5 -1
  53. package/dist/server/api/router.d.ts +21 -6
  54. package/dist/server/api/router.js +37 -9
  55. package/dist/server/api/socket.d.ts +15 -1
  56. package/dist/server/api/socket.js +59 -8
  57. package/dist/server/api/workspace.d.ts +2 -0
  58. package/dist/server/api/workspace.js +4 -2
  59. package/dist/server/bind.d.ts +16 -0
  60. package/dist/server/bind.js +25 -0
  61. package/dist/server/cloud.d.ts +5 -1
  62. package/dist/server/cloud.js +89 -56
  63. package/dist/server/hub-context.d.ts +7 -0
  64. package/dist/server/hub-context.js +18 -0
  65. package/dist/server/live.d.ts +2 -0
  66. package/dist/server/live.js +9 -0
  67. package/dist/server/preview.js +2 -1
  68. package/dist/server/server.d.ts +58 -1
  69. package/dist/server/server.js +267 -103
  70. package/dist/server/target.d.ts +20 -0
  71. package/dist/server/target.js +18 -0
  72. package/dist/server/trust.d.ts +41 -0
  73. package/dist/server/trust.js +42 -0
  74. package/package.json +1 -1
  75. package/schema/kraftwerk.schema.json +7 -46
  76. package/web/dist/assets/{dist-Cgcwvf4j.js → dist-1bNkaWWA.js} +1 -1
  77. package/web/dist/assets/dist-BU1SOB5S.js +1 -0
  78. package/web/dist/assets/{dist-CboP0-T1.js → dist-Bk3f-J5U.js} +1 -1
  79. package/web/dist/assets/{dist-2kswkIH2.js → dist-Bx99Puvv.js} +1 -1
  80. package/web/dist/assets/{dist-D2PvEzo7.js → dist-C0M2aITo.js} +1 -1
  81. package/web/dist/assets/{dist-D_BcTzz-.js → dist-C0z19Zge.js} +1 -1
  82. package/web/dist/assets/{dist-36MjITWa.js → dist-COEgBKG6.js} +1 -1
  83. package/web/dist/assets/{dist-Jdx6DRrm.js → dist-CYRrdlae.js} +1 -1
  84. package/web/dist/assets/{dist-DDkd_zrP.js → dist-CbEFnSCD.js} +1 -1
  85. package/web/dist/assets/{dist-BsuA2pgO.js → dist-DCYVKU-H.js} +1 -1
  86. package/web/dist/assets/{dist-B8gPjMPP.js → dist-XyTHWtoN.js} +1 -1
  87. package/web/dist/assets/{editor-ByRUOfrX.js → editor-DPcGvSPy.js} +3 -3
  88. package/web/dist/assets/index-BAYs-2WC.css +2 -0
  89. package/web/dist/assets/index-DKLJdwYE.js +229 -0
  90. package/web/dist/index.html +2 -2
  91. package/dist/cli/tunnel.d.ts +0 -10
  92. package/dist/cli/tunnel.js +0 -280
  93. package/dist/server/access.d.ts +0 -32
  94. package/dist/server/access.js +0 -121
  95. package/web/dist/assets/dist-CVqmJrYd.js +0 -1
  96. package/web/dist/assets/index-BR7HvDzD.js +0 -229
  97. package/web/dist/assets/index-Bra74Nxe.css +0 -2
package/dist/config.js CHANGED
@@ -16,6 +16,7 @@ import { parse } from "yaml";
16
16
  * name: my-project # display name (inspector header, "environment")
17
17
  * icon: "⚡" # emoji shown as inspector favicon
18
18
  * color: "#c2410c" # accent for this workspace in the switcher (default: derived from the root)
19
+ * slug: team-blau # this workspace's address (team-blau.localhost); default: the folder name
19
20
  * port: 1981 # port `kraftwerk ui` listens on
20
21
  * workflows: src/workflows # workflows root, relative to the file
21
22
  * output: output # run-artifact directory, relative to the file
@@ -36,12 +37,6 @@ import { parse } from "yaml";
36
37
  * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
37
38
  * projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
38
39
  * root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
39
- * public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
40
- * tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
41
- * name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
42
- * access: # verify the Cloudflare Access login on every request that arrives via `public`
43
- * team: acme # Zero Trust team name (https://<team>.cloudflareaccess.com)
44
- * aud: 4714c135… # Application Audience tag of the Access application
45
40
  * cloud: # kraftwerk cloud manager — ON BY DEFAULT, `enabled: false` opts out
46
41
  * url: https://srv.kraftwerk-cloud.netnode.cloud # where the cloud answers (default). KRAFTWERK_CLOUD_URL env wins; "off" disables
47
42
  * interval: 60 # seconds between heartbeats. Default: 60
@@ -114,34 +109,6 @@ export function cloudFor(project) {
114
109
  token: process.env.KRAFTWERK_CLOUD_TOKEN?.trim() || undefined,
115
110
  };
116
111
  }
117
- /** The public hostname (lowercase, no port) when `public` is set, undefined otherwise. */
118
- export function publicHostFor(project) {
119
- return project.config.public ? parsePublic(project.config.public)?.hostname : undefined;
120
- }
121
- /** The public origin ("https://kw.example.com") when `public` is set, undefined otherwise. */
122
- export function publicUrlFor(project) {
123
- return project.config.public ? parsePublic(project.config.public)?.origin : undefined;
124
- }
125
- /** The tunnel block when the feature is on, undefined otherwise. */
126
- export function tunnelFor(project) {
127
- const t = project.config.tunnel;
128
- if (!t || t.enabled === false)
129
- return undefined;
130
- return t;
131
- }
132
- /** "kw.example.com" or "https://kw.example.com[:port]" → its URL; undefined when it is neither. */
133
- export function parsePublic(value) {
134
- const text = value.trim();
135
- try {
136
- const url = new URL(/^https?:\/\//i.test(text) ? text : `https://${text}`);
137
- if (!url.hostname || url.pathname !== "/" || url.search || url.hash || url.username)
138
- return undefined;
139
- return url;
140
- }
141
- catch {
142
- return undefined;
143
- }
144
- }
145
112
  /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
146
113
  export function ignoreEntryFor(projectRoot, dir) {
147
114
  const rel = path.relative(projectRoot, path.resolve(projectRoot, dir)).split(path.sep).join("/");
@@ -201,12 +168,13 @@ export async function resolveWorkspace(cwd) {
201
168
  const configPath = await findConfigFile(dir);
202
169
  if (configPath) {
203
170
  // Above a bare candidate an unreadable kraftwerk.yml is someone else's problem.
204
- const config = bare ? await loadConfig(configPath).catch(() => null) : await loadConfig(configPath);
205
- if (!config)
171
+ const loaded = bare ? await loadConfig(configPath).catch(() => null) : await loadConfig(configPath);
172
+ if (!loaded)
206
173
  return bare;
174
+ const { config, legacyKeys } = loaded;
207
175
  const workflowsRoot = await findWorkflowsDir(dir, config.workflows);
208
176
  if (!bare || (workflowsRoot && path.resolve(workflowsRoot) === bare.workflowsRoot)) {
209
- return { root: dir, config, configPath, workflowsRoot, outputDir: path.resolve(dir, config.output ?? "output") };
177
+ return { root: dir, config, configPath, workflowsRoot, outputDir: path.resolve(dir, config.output ?? "output"), ...(legacyKeys.length ? { legacyKeys } : {}) };
210
178
  }
211
179
  return bare;
212
180
  }
@@ -227,7 +195,32 @@ export async function resolveWorkspace(cwd) {
227
195
  }
228
196
  /** @deprecated Renamed: a kraftwerk.yml root is a workspace (projects are the folders inside). Use `resolveWorkspace`. */
229
197
  export const resolveProject = resolveWorkspace;
230
- const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "public", "tunnel", "cloud"];
198
+ /** A workspace slug: a DNS label, so `<slug>.localhost` is a valid host name. */
199
+ const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
200
+ /** A folder name as a slug: "Team Blau (2024)" → "team-blau-2024". */
201
+ export function slugFromFolder(folder) {
202
+ const s = folder
203
+ .normalize("NFKD")
204
+ .replace(/[\u0300-\u036f]/g, "")
205
+ .toLowerCase()
206
+ .replace(/[^a-z0-9]+/g, "-")
207
+ .replace(/^-+|-+$/g, "")
208
+ .slice(0, 63)
209
+ .replace(/-+$/, "");
210
+ return s || "workspace";
211
+ }
212
+ /** The workspace's slug: kraftwerk.yml `slug`, else its folder name made into one. */
213
+ export function workspaceSlug(ws) {
214
+ return ws.config.slug ?? slugFromFolder(path.basename(ws.root));
215
+ }
216
+ const KNOWN_KEYS = ["name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "cloud"];
217
+ /**
218
+ * Keys an older kraftwerk understood and this one ignores, so the files
219
+ * that still carry them keep loading: `public:` and `tunnel:` (the
220
+ * Cloudflare Tunnel, removed in 0.65). Dropped before validation, so they
221
+ * never reach the rest of the code; doctor names them (`legacyKeys`).
222
+ */
223
+ const LEGACY_KEYS = ["public", "tunnel"];
231
224
  async function loadConfig(configPath) {
232
225
  let raw;
233
226
  try {
@@ -237,16 +230,22 @@ async function loadConfig(configPath) {
237
230
  throw new Error(`${path.basename(configPath)}: unreadable YAML: ${err.message}`);
238
231
  }
239
232
  if (raw === null || raw === undefined)
240
- return {};
233
+ return { config: {}, legacyKeys: [] };
241
234
  if (typeof raw !== "object" || Array.isArray(raw)) {
242
235
  throw new Error(`${path.basename(configPath)}: expected a mapping (${KNOWN_KEYS.join(", ")})`);
243
236
  }
244
237
  const config = raw;
238
+ const legacyKeys = LEGACY_KEYS.filter((key) => key in config);
239
+ for (const key of legacyKeys)
240
+ delete config[key];
245
241
  const known = KNOWN_KEYS;
246
242
  for (const key of Object.keys(config)) {
247
243
  if (!known.includes(key)) {
248
244
  throw new Error(`${path.basename(configPath)}: unknown key "${key}" (allowed: ${known.join(", ")})`);
249
245
  }
246
+ if (key === "slug" && (typeof config[key] !== "string" || !SLUG_RE.test(config[key]))) {
247
+ throw new Error(`${path.basename(configPath)}: slug must be lowercase letters, digits and dashes, starting with a letter or digit (at most 63)`);
248
+ }
250
249
  if (key === "port") {
251
250
  if (typeof config[key] !== "number" || !Number.isInteger(config[key])) {
252
251
  throw new Error(`${path.basename(configPath)}: port must be an integer`);
@@ -283,16 +282,6 @@ async function loadConfig(configPath) {
283
282
  config[key] = {};
284
283
  validateRootBlock(configPath, "projects", config[key]);
285
284
  }
286
- else if (key === "public") {
287
- if (typeof config[key] !== "string" || !parsePublic(config[key])) {
288
- throw new Error(`${path.basename(configPath)}: public must be a hostname or https URL like "https://kw.example.com"`);
289
- }
290
- }
291
- else if (key === "tunnel") {
292
- if (config[key] === null)
293
- config[key] = {};
294
- validateTunnel(configPath, config[key]);
295
- }
296
285
  else if (key === "cloud") {
297
286
  if (config[key] === null)
298
287
  config[key] = {};
@@ -302,49 +291,7 @@ async function loadConfig(configPath) {
302
291
  throw new Error(`${path.basename(configPath)}: ${key} must be a string`);
303
292
  }
304
293
  }
305
- const tunnel = config.tunnel;
306
- if (tunnel && tunnel.enabled !== false && !config.public) {
307
- throw new Error(`${path.basename(configPath)}: tunnel needs public: the hostname the tunnel routes to`);
308
- }
309
- return config;
310
- }
311
- /** tunnel: { enabled?, name?, access?: { team, aud } } */
312
- function validateTunnel(configPath, value) {
313
- const file = path.basename(configPath);
314
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
315
- throw new Error(`${file}: tunnel must be a mapping (enabled, name, access)`);
316
- }
317
- const t = value;
318
- for (const key of Object.keys(t)) {
319
- if (!["enabled", "name", "access"].includes(key)) {
320
- throw new Error(`${file}: tunnel.${key} is unknown (allowed: enabled, name, access)`);
321
- }
322
- }
323
- if (t.enabled !== undefined && typeof t.enabled !== "boolean") {
324
- throw new Error(`${file}: tunnel.enabled must be true or false`);
325
- }
326
- // The name is handed to cloudflared as a positional argument.
327
- if (t.name !== undefined && (typeof t.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(t.name))) {
328
- throw new Error(`${file}: tunnel.name must be a plain tunnel name (letters, digits, ".", "_", "-")`);
329
- }
330
- if (t.access !== undefined) {
331
- if (typeof t.access !== "object" || t.access === null || Array.isArray(t.access)) {
332
- throw new Error(`${file}: tunnel.access must be a mapping (team, aud)`);
333
- }
334
- const a = t.access;
335
- for (const key of Object.keys(a)) {
336
- if (!["team", "aud"].includes(key)) {
337
- throw new Error(`${file}: tunnel.access.${key} is unknown (allowed: team, aud)`);
338
- }
339
- }
340
- // The team becomes a hostname (<team>.cloudflareaccess.com).
341
- if (typeof a.team !== "string" || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(a.team)) {
342
- throw new Error(`${file}: tunnel.access.team must be the Zero Trust team name (the subdomain of cloudflareaccess.com)`);
343
- }
344
- if (typeof a.aud !== "string" || !/^[a-f0-9]{64}$/i.test(a.aud)) {
345
- throw new Error(`${file}: tunnel.access.aud must be the 64-character Application Audience tag`);
346
- }
347
- }
294
+ return { config: config, legacyKeys };
348
295
  }
349
296
  /** cloud: { enabled?, url?, interval? } */
350
297
  function validateCloud(configPath, value) {
@@ -375,7 +375,7 @@ async function baseScopeContext(scope, agent) {
375
375
  * Every chat renders in the inspector UI, so agents should know two things:
376
376
  * replies are markdown, and run artifacts are addressable over the
377
377
  * inspector's own file endpoint (relative URLs keep working wherever the
378
- * inspector is reachable — localhost, LAN, tunnel).
378
+ * inspector is reachable — localhost, LAN, a reverse proxy).
379
379
  */
380
380
  const RENDERING_BLOCK = `## Chat rendering\n` +
381
381
  `Your replies render as markdown in the inspector chat: headings, lists, tables, code blocks, ` +
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { workspaceEnv } from "../env.js";
3
4
  const clip = (raw) => {
4
5
  const oneLine = String(raw ?? "").replace(/\s+/g, " ").trim();
5
6
  return oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine;
@@ -23,7 +24,7 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
23
24
  const proc = spawn("pi", ["-p", "--mode", "json", "--session-id", sessionId, ...tuningArgs], {
24
25
  cwd,
25
26
  stdio: ["pipe", "pipe", "pipe"],
26
- env: process.env,
27
+ env: workspaceEnv(),
27
28
  });
28
29
  child = proc;
29
30
  proc.stdin.write(text);
@@ -1,7 +1,7 @@
1
1
  import type { Attachment, ChatAgentId, ChatMeta, ChatScope, ElicitationAnswer, StoredChatEvent } from "./types.js";
2
2
  import { type Channel } from "../channels.js";
3
3
  export { agentFilesContext, agentJournalContext, agentVibeablesContext, vibeableContext } from "./context.js";
4
- export { disposeAllBackends } from "./state.js";
4
+ export { disposeAllBackends, disposeWorkspaceBackends } from "./state.js";
5
5
  /**
6
6
  * The folder the agent is actually spawned in. A vibeable that was removed
7
7
  * leaves meta.cwd pointing nowhere; spawning there fails, so fall back to
@@ -12,7 +12,7 @@ import { expandSkillInvocation, scopeContext } from "./context.js";
12
12
  import { ensureBackend } from "./seats.js";
13
13
  import { MAIN, chatLabel, chatStates, dropBackend, dropSeat, emit, forgetFailedResume, isBusy, isUnattended, lastEventText, loadState, mainSeat, newSeat, ownerLabel, routineRef } from "./state.js";
14
14
  export { agentFilesContext, agentJournalContext, agentVibeablesContext, vibeableContext } from "./context.js";
15
- export { disposeAllBackends } from "./state.js";
15
+ export { disposeAllBackends, disposeWorkspaceBackends } from "./state.js";
16
16
  /**
17
17
  * Chats: what the API does with them — create, post, steer, answer, fork,
18
18
  * rename, delete, subscribe. The pieces live beside it: the live state and
@@ -77,5 +77,7 @@ export declare function forgetFailedResume(state: ChatState, seat: Seat): void;
77
77
  export declare function dropBackend(state: ChatState): void;
78
78
  /** Server shutdown: no agent subprocess may outlive the inspector. */
79
79
  export declare function disposeAllBackends(): void;
80
+ /** The current workspace's agent processes end (the workspace closes); transcripts stay on disk. */
81
+ export declare function disposeWorkspaceBackends(): void;
80
82
  export declare function loadState(id: string): Promise<ChatState | null>;
81
83
  export declare function emit(state: ChatState, ev: ChatEvent, from?: Author): StoredChatEvent;
@@ -134,6 +134,11 @@ export function disposeAllBackends() {
134
134
  for (const [ws, states] of chatStates.entries())
135
135
  ws.run(() => states.forEach((state) => dropBackend(state)));
136
136
  }
137
+ /** The current workspace's agent processes end (the workspace closes); transcripts stay on disk. */
138
+ export function disposeWorkspaceBackends() {
139
+ for (const state of chatStates().values())
140
+ dropBackend(state);
141
+ }
137
142
  export async function loadState(id) {
138
143
  const existing = chatStates().get(id);
139
144
  if (existing)
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The machine's kraftwerk daemon: one process (`kraftwerk daemon`) serving
3
+ * every open workspace, each by its slug (`<slug>.localhost:<port>`,
4
+ * `/w/<slug>/`). It writes ~/.kraftwerk/daemon.json while it runs, so the CLI
5
+ * (`kraftwerk ui`) and other kraftwerks (the switcher's start/stop) hand
6
+ * workspaces to it instead of starting a server of their own. The file
7
+ * outlives the daemon on purpose: it lists what was open, and the next start
8
+ * (a reboot, a self-update) opens it again. Whether a daemon runs is told by
9
+ * asking it (findDaemon), never by the file alone.
10
+ */
11
+ export declare const DAEMON_PORT = 1980;
12
+ export declare function writeDaemonFile(port: number, open: string[]): Promise<void>;
13
+ /** What the last daemon served, to open again; [] without a file. */
14
+ export declare function lastOpenRoots(): Promise<string[]>;
15
+ /** The running daemon (its file, and a hub that answers on its port), or null. */
16
+ export declare function findDaemon(): Promise<{
17
+ pid: number;
18
+ port: number;
19
+ } | null>;
20
+ /** Ask the daemon to open or close a workspace (this machine's request: local trust). */
21
+ export declare function daemonCall<T>(port: number, verb: "open" | "close", root: string): Promise<{
22
+ ok: boolean;
23
+ status: number;
24
+ data: T & {
25
+ error?: string;
26
+ };
27
+ }>;
@@ -0,0 +1,61 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * The machine's kraftwerk daemon: one process (`kraftwerk daemon`) serving
6
+ * every open workspace, each by its slug (`<slug>.localhost:<port>`,
7
+ * `/w/<slug>/`). It writes ~/.kraftwerk/daemon.json while it runs, so the CLI
8
+ * (`kraftwerk ui`) and other kraftwerks (the switcher's start/stop) hand
9
+ * workspaces to it instead of starting a server of their own. The file
10
+ * outlives the daemon on purpose: it lists what was open, and the next start
11
+ * (a reboot, a self-update) opens it again. Whether a daemon runs is told by
12
+ * asking it (findDaemon), never by the file alone.
13
+ */
14
+ export const DAEMON_PORT = 1980;
15
+ const daemonFile = () => path.join(os.homedir(), ".kraftwerk", "daemon.json");
16
+ const startedAt = new Date().toISOString();
17
+ export async function writeDaemonFile(port, open) {
18
+ await fs.mkdir(path.dirname(daemonFile()), { recursive: true });
19
+ const tmp = `${daemonFile()}.${process.pid}.tmp`;
20
+ await fs.writeFile(tmp, JSON.stringify({ pid: process.pid, port, startedAt, open }));
21
+ await fs.rename(tmp, daemonFile());
22
+ }
23
+ /** What the last daemon served, to open again; [] without a file. */
24
+ export async function lastOpenRoots() {
25
+ try {
26
+ const rec = JSON.parse(await fs.readFile(daemonFile(), "utf8"));
27
+ return Array.isArray(rec.open) ? rec.open.filter((r) => typeof r === "string") : [];
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ /** The running daemon (its file, and a hub that answers on its port), or null. */
34
+ export async function findDaemon() {
35
+ let rec;
36
+ try {
37
+ rec = JSON.parse(await fs.readFile(daemonFile(), "utf8"));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ try {
43
+ // Every kraftwerk server answers /api/hub; only the daemon says it is one (a standalone `kraftwerk ui` may sit on that port now).
44
+ const r = await fetch(`http://127.0.0.1:${rec.port}/api/hub`, { signal: AbortSignal.timeout(800) });
45
+ const body = r.ok ? (await r.json()) : null;
46
+ return body?.daemon === true ? { pid: rec.pid, port: rec.port } : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** Ask the daemon to open or close a workspace (this machine's request: local trust). */
53
+ export async function daemonCall(port, verb, root) {
54
+ const r = await fetch(`http://127.0.0.1:${port}/api/hub/${verb}`, {
55
+ method: "POST",
56
+ headers: { "content-type": "application/json" },
57
+ body: JSON.stringify({ root }),
58
+ signal: AbortSignal.timeout(30_000),
59
+ });
60
+ return { ok: r.ok, status: r.status, data: (await r.json().catch(() => ({}))) };
61
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Paired devices: other machines — a phone's browser, a native app, a
3
+ * second laptop — that may use this machine's kraftwerk without being on
4
+ * it. One pairing covers every workspace here (the store is per machine,
5
+ * ~/.kraftwerk/devices.json), like the daemon will.
6
+ *
7
+ * Pairing: someone on this machine creates a short-lived one-time code
8
+ * (`kraftwerk devices pair`, or settings in the web UI); the device trades
9
+ * it for its token (POST /api/pair). Tokens are 32 random bytes; the store
10
+ * keeps only their SHA-256, so the file leaks nothing that grants access.
11
+ * Revoking a device deletes its hash: the token stops working at once.
12
+ */
13
+ export interface Device {
14
+ id: string;
15
+ name: string;
16
+ createdAt: string;
17
+ lastSeenAt?: string;
18
+ }
19
+ /** How long a pairing code works. */
20
+ export declare const PAIR_CODE_TTL_MS: number;
21
+ /** A new one-time pairing code, valid for PAIR_CODE_TTL_MS. Shown once; the store keeps its hash. */
22
+ export declare function createPairCode(): Promise<{
23
+ code: string;
24
+ expiresAt: string;
25
+ }>;
26
+ /** Trade a pairing code for a device token. The code works once; a wrong or expired one is refused without saying which. */
27
+ export declare function redeemPairCode(code: string, name: string): Promise<{
28
+ token: string;
29
+ device: Device;
30
+ }>;
31
+ /** The device a token belongs to, or null. Notes when it was last seen (at most once a minute). */
32
+ export declare function deviceForToken(token: string): Promise<Device | null>;
33
+ export declare function listDevices(): Promise<Device[]>;
34
+ /** Unpair a device: its token stops working at once. */
35
+ export declare function revokeDevice(id: string): Promise<boolean>;
@@ -0,0 +1,156 @@
1
+ import { createHash, randomBytes, randomInt } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /** How long a pairing code works. */
6
+ export const PAIR_CODE_TTL_MS = 10 * 60_000;
7
+ /** No 0/O, 1/I/L: a code is read off one screen and typed into another. */
8
+ const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
9
+ const CODE_LENGTH = 8;
10
+ const TOKEN_PREFIX = "kwd_";
11
+ /** lastSeenAt is written at most this often per device. */
12
+ const SEEN_EVERY_MS = 60_000;
13
+ const storeFile = () => path.join(os.homedir(), ".kraftwerk", "devices.json");
14
+ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
15
+ /** Codes compare case- and separator-insensitively: "abcd-efgh" is ABCDEFGH. */
16
+ const normalizeCode = (code) => code.toUpperCase().replace(/[^A-Z0-9]/g, "");
17
+ /** The store as last read, with the file's mtime: every request checks a token, so the file is re-read only when it changed. */
18
+ let cached = null;
19
+ async function read() {
20
+ const file = storeFile();
21
+ const st = await fs.stat(file).catch(() => null);
22
+ if (!st)
23
+ return { devices: [], codes: [] };
24
+ if (cached && cached.mtimeMs === st.mtimeMs)
25
+ return cached.store;
26
+ try {
27
+ const raw = JSON.parse(await fs.readFile(file, "utf8"));
28
+ const store = { devices: Array.isArray(raw.devices) ? raw.devices : [], codes: Array.isArray(raw.codes) ? raw.codes : [] };
29
+ cached = { mtimeMs: st.mtimeMs, store };
30
+ return store;
31
+ }
32
+ catch {
33
+ return { devices: [], codes: [] };
34
+ }
35
+ }
36
+ async function write(store) {
37
+ const file = storeFile();
38
+ const now = Date.now();
39
+ store.codes = store.codes.filter((c) => Date.parse(c.expiresAt) > now);
40
+ // Write-then-rename, owner-only: hashes grant nothing, but names and times are nobody else's business.
41
+ // A temp name per write: two writes in flight must not rename each other's file.
42
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
43
+ await fs.writeFile(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
44
+ await fs.rename(tmp, file);
45
+ cached = null;
46
+ }
47
+ const LOCK_STALE_MS = 10_000;
48
+ let chain = Promise.resolve();
49
+ /**
50
+ * Change the store: read it fresh, apply `change`, write it — one change at
51
+ * a time across every process on the machine (the servers of each workspace
52
+ * and the CLI share the file). In-process changes queue; across processes an
53
+ * O_EXCL lock file does, taken over when its holder died (older than 10 s).
54
+ */
55
+ function update(change) {
56
+ const run = chain.then(async () => {
57
+ const file = storeFile();
58
+ const lock = `${file}.lock`;
59
+ await fs.mkdir(path.dirname(file), { recursive: true });
60
+ for (let attempt = 0;; attempt++) {
61
+ try {
62
+ await (await fs.open(lock, "wx")).close();
63
+ break;
64
+ }
65
+ catch (err) {
66
+ if (err.code !== "EEXIST")
67
+ throw err;
68
+ const st = await fs.stat(lock).catch(() => null);
69
+ if (st && Date.now() - st.mtimeMs > LOCK_STALE_MS)
70
+ await fs.rm(lock, { force: true });
71
+ else if (attempt > 200)
72
+ throw new Error("the device store is locked — try again");
73
+ else
74
+ await new Promise((r) => setTimeout(r, 25));
75
+ }
76
+ }
77
+ try {
78
+ cached = null;
79
+ const store = await read();
80
+ const result = change(store);
81
+ await write(store);
82
+ return result;
83
+ }
84
+ finally {
85
+ await fs.rm(lock, { force: true });
86
+ }
87
+ });
88
+ chain = run.catch(() => { });
89
+ return run;
90
+ }
91
+ const view = ({ tokenHash: _h, ...d }) => d;
92
+ /** A new one-time pairing code, valid for PAIR_CODE_TTL_MS. Shown once; the store keeps its hash. */
93
+ export async function createPairCode() {
94
+ let code = "";
95
+ for (let i = 0; i < CODE_LENGTH; i++)
96
+ code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
97
+ const expiresAt = new Date(Date.now() + PAIR_CODE_TTL_MS).toISOString();
98
+ await update((store) => store.codes.push({ codeHash: sha256(code), expiresAt }));
99
+ return { code: `${code.slice(0, 4)}-${code.slice(4)}`, expiresAt };
100
+ }
101
+ /** Trade a pairing code for a device token. The code works once; a wrong or expired one is refused without saying which. */
102
+ export async function redeemPairCode(code, name) {
103
+ const hash = sha256(normalizeCode(code));
104
+ const token = TOKEN_PREFIX + randomBytes(32).toString("base64url");
105
+ const device = {
106
+ id: randomBytes(6).toString("hex"),
107
+ name: name.trim().slice(0, 60) || "device",
108
+ createdAt: new Date().toISOString(),
109
+ tokenHash: sha256(token),
110
+ };
111
+ const redeemed = await update((store) => {
112
+ const now = Date.now();
113
+ const i = store.codes.findIndex((c) => c.codeHash === hash && Date.parse(c.expiresAt) > now);
114
+ if (i < 0)
115
+ return false;
116
+ store.codes.splice(i, 1);
117
+ store.devices.push(device);
118
+ return true;
119
+ });
120
+ if (!redeemed)
121
+ throw new Error("invalid or expired pairing code");
122
+ return { token, device: view(device) };
123
+ }
124
+ /** The device a token belongs to, or null. Notes when it was last seen (at most once a minute). */
125
+ export async function deviceForToken(token) {
126
+ if (!token.startsWith(TOKEN_PREFIX))
127
+ return null;
128
+ const store = await read();
129
+ const hash = sha256(token);
130
+ const device = store.devices.find((d) => d.tokenHash === hash);
131
+ if (!device)
132
+ return null;
133
+ const now = Date.now();
134
+ if (!device.lastSeenAt || now - Date.parse(device.lastSeenAt) > SEEN_EVERY_MS) {
135
+ const seen = new Date(now).toISOString();
136
+ // Applied to the store as it is now, not to the copy read above: a pairing in between must survive.
137
+ await update((fresh) => {
138
+ const d = fresh.devices.find((x) => x.id === device.id);
139
+ if (d)
140
+ d.lastSeenAt = seen;
141
+ }).catch(() => { });
142
+ return view({ ...device, lastSeenAt: seen });
143
+ }
144
+ return view(device);
145
+ }
146
+ export async function listDevices() {
147
+ return (await read()).devices.map(view);
148
+ }
149
+ /** Unpair a device: its token stops working at once. */
150
+ export async function revokeDevice(id) {
151
+ return update((store) => {
152
+ const before = store.devices.length;
153
+ store.devices = store.devices.filter((d) => d.id !== id);
154
+ return store.devices.length < before;
155
+ });
156
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The environment a workspace's processes run with: this process's, plus
3
+ * the workspace's own `.env` (dotenv.ts for the precedence — a value set in
4
+ * the shell wins, unless an earlier kraftwerk process injected it from a
5
+ * `.env`). Everything a workspace spawns — agents, workflow runs, app dev
6
+ * servers, git — takes its environment from here, never from process.env
7
+ * directly: one process may serve several workspaces, and one team's
8
+ * `.env` must not reach another's agents.
9
+ *
10
+ * With one workspace per process the CLI has already applied that
11
+ * workspace's `.env` to process.env, so the result is the same as before.
12
+ */
13
+ /** Read `<root>/.env` (parsed, not applied); {} without one. Read when a workspace is served — a changed file applies on restart, as before. */
14
+ export declare function readWorkspaceDotenv(root: string): Record<string, string>;
15
+ /** The current workspace's environment (outside any workspace: process.env). */
16
+ export declare function workspaceEnv(): NodeJS.ProcessEnv;
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { DOTENV_FILE, DOTENV_MARKER, parseDotenv } from "../dotenv.js";
4
+ import { currentWorkspace } from "./workspace.js";
5
+ /**
6
+ * The environment a workspace's processes run with: this process's, plus
7
+ * the workspace's own `.env` (dotenv.ts for the precedence — a value set in
8
+ * the shell wins, unless an earlier kraftwerk process injected it from a
9
+ * `.env`). Everything a workspace spawns — agents, workflow runs, app dev
10
+ * servers, git — takes its environment from here, never from process.env
11
+ * directly: one process may serve several workspaces, and one team's
12
+ * `.env` must not reach another's agents.
13
+ *
14
+ * With one workspace per process the CLI has already applied that
15
+ * workspace's `.env` to process.env, so the result is the same as before.
16
+ */
17
+ /** Read `<root>/.env` (parsed, not applied); {} without one. Read when a workspace is served — a changed file applies on restart, as before. */
18
+ export function readWorkspaceDotenv(root) {
19
+ try {
20
+ return parseDotenv(readFileSync(path.join(root, DOTENV_FILE), "utf8"));
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ /** The current workspace's environment (outside any workspace: process.env). */
27
+ export function workspaceEnv() {
28
+ let dotenv;
29
+ try {
30
+ dotenv = currentWorkspace().env;
31
+ }
32
+ catch {
33
+ return { ...process.env };
34
+ }
35
+ const env = { ...process.env };
36
+ const injected = new Set((process.env[DOTENV_MARKER] ?? "").split(",").filter(Boolean));
37
+ // What another workspace's .env put into this process is not this workspace's.
38
+ for (const key of injected)
39
+ if (!(key in dotenv))
40
+ delete env[key];
41
+ for (const [key, value] of Object.entries(dotenv))
42
+ if (!process.env[key] || injected.has(key))
43
+ env[key] = value;
44
+ return env;
45
+ }
@@ -146,3 +146,5 @@ export declare function gitPush(): Promise<{
146
146
  * out) before the next one starts, or a short interval piles up processes.
147
147
  */
148
148
  export declare function startGitSync(): void;
149
+ /** Stop the current workspace's background sync (the workspace closes). A tick in flight finishes, then stops. */
150
+ export declare function stopGitSync(): void;