@netnodeag/kraftwerk 0.77.0 → 0.78.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/invite.d.ts +2 -0
- package/dist/cli/invite.js +48 -0
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/cli/remote.js +1 -1
- package/dist/client/relay.d.ts +40 -0
- package/dist/client/relay.js +35 -0
- package/dist/client/routes.d.ts +1 -0
- package/dist/client/routes.js +1 -0
- package/dist/core/chat/channel-turns.d.ts +2 -2
- package/dist/core/chat/channel-turns.js +2 -2
- package/dist/core/chat/sessions.d.ts +20 -3
- package/dist/core/chat/sessions.js +22 -7
- package/dist/core/chat/types.d.ts +21 -0
- package/dist/core/cloud.d.ts +38 -1
- package/dist/core/cloud.js +4 -0
- package/dist/core/routines.d.ts +22 -2
- package/dist/core/routines.js +26 -0
- package/dist/server/api/chats.js +45 -8
- package/dist/server/api/cloud.d.ts +16 -2
- package/dist/server/api/cloud.js +15 -2
- package/dist/server/api/devices.d.ts +7 -1
- package/dist/server/api/devices.js +3 -2
- package/dist/server/api/index.d.ts +20 -1
- package/dist/server/api/router.d.ts +3 -2
- package/dist/server/api/router.js +12 -0
- package/dist/server/cloud.d.ts +42 -3
- package/dist/server/cloud.js +77 -2
- package/dist/server/members.d.ts +87 -0
- package/dist/server/members.js +117 -0
- package/dist/server/relay.d.ts +15 -2
- package/dist/server/relay.js +58 -6
- package/dist/server/server.js +7 -0
- package/dist/server/trust.d.ts +16 -0
- package/dist/server/trust.js +12 -1
- package/package.json +2 -2
- package/web/dist/assets/{dist-BGHweZKi.js → dist-B-yVL5lk.js} +1 -1
- package/web/dist/assets/{dist-BVPxOqcx.js → dist-B9EMB1Ie.js} +1 -1
- package/web/dist/assets/{dist-DT2Tjvwx.js → dist-BTc878j7.js} +1 -1
- package/web/dist/assets/{dist-DoD3qX1P.js → dist-C90kUzps.js} +1 -1
- package/web/dist/assets/{dist-DCFREMXk.js → dist-CpLdAqFQ.js} +1 -1
- package/web/dist/assets/{dist-CXoAMU1b.js → dist-Crnb8VVO.js} +1 -1
- package/web/dist/assets/{dist-FFA4-S6d.js → dist-D0rjnN8S.js} +1 -1
- package/web/dist/assets/{dist-yGx0qYbw.js → dist-D4SDXoGw.js} +1 -1
- package/web/dist/assets/{dist-DdA5PoVJ.js → dist-DHRhdODt.js} +1 -1
- package/web/dist/assets/{dist-CCNyRSAA.js → dist-DQbifCX0.js} +1 -1
- package/web/dist/assets/dist-lSJ-O7kT.js +1 -0
- package/web/dist/assets/{editor-CyWapLQr.js → editor-DHAdW1g_.js} +3 -3
- package/web/dist/assets/{index-CNXcNoKJ.js → index-D7JVAjll.js} +4 -4
- package/web/dist/index.html +1 -1
- package/web/dist-remote/sw.js +1 -1
- package/web/dist/assets/dist-zjUUP5a9.js +0 -1
|
@@ -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
|
+
}
|
package/dist/cli/kraftwerk.js
CHANGED
|
@@ -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")
|
package/dist/cli/remote.js
CHANGED
|
@@ -49,7 +49,7 @@ export function registerRemoteCommand(program) {
|
|
|
49
49
|
remote
|
|
50
50
|
.command("on")
|
|
51
51
|
.description("Turn remote access on: the daemon connects to the relay")
|
|
52
|
-
.option("--url <url>", "The cloud whose relay to use (default: the kraftwerk cloud)")
|
|
52
|
+
.option("--url <url>", "The cloud whose relay to use (default: this machine's cloud — env, cloud.json, else the kraftwerk cloud)")
|
|
53
53
|
.action(async (opts) => {
|
|
54
54
|
if (opts.url && !/^https?:\/\//.test(opts.url)) {
|
|
55
55
|
console.error(chalk.red("✖ --url is the cloud's address, e.g. https://srv.kraftwerk-cloud.netnode.cloud"));
|
package/dist/client/relay.d.ts
CHANGED
|
@@ -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>;
|
package/dist/client/relay.js
CHANGED
|
@@ -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
|
}
|
package/dist/client/routes.d.ts
CHANGED
|
@@ -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"];
|
package/dist/client/routes.js
CHANGED
|
@@ -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;
|
package/dist/core/cloud.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/cloud.js
CHANGED
|
@@ -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 });
|
package/dist/core/routines.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
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,
|
package/dist/core/routines.js
CHANGED
|
@@ -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 }));
|