@netnodeag/kraftwerk 0.77.0 → 0.78.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 (50) hide show
  1. package/dist/cli/invite.d.ts +2 -0
  2. package/dist/cli/invite.js +48 -0
  3. package/dist/cli/kraftwerk.js +3 -0
  4. package/dist/client/relay.d.ts +40 -0
  5. package/dist/client/relay.js +35 -0
  6. package/dist/client/routes.d.ts +1 -0
  7. package/dist/client/routes.js +1 -0
  8. package/dist/core/chat/channel-turns.d.ts +2 -2
  9. package/dist/core/chat/channel-turns.js +2 -2
  10. package/dist/core/chat/sessions.d.ts +20 -3
  11. package/dist/core/chat/sessions.js +22 -7
  12. package/dist/core/chat/types.d.ts +21 -0
  13. package/dist/core/cloud.d.ts +38 -1
  14. package/dist/core/cloud.js +4 -0
  15. package/dist/core/routines.d.ts +22 -2
  16. package/dist/core/routines.js +26 -0
  17. package/dist/server/api/chats.js +45 -8
  18. package/dist/server/api/cloud.d.ts +16 -2
  19. package/dist/server/api/cloud.js +15 -2
  20. package/dist/server/api/devices.d.ts +7 -1
  21. package/dist/server/api/devices.js +3 -2
  22. package/dist/server/api/index.d.ts +20 -1
  23. package/dist/server/api/router.d.ts +3 -2
  24. package/dist/server/api/router.js +12 -0
  25. package/dist/server/cloud.d.ts +42 -3
  26. package/dist/server/cloud.js +77 -2
  27. package/dist/server/members.d.ts +87 -0
  28. package/dist/server/members.js +117 -0
  29. package/dist/server/relay.d.ts +8 -1
  30. package/dist/server/relay.js +45 -2
  31. package/dist/server/server.js +7 -0
  32. package/dist/server/trust.d.ts +16 -0
  33. package/dist/server/trust.js +12 -1
  34. package/package.json +1 -1
  35. package/web/dist/assets/{dist-BGHweZKi.js → dist-B-yVL5lk.js} +1 -1
  36. package/web/dist/assets/{dist-BVPxOqcx.js → dist-B9EMB1Ie.js} +1 -1
  37. package/web/dist/assets/{dist-DT2Tjvwx.js → dist-BTc878j7.js} +1 -1
  38. package/web/dist/assets/{dist-DoD3qX1P.js → dist-C90kUzps.js} +1 -1
  39. package/web/dist/assets/{dist-DCFREMXk.js → dist-CpLdAqFQ.js} +1 -1
  40. package/web/dist/assets/{dist-CXoAMU1b.js → dist-Crnb8VVO.js} +1 -1
  41. package/web/dist/assets/{dist-FFA4-S6d.js → dist-D0rjnN8S.js} +1 -1
  42. package/web/dist/assets/{dist-yGx0qYbw.js → dist-D4SDXoGw.js} +1 -1
  43. package/web/dist/assets/{dist-DdA5PoVJ.js → dist-DHRhdODt.js} +1 -1
  44. package/web/dist/assets/{dist-CCNyRSAA.js → dist-DQbifCX0.js} +1 -1
  45. package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
  46. package/web/dist/assets/{editor-CyWapLQr.js → editor-DHAdW1g_.js} +3 -3
  47. package/web/dist/assets/{index-CNXcNoKJ.js → index-D7JVAjll.js} +4 -4
  48. package/web/dist/index.html +1 -1
  49. package/web/dist-remote/sw.js +1 -1
  50. package/web/dist/assets/dist-zjUUP5a9.js +0 -1
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerInviteCommand(program: Command): void;
@@ -0,0 +1,48 @@
1
+ import chalk from "chalk";
2
+ import { resolveWorkspace, workspaceSlug } from "../config.js";
3
+ import { findDaemon } from "../core/daemon.js";
4
+ const fail = (message, code = 1) => {
5
+ console.error(chalk.red(`✖ ${message}`));
6
+ process.exit(code);
7
+ };
8
+ export function registerInviteCommand(program) {
9
+ program
10
+ .command("invite")
11
+ .description("Invite someone into this workspace as a remote member (no clone): prints the join code and link — the machine must be claimed (`kraftwerk login`)")
12
+ .option("--role <role>", "member (default) or owner")
13
+ .option("--workspace <slug>", "A workspace the daemon serves, instead of the one in this folder")
14
+ .option("--json", "Machine-readable output")
15
+ .action(async (opts) => {
16
+ if (opts.role !== undefined && opts.role !== "owner" && opts.role !== "member")
17
+ fail("--role is member or owner", 2);
18
+ let which;
19
+ if (opts.workspace)
20
+ which = { slug: opts.workspace };
21
+ else {
22
+ const ws = await resolveWorkspace(process.cwd()).catch((err) => fail(err.message));
23
+ if (!ws.configPath)
24
+ fail("not in a workspace folder (no kraftwerk.yml here or above) — run it there, or pass --workspace <slug>", 2);
25
+ if (!ws.config.id)
26
+ fail("this workspace has no id (ws_…) in kraftwerk.yml — run `kraftwerk doctor --fix`");
27
+ which = { root: ws.root, slug: workspaceSlug(ws) };
28
+ }
29
+ const daemon = await findDaemon();
30
+ if (!daemon)
31
+ fail("the daemon is not running — `kraftwerk daemon start` (it holds this machine's place in the cloud)");
32
+ const r = await fetch(`http://127.0.0.1:${daemon.port}/api/cloud/invite`, {
33
+ method: "POST",
34
+ headers: { "content-type": "application/json" },
35
+ body: JSON.stringify({ ...which, ...(opts.role ? { role: opts.role } : {}) }),
36
+ signal: AbortSignal.timeout(20_000),
37
+ }).catch((err) => fail(`the daemon did not answer: ${err.message}`));
38
+ const data = (await r.json().catch(() => ({})));
39
+ if (!r.ok)
40
+ fail(data.error ?? `${r.status} from the daemon`);
41
+ if (opts.json)
42
+ return void console.log(JSON.stringify(data, null, 2));
43
+ console.log(`${chalk.green("✔")} invite to ${chalk.bold(data.workspace.name)} ${chalk.dim(`(${data.workspace.slug}, ${data.workspace.id})`)} as ${data.role}`);
44
+ console.log(` code ${chalk.bold(data.code)}`);
45
+ console.log(` join link ${chalk.cyan(data.url)}`);
46
+ console.log(chalk.dim(` valid until ${new Date(data.expiresAt).toLocaleString()} — they join in the cloud and reach the workspace through this machine`));
47
+ });
48
+ }
@@ -23,6 +23,7 @@ import { registerRemoteCommand } from "./remote.js";
23
23
  import { registerJournalCommands } from "./journal.js";
24
24
  import { registerWhoamiCommand } from "./whoami.js";
25
25
  import { registerCloudCommands } from "./cloud.js";
26
+ import { registerInviteCommand } from "./invite.js";
26
27
  import { applyDotenv } from "../dotenv.js";
27
28
  import { resolveWorkspace } from "../config.js";
28
29
  import { registerRoutineCommands } from "./routines.js";
@@ -49,6 +50,7 @@ import { runUi } from "./ui.js";
49
50
  * kraftwerk daemon the machine's kraftwerk: every open workspace at <slug>.localhost
50
51
  * kraftwerk cloud [on|off|status] this machine in the kraftwerk cloud (the daemon registers it)
51
52
  * kraftwerk login / logout claim this machine into your cloud account (in the browser) / release it
53
+ * kraftwerk invite [--role owner] invite a remote member into this workspace (join code + link, via the daemon)
52
54
  * kraftwerk doctor [--fix] preflight: harness CLIs, docker, workflows, env, routine runners
53
55
  * kraftwerk validate [paths...] validate without executing
54
56
  *
@@ -306,6 +308,7 @@ registerRemoteCommand(program);
306
308
  registerJournalCommands(program);
307
309
  registerWhoamiCommand(program);
308
310
  registerCloudCommands(program);
311
+ registerInviteCommand(program);
309
312
  program
310
313
  .command("ui")
311
314
  .description("Start the inspector web UI for this project's runs and workflows")
@@ -28,6 +28,12 @@
28
28
  * presents a paired device's token like any network caller, and the daemon
29
29
  * applies the same trust rules (src/server/trust.ts). Anyone holding the
30
30
  * pairing link still needs a pairing code or a token.
31
+ *
32
+ * Or, as a remote member of one workspace, it sends `{t:"auth",
33
+ * memberToken, workspaceId}` as its first sealed message and waits for the
34
+ * daemon's `{t:"auth", ok, …}` (src/server/members.ts): the whole channel
35
+ * is then that member's, scoped to that workspace. A client that sends no
36
+ * auth is a device's channel exactly as before.
31
37
  */
32
38
  export declare const RELAY_PROTOCOL = 1;
33
39
  export declare function toBase64(bytes: Uint8Array): string;
@@ -96,7 +102,27 @@ export type TunnelToDaemon = {
96
102
  } | {
97
103
  t: "abort";
98
104
  id: number;
105
+ }
106
+ /** A member session: first, once, before any request (see src/server/members.ts). */
107
+ | {
108
+ t: "auth";
109
+ memberToken: string;
110
+ workspaceId: string;
99
111
  };
112
+ /** Who the cloud says the member is, and the workspace the session is scoped to. */
113
+ export interface MemberAuth {
114
+ member: {
115
+ email: string;
116
+ handle: string;
117
+ role: "owner" | "member";
118
+ };
119
+ /** `slug` addresses it: /w/<slug>/api/… */
120
+ workspace: {
121
+ id: string;
122
+ slug: string;
123
+ name: string;
124
+ };
125
+ }
100
126
  export type TunnelToClient = {
101
127
  t: "res";
102
128
  id: number;
@@ -113,6 +139,13 @@ export type TunnelToClient = {
113
139
  t: "fail";
114
140
  id: number;
115
141
  error: string;
142
+ } | ({
143
+ t: "auth";
144
+ ok: true;
145
+ } & MemberAuth) | {
146
+ t: "auth";
147
+ ok: false;
148
+ error: string;
116
149
  };
117
150
  /** The socket the client talks over — the browser's WebSocket and Node's global one both fit. */
118
151
  export interface RelaySocket {
@@ -134,6 +167,8 @@ export interface Tunnel {
134
167
  close(): void;
135
168
  /** Resolves when the connection ends, with why. */
136
169
  readonly closed: Promise<string>;
170
+ /** A member session's answer (ConnectOptions.member); absent on a device's channel. */
171
+ readonly member?: MemberAuth;
137
172
  }
138
173
  export interface ConnectOptions {
139
174
  /** The relay's client endpoint, e.g. wss://srv.kraftwerk-cloud.netnode.cloud/api/relay/client */
@@ -144,6 +179,11 @@ export interface ConnectOptions {
144
179
  connect?: (url: string) => RelaySocket;
145
180
  /** Abort the handshake after this long (default 15 s). */
146
181
  timeoutMs?: number;
182
+ /** Open the channel as a remote member of one workspace instead of a device: the daemon verifies it with the cloud. */
183
+ member?: {
184
+ token: string;
185
+ workspaceId: string;
186
+ };
147
187
  }
148
188
  /** Connect to a machine through the relay: the handshake, then a `fetch` that goes through it. */
149
189
  export declare function connectTunnel(opts: ConnectOptions): Promise<Tunnel>;
@@ -28,6 +28,12 @@
28
28
  * presents a paired device's token like any network caller, and the daemon
29
29
  * applies the same trust rules (src/server/trust.ts). Anyone holding the
30
30
  * pairing link still needs a pairing code or a token.
31
+ *
32
+ * Or, as a remote member of one workspace, it sends `{t:"auth",
33
+ * memberToken, workspaceId}` as its first sealed message and waits for the
34
+ * daemon's `{t:"auth", ok, …}` (src/server/members.ts): the whole channel
35
+ * is then that member's, scoped to that workspace. A client that sends no
36
+ * auth is a device's channel exactly as before.
31
37
  */
32
38
  const subtle = () => globalThis.crypto.subtle;
33
39
  const enc = new TextEncoder();
@@ -167,6 +173,7 @@ export async function connectTunnel(opts) {
167
173
  const closed = new Promise((r) => (resolveClosed = r));
168
174
  const pending = new Map();
169
175
  let nextId = 1;
176
+ let onAuth = null;
170
177
  const finish = (why) => {
171
178
  if (closedWhy)
172
179
  return;
@@ -230,6 +237,11 @@ export async function connectTunnel(opts) {
230
237
  });
231
238
  });
232
239
  const receive = (msg) => {
240
+ if (msg.t === "auth") {
241
+ onAuth?.(msg);
242
+ onAuth = null;
243
+ return;
244
+ }
233
245
  const p = pending.get(msg.id);
234
246
  if (!p)
235
247
  return;
@@ -277,6 +289,28 @@ export async function connectTunnel(opts) {
277
289
  ws.send(await channel.seal(JSON.stringify(msg)));
278
290
  };
279
291
  await ready;
292
+ let member;
293
+ if (opts.member) {
294
+ const m = opts.member;
295
+ const answer = await new Promise((resolve, reject) => {
296
+ const timer = setTimeout(() => reject(new Error("the machine did not answer the member's hello")), opts.timeoutMs ?? 15_000);
297
+ onAuth = (msg) => {
298
+ clearTimeout(timer);
299
+ resolve(msg);
300
+ };
301
+ void closed.then((why) => {
302
+ clearTimeout(timer);
303
+ reject(new Error(why));
304
+ });
305
+ send({ t: "auth", memberToken: m.token, workspaceId: m.workspaceId }).catch(reject);
306
+ });
307
+ if (!answer.ok) {
308
+ ws.close(1000, "bye");
309
+ finish(answer.error);
310
+ throw new Error(answer.error);
311
+ }
312
+ member = { member: answer.member, workspace: answer.workspace };
313
+ }
280
314
  const tunnelFetch = async (input, init) => {
281
315
  if (closedWhy)
282
316
  throw new Error(closedWhy);
@@ -314,5 +348,6 @@ export async function connectTunnel(opts) {
314
348
  finish("closed");
315
349
  },
316
350
  closed,
351
+ ...(member ? { member } : {}),
317
352
  };
318
353
  }
@@ -45,6 +45,7 @@ export declare const ROUTES: {
45
45
  readonly "chats.setVibeable": readonly ["POST", "/api/chats/:id/vibeable"];
46
46
  readonly "chats.steer": readonly ["POST", "/api/chats/:id/steer"];
47
47
  readonly "chats.stopTask": readonly ["POST", "/api/chats/:id/task-stop"];
48
+ readonly "cloud.invite": readonly ["POST", "/api/cloud/invite"];
48
49
  readonly "cloud.set": readonly ["POST", "/api/cloud"];
49
50
  readonly "cloud.status": readonly ["GET", "/api/cloud"];
50
51
  readonly "cloud.sync": readonly ["POST", "/api/cloud/sync"];
@@ -46,6 +46,7 @@ export const ROUTES = {
46
46
  "chats.setVibeable": ["POST", "/api/chats/:id/vibeable"],
47
47
  "chats.steer": ["POST", "/api/chats/:id/steer"],
48
48
  "chats.stopTask": ["POST", "/api/chats/:id/task-stop"],
49
+ "cloud.invite": ["POST", "/api/cloud/invite"],
49
50
  "cloud.set": ["POST", "/api/cloud"],
50
51
  "cloud.status": ["GET", "/api/cloud"],
51
52
  "cloud.sync": ["POST", "/api/cloud/sync"],
@@ -1,4 +1,4 @@
1
- import type { Attachment } from "./types.js";
1
+ import type { Attachment, ChatPerson } from "./types.js";
2
2
  import { type ChatState } from "./state.js";
3
3
  /**
4
4
  * Channels: how a post wakes the agents it mentions, and how each agent
@@ -10,6 +10,6 @@ import { type ChatState } from "./state.js";
10
10
  * parallel, never blocking the humans, who can keep posting. An agent that
11
11
  * is still busy is woken again when its turn ends, so nothing is lost.
12
12
  */
13
- export declare function postChannelMessage(state: ChatState, slug: string, text: string, from?: string, attachments?: Attachment[]): Promise<{
13
+ export declare function postChannelMessage(state: ChatState, slug: string, text: string, from?: string, attachments?: Attachment[], by?: ChatPerson): Promise<{
14
14
  error?: string;
15
15
  }>;
@@ -15,12 +15,12 @@ import { dropSeat, emit, forgetFailedResume, newSeat } from "./state.js";
15
15
  * parallel, never blocking the humans, who can keep posting. An agent that
16
16
  * is still busy is woken again when its turn ends, so nothing is lost.
17
17
  */
18
- export async function postChannelMessage(state, slug, text, from, attachments) {
18
+ export async function postChannelMessage(state, slug, text, from, attachments, by) {
19
19
  const channel = await getChannel(slug).catch(() => null);
20
20
  if (!channel)
21
21
  return { error: `channel "${slug}" has no definition under channels/` };
22
22
  const name = (from ?? "").replace(/\s+/g, " ").trim().slice(0, 40) || "you";
23
- emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}) }, { kind: "human", name });
23
+ emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}), ...(by ? { by } : {}) }, { kind: "human", name });
24
24
  void writeMeta(state.meta).catch(() => { });
25
25
  for (const target of mentionTargets(text, channel))
26
26
  void wakeSeat(state, channel, target, 0);
@@ -1,7 +1,19 @@
1
- import type { Attachment, ChatAgentId, ChatMeta, ChatScope, ElicitationAnswer, StoredChatEvent } from "./types.js";
1
+ import type { Attachment, ChatAgentId, ChatMeta, ChatPerson, 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
4
  export { disposeAllBackends, disposeWorkspaceBackends } from "./state.js";
5
+ /**
6
+ * Chats: what the API does with them — create, post, steer, answer, fork,
7
+ * rename, delete, subscribe. The pieces live beside it: the live state and
8
+ * its events (state.ts), the context an agent is given (context.ts), its
9
+ * process (seats.ts) and channel turns (channel-turns.ts).
10
+ */
11
+ /**
12
+ * This machine's person: its identity handle (identity.ts). What a chat or
13
+ * message is signed with when nobody else is named — local use, a paired
14
+ * device, a routine's run. A member session names its member instead.
15
+ */
16
+ export declare function localPerson(): Promise<ChatPerson>;
5
17
  /**
6
18
  * The folder the agent is actually spawned in. A vibeable that was removed
7
19
  * leaves meta.cwd pointing nowhere; spawning there fails, so fall back to
@@ -18,6 +30,10 @@ export declare function createChat(opts: {
18
30
  resume?: string;
19
31
  /** The project an agent's chat works in (meta.project); the caller checks that it exists. */
20
32
  project?: string;
33
+ /** Who starts it; default this machine's person. */
34
+ author?: ChatPerson;
35
+ /** A private routine's run: hidden from remote callers (meta.private). */
36
+ private?: boolean;
21
37
  }): Promise<ChatMeta>;
22
38
  /** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
23
39
  export declare function chatAwaitingApproval(id: string): boolean;
@@ -61,6 +77,7 @@ export declare function getChat(id: string): Promise<{
61
77
  export declare function postMessage(id: string, text: string, opts?: {
62
78
  from?: string;
63
79
  attachments?: Attachment[];
80
+ by?: ChatPerson;
64
81
  }): Promise<{
65
82
  error?: string;
66
83
  }>;
@@ -101,7 +118,7 @@ export declare function setChatVibeable(id: string, slug: string | null): Promis
101
118
  * once. The source's process is (re)spawned if needed; the fork's own
102
119
  * process starts with its first message and resumes the forked id.
103
120
  */
104
- export declare function forkChat(id: string): Promise<{
121
+ export declare function forkChat(id: string, by?: ChatPerson): Promise<{
105
122
  error?: string;
106
123
  status?: number;
107
124
  meta?: ChatMeta;
@@ -125,7 +142,7 @@ export declare function answerElicitation(id: string, requestId: string, answer:
125
142
  * for it to end. Shown in the thread like any message. When no turn is
126
143
  * running (or the agent cannot take steering) it goes the ordinary way.
127
144
  */
128
- export declare function steerChat(id: string, text: string, attachments?: Attachment[]): Promise<{
145
+ export declare function steerChat(id: string, text: string, attachments?: Attachment[], by?: ChatPerson): Promise<{
129
146
  error?: string;
130
147
  }>;
131
148
  export declare function stopChatTask(id: string, taskId: string): Promise<{
@@ -5,6 +5,7 @@ import { getAgent } from "../agents.js";
5
5
  import { resolveVibeable } from "../vibeables.js";
6
6
  import { deleteAgentSession, listAgentSessions } from "./acp.js";
7
7
  import { chatHref, pushNotification } from "../notifications.js";
8
+ import { readIdentity } from "../identity.js";
8
9
  import { moveToTrash } from "../trash.js";
9
10
  import { attachmentPath, copyChatFiles, listChatMetas, newChatId, readMetaAt, safeChatDir, writeMeta } from "./store.js";
10
11
  import { postChannelMessage } from "./channel-turns.js";
@@ -20,6 +21,14 @@ export { disposeAllBackends, disposeWorkspaceBackends } from "./state.js";
20
21
  * process (seats.ts) and channel turns (channel-turns.ts).
21
22
  */
22
23
  /* ---------- public API (used by server.ts) ---------- */
24
+ /**
25
+ * This machine's person: its identity handle (identity.ts). What a chat or
26
+ * message is signed with when nobody else is named — local use, a paired
27
+ * device, a routine's run. A member session names its member instead.
28
+ */
29
+ export async function localPerson() {
30
+ return { handle: (await readIdentity()).handle };
31
+ }
23
32
  /** Where a chat's agent runs when no vibeable is open: the run folder for run chats, else the project root. */
24
33
  const defaultCwd = (scope) => (scope.kind === "run" ? safeRunDir(scope.runId) : workspaceRoot());
25
34
  /**
@@ -37,6 +46,7 @@ export async function effectiveCwd(meta) {
37
46
  export async function createChat(opts) {
38
47
  const cwd = defaultCwd(opts.scope);
39
48
  const now = new Date().toISOString();
49
+ const author = opts.author ?? (await localPerson());
40
50
  const meta = {
41
51
  id: newChatId(),
42
52
  agent: opts.agent,
@@ -45,6 +55,8 @@ export async function createChat(opts) {
45
55
  scope: opts.scope,
46
56
  ...(opts.project && opts.scope.kind === "agent" ? { project: opts.project } : {}),
47
57
  ...(opts.resume && opts.agent !== "pi" ? { sessions: { [MAIN]: opts.resume } } : {}),
58
+ author,
59
+ ...(opts.private ? { private: true } : {}),
48
60
  createdAt: now,
49
61
  updatedAt: now,
50
62
  };
@@ -121,8 +133,9 @@ export async function postMessage(id, text, opts = {}) {
121
133
  if (!state)
122
134
  return { error: "not found" };
123
135
  const attachments = opts.attachments?.length ? opts.attachments : undefined;
136
+ const by = opts.by ?? (await localPerson());
124
137
  if (state.meta.scope.kind === "channel")
125
- return postChannelMessage(state, state.meta.scope.slug, text, opts.from, attachments);
138
+ return postChannelMessage(state, state.meta.scope.slug, text, opts.from, attachments, by);
126
139
  const seat = mainSeat(state);
127
140
  if (seat.busy)
128
141
  return { error: "agent is still working — wait for the turn to finish" };
@@ -133,7 +146,7 @@ export async function postMessage(id, text, opts = {}) {
133
146
  state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
134
147
  void writeMeta(state.meta).catch(() => { });
135
148
  }
136
- const turnStart = emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}) }).seq;
149
+ const turnStart = emit(state, { type: "user_message", text, ...(attachments ? { attachments } : {}), by }).seq;
137
150
  // The turn runs in the background; the HTTP request returns immediately
138
151
  // and the browser follows along on the SSE stream.
139
152
  void (async () => {
@@ -319,7 +332,7 @@ export async function setChatVibeable(id, slug) {
319
332
  * once. The source's process is (re)spawned if needed; the fork's own
320
333
  * process starts with its first message and resumes the forked id.
321
334
  */
322
- export async function forkChat(id) {
335
+ export async function forkChat(id, by) {
323
336
  const state = await loadState(id);
324
337
  if (!state)
325
338
  return { error: "not found", status: 404 };
@@ -351,6 +364,7 @@ export async function forkChat(id) {
351
364
  id: newChatId(),
352
365
  title: meta.title ? `${meta.title} (fork)` : "",
353
366
  sessions: { [MAIN]: forked },
367
+ author: by ?? (await localPerson()),
354
368
  createdAt: now,
355
369
  updatedAt: now,
356
370
  };
@@ -394,7 +408,7 @@ export async function answerElicitation(id, requestId, answer) {
394
408
  * for it to end. Shown in the thread like any message. When no turn is
395
409
  * running (or the agent cannot take steering) it goes the ordinary way.
396
410
  */
397
- export async function steerChat(id, text, attachments) {
411
+ export async function steerChat(id, text, attachments, by) {
398
412
  const state = await loadState(id);
399
413
  if (!state)
400
414
  return { error: "not found" };
@@ -402,17 +416,18 @@ export async function steerChat(id, text, attachments) {
402
416
  return { error: "channels take messages, not steering — @mention the agent" };
403
417
  const seat = mainSeat(state);
404
418
  const files = attachments?.length ? attachments : undefined;
419
+ const person = by ?? (await localPerson());
405
420
  if (!seat.busy || !seat.backend?.steer)
406
- return postMessage(id, text, { attachments: files });
421
+ return postMessage(id, text, { attachments: files, by: person });
407
422
  try {
408
423
  const outcome = await seat.backend.steer(text, promptFiles(id, files));
409
424
  if (outcome === "promptRequired")
410
- return postMessage(id, text, { attachments: files });
425
+ return postMessage(id, text, { attachments: files, by: person });
411
426
  }
412
427
  catch (err) {
413
428
  return { error: err.message };
414
429
  }
415
- emit(state, { type: "user_message", text, steered: true, ...(files ? { attachments: files } : {}) });
430
+ emit(state, { type: "user_message", text, steered: true, ...(files ? { attachments: files } : {}), by: person });
416
431
  void writeMeta(state.meta).catch(() => { });
417
432
  return {};
418
433
  }
@@ -31,6 +31,17 @@ export type ChatScope = {
31
31
  kind: "channel";
32
32
  slug: string;
33
33
  };
34
+ /**
35
+ * The person behind a chat or a message: this machine's identity handle
36
+ * (local use, a paired device) or a remote member of the workspace (the
37
+ * cloud vouched for their handle and email; see server/members.ts).
38
+ * Absent on chats and messages from before authors were recorded.
39
+ */
40
+ export interface ChatPerson {
41
+ handle: string;
42
+ /** A remote member's account email; absent for this machine's own person. */
43
+ email?: string;
44
+ }
34
45
  /** Who produced an event. Absent on single-agent chats (the one human, the one agent). */
35
46
  export type Author = {
36
47
  kind: "human";
@@ -60,6 +71,14 @@ export interface ChatMeta {
60
71
  * over with the transcript as a summary.
61
72
  */
62
73
  sessions?: Record<string, string>;
74
+ /** Who started the chat (a routine's run: this machine's person). */
75
+ author?: ChatPerson;
76
+ /**
77
+ * A run of a `private: true` routine: left out of what remote callers see
78
+ * (member sessions, paired devices over the relay); this machine and the
79
+ * LAN still see it.
80
+ */
81
+ private?: boolean;
63
82
  createdAt: string;
64
83
  updatedAt: string;
65
84
  }
@@ -166,11 +185,13 @@ export type ElicitationAnswer = {
166
185
  */
167
186
  export type ChatEvent =
168
187
  /** `steered`: handed into the running turn instead of starting the next one. */
188
+ /** `by`: the person who sent it (see ChatPerson). */
169
189
  {
170
190
  type: "user_message";
171
191
  text: string;
172
192
  steered?: boolean;
173
193
  attachments?: Attachment[];
194
+ by?: ChatPerson;
174
195
  } | {
175
196
  type: "text";
176
197
  text: string;
@@ -23,13 +23,25 @@
23
23
  *
24
24
  * /api/machines/register {id, key, secret, name, handle, hostname?, platform?, version, interval?, workspaces}
25
25
  * → {ok, interval, claimCode|null, account|null} (403: wrong secret)
26
- * /api/machines/heartbeat {id, secret, version?, workspaces?} → {ok, interval, account, claimCode}
26
+ * /api/machines/heartbeat {id, secret, version?, workspaces?, routines?} → {ok, interval, account, claimCode}
27
27
  * /api/machines/goodbye {id, secret}
28
28
  * /api/machines/unlink {id, secret} → {ok, claimCode}
29
29
  *
30
30
  * workspaces: [{id?: "ws_…", name, slug, root?}] — `root` only once the
31
31
  * machine is claimed (account set): an unclaimed machine tells the cloud no
32
32
  * paths.
33
+ *
34
+ * routines: [{workspaceId, routineId: "<agent>/<id>", name, lastRunAt|null, ok}]
35
+ * — the routines that ran here, private ones left out, at most 500; only
36
+ * while claimed, and only when it changed since the cloud last took it.
37
+ *
38
+ * Membership (remote members, no clone — they reach a workspace through the
39
+ * relay with a workspace-scoped member token, see server/members.ts):
40
+ *
41
+ * /api/members/verify {id, secret, token, workspaceId} → {email, handle, role: "owner"|"member"}
42
+ * (404: unknown or revoked token; 403: this machine does not host the workspace)
43
+ * /api/workspaces/:id/invites {id, secret, role?} → {code, url, role, expiresAt}
44
+ * (a claimed machine reporting the workspace; `kraftwerk invite`)
33
45
  */
34
46
  export interface CloudFile {
35
47
  enabled?: boolean;
@@ -67,6 +79,28 @@ export interface HeartbeatResponse {
67
79
  account: string | null;
68
80
  claimCode: string | null;
69
81
  }
82
+ /** What the cloud vouches for about a member token in one workspace. */
83
+ export interface MemberVerdict {
84
+ email: string;
85
+ handle: string;
86
+ role: "owner" | "member";
87
+ }
88
+ export interface InviteResponse {
89
+ code: string;
90
+ url: string;
91
+ role: "owner" | "member";
92
+ expiresAt: string;
93
+ }
94
+ /** One routine that ran here, in the heartbeat (claimed machines only). */
95
+ export interface RoutineReportEntry {
96
+ workspaceId: string;
97
+ routineId: string;
98
+ name: string;
99
+ lastRunAt: string | null;
100
+ ok: boolean;
101
+ }
102
+ /** The most routines one heartbeat carries. */
103
+ export declare const MAX_REPORTED_ROUTINES = 500;
70
104
  export interface UnlinkResponse {
71
105
  ok: boolean;
72
106
  claimCode: string | null;
@@ -110,6 +144,9 @@ export declare const machineRegister: (base: string, creds: Credentials, hello:
110
144
  export declare const machineHeartbeat: (base: string, creds: Credentials, extra: {
111
145
  version?: string;
112
146
  workspaces?: MachineWorkspace[];
147
+ routines?: RoutineReportEntry[];
113
148
  }) => Promise<HeartbeatResponse>;
114
149
  export declare const machineGoodbye: (base: string, creds: Credentials, version?: string) => Promise<unknown>;
115
150
  export declare const machineUnlink: (base: string, creds: Credentials, version?: string) => Promise<UnlinkResponse>;
151
+ export declare const memberVerify: (base: string, creds: Credentials, token: string, workspaceId: string, version?: string) => Promise<MemberVerdict>;
152
+ export declare const workspaceInvite: (base: string, creds: Credentials, workspaceId: string, role?: "owner" | "member", version?: string) => Promise<InviteResponse>;
@@ -3,6 +3,8 @@ import { promises as fs } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { CLOUD_DEFAULT_URL } from "../config.js";
6
+ /** The most routines one heartbeat carries. */
7
+ export const MAX_REPORTED_ROUTINES = 500;
6
8
  // Resolved on every use, not at import: a test swaps HOME after some module already imported this one.
7
9
  const kwHome = () => path.join(os.homedir(), ".kraftwerk");
8
10
  export const cloudFilePath = () => path.join(kwHome(), "cloud.json");
@@ -92,3 +94,5 @@ export const machineRegister = (base, creds, hello, workspaces) => cloudPost(bas
92
94
  export const machineHeartbeat = (base, creds, extra) => cloudPost(base, "/api/machines/heartbeat", { id: creds.id, secret: creds.secret, ...extra }, { version: extra.version });
93
95
  export const machineGoodbye = (base, creds, version) => cloudPost(base, "/api/machines/goodbye", { id: creds.id, secret: creds.secret }, { version, timeout: GOODBYE_TIMEOUT_MS });
94
96
  export const machineUnlink = (base, creds, version) => cloudPost(base, "/api/machines/unlink", { id: creds.id, secret: creds.secret }, { version });
97
+ export const memberVerify = (base, creds, token, workspaceId, version) => cloudPost(base, "/api/members/verify", { id: creds.id, secret: creds.secret, token, workspaceId }, { version });
98
+ export const workspaceInvite = (base, creds, workspaceId, role, version) => cloudPost(base, `/api/workspaces/${encodeURIComponent(workspaceId)}/invites`, { id: creds.id, secret: creds.secret, ...(role ? { role } : {}) }, { version });
@@ -18,7 +18,12 @@
18
18
  * runs_on is exactly this machine's identity. No runs_on, no runner — an
19
19
  * unassigned routine fires nowhere until someone adopts it ("run these
20
20
  * here"), which writes runs_on and commits routines.yml so the other clones
21
- * learn who runs it. `private:` is stored and round-tripped; nothing reads it yet.
21
+ * learn who runs it.
22
+ *
23
+ * `private: true` keeps a routine's runs to this machine: each run's chat is
24
+ * marked private when it is created (ChatMeta.private), and remote callers —
25
+ * member sessions and paired devices over the relay — never get it listed;
26
+ * the machine's cloud report (routineReport) leaves the routine out too.
22
27
  */
23
28
  export interface Routine {
24
29
  id: string;
@@ -29,7 +34,7 @@ export interface Routine {
29
34
  enabled: boolean;
30
35
  /** The one identity (`handle@machine`) that fires it. Absent: unassigned, fires nowhere. */
31
36
  runsOn?: string;
32
- /** Stored as given; no behaviour yet. */
37
+ /** Its runs' chats stay off remote callers' listings, and out of the cloud's report. */
33
38
  private?: boolean;
34
39
  }
35
40
  /**
@@ -101,6 +106,21 @@ export declare function routineStatuses(slug: string): Promise<RoutineStatus[]>;
101
106
  export declare function runRoutineNow(slug: string, id: string): Promise<{
102
107
  chatId: string;
103
108
  }>;
109
+ /** One routine that ran on this machine, as the machine's cloud heartbeat reports it (server/cloud.ts). */
110
+ export interface RoutineRan {
111
+ /** `<agent>/<routine id>`: the key in routines-state.json. */
112
+ routineId: string;
113
+ name: string;
114
+ /** Null when it never started (only failed to). */
115
+ lastRunAt: string | null;
116
+ /** False when its last attempt could not start (lastError). */
117
+ ok: boolean;
118
+ }
119
+ /**
120
+ * The current workspace's routines that ran (or tried to) on this machine,
121
+ * from routines-state.json and the listings; private routines left out.
122
+ */
123
+ export declare function routineReport(): Promise<RoutineRan[]>;
104
124
  /**
105
125
  * The routines due at `now` that fire on this machine: enabled, runs_on
106
126
  * exactly this identity, cron matching. Everything else — unassigned,
@@ -252,6 +252,7 @@ async function fireRoutine(slug, routine) {
252
252
  agent: def.harness,
253
253
  scope: { kind: "agent", slug: slug, routine: routine.id },
254
254
  title: `⏰ ${routine.name}`,
255
+ ...(routine.private ? { private: true } : {}),
255
256
  });
256
257
  const { error } = await postMessage(meta.id, routine.prompt);
257
258
  if (error)
@@ -288,6 +289,31 @@ export async function runRoutineNow(slug, id) {
288
289
  throw err;
289
290
  }
290
291
  }
292
+ /**
293
+ * The current workspace's routines that ran (or tried to) on this machine,
294
+ * from routines-state.json and the listings; private routines left out.
295
+ */
296
+ export async function routineReport() {
297
+ const state = await readState();
298
+ const out = [];
299
+ const bySlug = new Map();
300
+ for (const [key, s] of Object.entries(state)) {
301
+ if (!s.lastRunAt && !s.lastError)
302
+ continue;
303
+ const cut = key.indexOf("/");
304
+ if (cut <= 0)
305
+ continue;
306
+ const slug = key.slice(0, cut);
307
+ const id = key.slice(cut + 1);
308
+ if (!bySlug.has(slug))
309
+ bySlug.set(slug, await listRoutines(slug).catch(() => []));
310
+ const r = bySlug.get(slug).find((x) => x.id === id);
311
+ if (r?.private)
312
+ continue;
313
+ out.push({ routineId: key, name: r?.name ?? id, lastRunAt: s.lastRunAt ?? null, ok: !s.lastError });
314
+ }
315
+ return out.sort((a, b) => a.routineId.localeCompare(b.routineId));
316
+ }
291
317
  /* ---------- scheduler ---------- */
292
318
  /** The scheduler's timer, one per workspace. */
293
319
  const scheduler = perWorkspace(() => ({ timer: null }));