@netnodeag/kraftwerk 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -7
- package/dist/cli/doctor.js +7 -1
- package/dist/cli/init.js +1 -0
- package/dist/cli/kraftwerk.js +5 -1
- package/dist/cli/projects.d.ts +0 -14
- package/dist/cli/projects.js +181 -107
- package/dist/cli/repos.js +1 -1
- package/dist/cli/ui.js +1 -1
- package/dist/cli/vibeables.js +1 -1
- package/dist/cli/workspaces.d.ts +16 -0
- package/dist/cli/workspaces.js +149 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +19 -3
- package/dist/inspector/agents.js +2 -2
- package/dist/inspector/channels.d.ts +10 -1
- package/dist/inspector/channels.js +11 -2
- package/dist/inspector/chat/sessions.d.ts +8 -3
- package/dist/inspector/chat/sessions.js +58 -19
- package/dist/inspector/chat/types.d.ts +11 -0
- package/dist/inspector/git.d.ts +51 -0
- package/dist/inspector/git.js +22 -12
- package/dist/inspector/instances.d.ts +14 -12
- package/dist/inspector/instances.js +71 -31
- package/dist/inspector/notifications.js +2 -0
- package/dist/inspector/projects.d.ts +167 -0
- package/dist/inspector/projects.js +520 -0
- package/dist/inspector/repos.d.ts +47 -0
- package/dist/inspector/repos.js +121 -2
- package/dist/inspector/search.d.ts +1 -1
- package/dist/inspector/search.js +3 -3
- package/dist/inspector/server.js +149 -21
- package/dist/inspector/settings.d.ts +3 -1
- package/dist/inspector/settings.js +14 -3
- package/inspector/dist/assets/dist-BB5opUKX.js +1 -0
- package/inspector/dist/assets/{dist-Dv40dCIn.js → dist-BV28M6sp.js} +1 -1
- package/inspector/dist/assets/{dist-BNGsfwfO.js → dist-Bi4ZTD6W.js} +1 -1
- package/inspector/dist/assets/{dist-n8GR1Slo.js → dist-BuqvesGh.js} +1 -1
- package/inspector/dist/assets/{dist-O97r5Ket.js → dist-CLSTyUdp.js} +1 -1
- package/inspector/dist/assets/{dist-D6fPO3Jz.js → dist-Ca8OxgWP.js} +1 -1
- package/inspector/dist/assets/{dist-DUq31xBw.js → dist-DGB-RjHI.js} +1 -1
- package/inspector/dist/assets/{dist-Bz3XVWXl.js → dist-DHS6DxTY.js} +1 -1
- package/inspector/dist/assets/{dist-Dk5ZcwMb.js → dist-DVWyuKcp.js} +1 -1
- package/inspector/dist/assets/{dist-BWkBM49t.js → dist-DkEzjj-j.js} +1 -1
- package/inspector/dist/assets/{dist-CmH9PvLb.js → dist-Dv003C77.js} +1 -1
- package/inspector/dist/assets/{editor-toVFopCO.js → editor-B5WUiag9.js} +3 -3
- package/inspector/dist/assets/{index-CC0tY40i.js → index-BfiWjmwA.js} +11 -11
- package/inspector/dist/assets/{index-DQrsXEWt.css → index-DCpGWY1g.css} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +18 -0
- package/inspector/dist/assets/dist-JaBY_kZV.js +0 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { absolutePath } from "../config.js";
|
|
4
|
+
import Table from "cli-table3";
|
|
5
|
+
import { discoverWorkspaces, forgetWorkspace, startWorkspace, stopWorkspace, tildify, } from "../inspector/instances.js";
|
|
6
|
+
/**
|
|
7
|
+
* `kraftwerk workspaces` — every workspace that ever ran the inspector on this
|
|
8
|
+
* machine (~/.kraftwerk/workspaces), joined with what is running right now.
|
|
9
|
+
* The answer to "where did I start that UI, and how do I get it back":
|
|
10
|
+
*
|
|
11
|
+
* kraftwerk workspaces list: name, root, running/stopped
|
|
12
|
+
* kraftwerk workspaces start <ref> relaunch `kraftwerk ui` in that root, detached
|
|
13
|
+
* kraftwerk workspaces stop <ref> SIGTERM a running UI (also ones started in a terminal)
|
|
14
|
+
* kraftwerk workspaces forget <ref> drop the record (the folder stays)
|
|
15
|
+
*
|
|
16
|
+
* <ref> is a workspace name, the root's folder name, the root path, or a
|
|
17
|
+
* port / localhost:port for running instances that recorded no root.
|
|
18
|
+
*/
|
|
19
|
+
export const fmtAgo = (iso) => {
|
|
20
|
+
if (!iso)
|
|
21
|
+
return "—";
|
|
22
|
+
const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
|
|
23
|
+
if (s < 90)
|
|
24
|
+
return "just now";
|
|
25
|
+
if (s < 5400)
|
|
26
|
+
return `${Math.round(s / 60)} min ago`;
|
|
27
|
+
if (s < 129600)
|
|
28
|
+
return `${Math.round(s / 3600)} h ago`;
|
|
29
|
+
return `${Math.round(s / 86400)} d ago`;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Running → its url. Not running: "stopped" after a clean shutdown, "died"
|
|
33
|
+
* when the last start has no matching stop (killed, crashed, terminal
|
|
34
|
+
* closed) — the case this command exists for.
|
|
35
|
+
*/
|
|
36
|
+
const status = (e) => {
|
|
37
|
+
if (e.live)
|
|
38
|
+
return chalk.green(`● ${e.url.replace(/^https?:\/\//, "")}`);
|
|
39
|
+
if (e.exists === false)
|
|
40
|
+
return chalk.red("path missing");
|
|
41
|
+
const clean = !!e.lastStopped && !!e.lastStarted && e.lastStopped >= e.lastStarted;
|
|
42
|
+
return clean ? chalk.dim("stopped") : chalk.yellow("died");
|
|
43
|
+
};
|
|
44
|
+
/** Match a ref against known workspaces; `needRoot` excludes rootless live instances (start/forget need a root). */
|
|
45
|
+
async function resolveRef(ref, needRoot = true) {
|
|
46
|
+
const all = (await discoverWorkspaces()).filter((e) => e.root || !needRoot);
|
|
47
|
+
const abs = absolutePath(ref);
|
|
48
|
+
const port = /^\d+$/.test(ref) ? `http://localhost:${ref}` : `http://${ref.replace(/^https?:\/\//, "")}`;
|
|
49
|
+
const hits = all.filter((e) => e.root === abs ||
|
|
50
|
+
e.root === ref ||
|
|
51
|
+
e.name === ref ||
|
|
52
|
+
(e.root && path.basename(e.root) === ref) ||
|
|
53
|
+
e.url === port);
|
|
54
|
+
if (hits.length === 1)
|
|
55
|
+
return hits[0];
|
|
56
|
+
if (hits.length === 0) {
|
|
57
|
+
console.error(chalk.red(`No known workspace "${ref}". See \`kraftwerk workspaces\`.`));
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
console.error(chalk.red(`"${ref}" is ambiguous — use the root path or port:`));
|
|
61
|
+
for (const h of hits)
|
|
62
|
+
console.error(` ${h.root ?? h.url}`);
|
|
63
|
+
process.exit(2);
|
|
64
|
+
}
|
|
65
|
+
export function registerWorkspaceCommands(program) {
|
|
66
|
+
const workspaces = program
|
|
67
|
+
.command("workspaces")
|
|
68
|
+
.description("Known kraftwerk workspaces on this machine: list, start a stopped UI, stop a running one, forget");
|
|
69
|
+
workspaces
|
|
70
|
+
.command("list", { isDefault: true })
|
|
71
|
+
.description("List known workspaces with their root and whether the UI is running")
|
|
72
|
+
.option("--json", "Machine-readable output")
|
|
73
|
+
.action(async (opts) => {
|
|
74
|
+
const entries = await discoverWorkspaces();
|
|
75
|
+
if (opts.json) {
|
|
76
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (entries.length === 0) {
|
|
80
|
+
console.log(chalk.dim("No workspaces known yet — `kraftwerk ui` registers the current one."));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const table = new Table({
|
|
84
|
+
head: ["workspace", "root", "status", "last started"].map((h) => chalk.bold(h)),
|
|
85
|
+
wordWrap: true,
|
|
86
|
+
colWidths: [22, 48, 24, 14],
|
|
87
|
+
});
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
table.push([
|
|
90
|
+
`${e.icon ? e.icon + " " : ""}${chalk.cyan(e.name)}`,
|
|
91
|
+
e.rootLabel ?? chalk.dim("(no root recorded)"),
|
|
92
|
+
status(e),
|
|
93
|
+
fmtAgo(e.lastStarted),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
console.log(table.toString());
|
|
97
|
+
});
|
|
98
|
+
workspaces
|
|
99
|
+
.command("start")
|
|
100
|
+
.description("Relaunch the UI of a stopped workspace (detached; logs in ~/.kraftwerk/logs)")
|
|
101
|
+
.argument("<ref>", "Workspace name, folder name, or root path")
|
|
102
|
+
.action(async (ref) => {
|
|
103
|
+
const entry = await resolveRef(ref);
|
|
104
|
+
if (entry.live) {
|
|
105
|
+
console.log(`${chalk.green("✔")} ${entry.name} already running: ${chalk.cyan(entry.url)}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const r = await startWorkspace(entry.root);
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
console.error(chalk.red(`Could not start ${entry.name}: ${r.error}`));
|
|
111
|
+
if (r.log)
|
|
112
|
+
console.error(chalk.dim(`log: ${tildify(r.log)}`));
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
console.log(`${chalk.green("✔")} ${entry.name} ${r.live ? "running" : "starting"}: ${chalk.cyan(r.url)}` +
|
|
116
|
+
chalk.dim(` (pid ${r.pid}${r.log ? `, log ${tildify(r.log)}` : ""})`));
|
|
117
|
+
});
|
|
118
|
+
workspaces
|
|
119
|
+
.command("stop")
|
|
120
|
+
.description("Stop a running UI (SIGTERM to its server; works for terminal-started ones too)")
|
|
121
|
+
.argument("<ref>", "Workspace name, folder name, root path, or port")
|
|
122
|
+
.action(async (ref) => {
|
|
123
|
+
const entry = await resolveRef(ref, false);
|
|
124
|
+
if (!entry.live) {
|
|
125
|
+
console.log(chalk.dim(`${entry.name} is not running.`));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const r = await stopWorkspace({ root: entry.root, url: entry.url });
|
|
129
|
+
if (!r.ok) {
|
|
130
|
+
console.error(chalk.red(`Could not stop ${entry.name}: ${r.error}`));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
console.log(`${chalk.green("✔")} ${entry.name} stopped ${chalk.dim(`(${entry.url.replace(/^https?:\/\//, "")})`)}`);
|
|
134
|
+
});
|
|
135
|
+
workspaces
|
|
136
|
+
.command("forget")
|
|
137
|
+
.description("Remove a workspace from the registry (its folder is untouched)")
|
|
138
|
+
.argument("<ref>", "Workspace name, folder name, or root path")
|
|
139
|
+
.action(async (ref) => {
|
|
140
|
+
const entry = await resolveRef(ref);
|
|
141
|
+
if (await forgetWorkspace(entry.root)) {
|
|
142
|
+
console.log(`${chalk.green("✔")} forgot ${entry.name} (${entry.rootLabel})`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
console.error(chalk.red(`No record for ${entry.root}`));
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
* repos: # git repositories the agents work on (absent = off, bare key = on)
|
|
30
30
|
* root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
|
|
31
31
|
* vibeables: # small apps built live in a chat, rendered in the inspector (absent = off, bare key = on)
|
|
32
|
-
|
|
32
|
+
* root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
|
|
33
|
+
* projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
|
|
34
|
+
* root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
|
|
33
35
|
* public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
|
|
34
36
|
* tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
|
|
35
37
|
* name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
|
|
@@ -102,6 +104,23 @@ export interface VibeablesConfig {
|
|
|
102
104
|
export declare const VIBEABLES_DEFAULT_ROOT = "kraftwerk-data/vibeables";
|
|
103
105
|
/** Absolute vibeables root when the feature is on, undefined otherwise. */
|
|
104
106
|
export declare function vibeablesRootFor(project: Project): string | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* Projects: a goal with everything the agents need to reach it in one
|
|
109
|
+
* folder — a brief, the systems of record where the truth lives, and links
|
|
110
|
+
* to the workspace's knowledge, vibeables, repositories, workflows and
|
|
111
|
+
* agents. Each is one folder under the root; part of the workspace and
|
|
112
|
+
* synced by the workspace git like agents and knowledge.
|
|
113
|
+
*/
|
|
114
|
+
export interface ProjectsConfig {
|
|
115
|
+
/** false keeps the block but turns the feature off. Default: true. */
|
|
116
|
+
enabled?: boolean;
|
|
117
|
+
/** Where the projects live, relative to the project root. Default: kraftwerk-data/projects */
|
|
118
|
+
root?: string;
|
|
119
|
+
}
|
|
120
|
+
/** Where projects live when `projects.root` is not set. */
|
|
121
|
+
export declare const PROJECTS_DEFAULT_ROOT = "kraftwerk-data/projects";
|
|
122
|
+
/** Absolute projects root when the feature is on, undefined otherwise. */
|
|
123
|
+
export declare function projectsRootFor(project: Project): string | undefined;
|
|
105
124
|
/**
|
|
106
125
|
* Cloudflare Access in front of the public hostname. Access puts a login
|
|
107
126
|
* page on the hostname at Cloudflare's edge and hands the origin a signed
|
|
@@ -183,6 +202,8 @@ export interface ProjectConfig {
|
|
|
183
202
|
repos?: ReposConfig;
|
|
184
203
|
/** Vibeables: apps built live in a chat. Absent = off. */
|
|
185
204
|
vibeables?: VibeablesConfig;
|
|
205
|
+
/** Projects: goal-scoped folders the chats work in. Absent = off. */
|
|
206
|
+
projects?: ProjectsConfig;
|
|
186
207
|
/**
|
|
187
208
|
* Hostname the inspector is reached at through a tunnel or reverse proxy,
|
|
188
209
|
* e.g. "https://kw.example.com". The loopback bind then answers requests
|
package/dist/config.js
CHANGED
|
@@ -33,7 +33,9 @@ import { parse } from "yaml";
|
|
|
33
33
|
* repos: # git repositories the agents work on (absent = off, bare key = on)
|
|
34
34
|
* root: kraftwerk-data/repos # where clones land, relative to the file. Default: repos
|
|
35
35
|
* vibeables: # small apps built live in a chat, rendered in the inspector (absent = off, bare key = on)
|
|
36
|
-
|
|
36
|
+
* root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
|
|
37
|
+
* projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
|
|
38
|
+
* root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
|
|
37
39
|
* public: https://kw.example.com # hostname the inspector is reached at through a tunnel or reverse proxy
|
|
38
40
|
* tunnel: # Cloudflare Tunnel run by `kraftwerk ui` (absent = off, bare key = on)
|
|
39
41
|
* name: kraftwerk # locally-managed tunnel (cloudflared tunnel create); absent: TUNNEL_TOKEN env, dashboard-managed
|
|
@@ -84,6 +86,15 @@ export function vibeablesRootFor(project) {
|
|
|
84
86
|
return undefined;
|
|
85
87
|
return path.resolve(project.root, v.root ?? VIBEABLES_DEFAULT_ROOT);
|
|
86
88
|
}
|
|
89
|
+
/** Where projects live when `projects.root` is not set. */
|
|
90
|
+
export const PROJECTS_DEFAULT_ROOT = "kraftwerk-data/projects";
|
|
91
|
+
/** Absolute projects root when the feature is on, undefined otherwise. */
|
|
92
|
+
export function projectsRootFor(project) {
|
|
93
|
+
const p = project.config.projects;
|
|
94
|
+
if (!p || p.enabled === false)
|
|
95
|
+
return undefined;
|
|
96
|
+
return path.resolve(project.root, p.root ?? PROJECTS_DEFAULT_ROOT);
|
|
97
|
+
}
|
|
87
98
|
/** The public hostname (lowercase, no port) when `public` is set, undefined otherwise. */
|
|
88
99
|
export function publicHostFor(project) {
|
|
89
100
|
return project.config.public ? parsePublic(project.config.public)?.hostname : undefined;
|
|
@@ -187,7 +198,7 @@ export async function resolveProject(cwd) {
|
|
|
187
198
|
const root = gitFallback ?? start;
|
|
188
199
|
return { root, config: {}, outputDir: path.join(root, "output") };
|
|
189
200
|
}
|
|
190
|
-
const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "public", "tunnel"];
|
|
201
|
+
const KNOWN_KEYS = ["name", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "public", "tunnel"];
|
|
191
202
|
async function loadConfig(configPath) {
|
|
192
203
|
let raw;
|
|
193
204
|
try {
|
|
@@ -238,6 +249,11 @@ async function loadConfig(configPath) {
|
|
|
238
249
|
config[key] = {};
|
|
239
250
|
validateRootBlock(configPath, "vibeables", config[key]);
|
|
240
251
|
}
|
|
252
|
+
else if (key === "projects") {
|
|
253
|
+
if (config[key] === null)
|
|
254
|
+
config[key] = {};
|
|
255
|
+
validateRootBlock(configPath, "projects", config[key]);
|
|
256
|
+
}
|
|
241
257
|
else if (key === "public") {
|
|
242
258
|
if (typeof config[key] !== "string" || !parsePublic(config[key])) {
|
|
243
259
|
throw new Error(`${path.basename(configPath)}: public must be a hostname or https URL like "https://kw.example.com"`);
|
|
@@ -353,7 +369,7 @@ function validateGit(configPath, value) {
|
|
|
353
369
|
}
|
|
354
370
|
}
|
|
355
371
|
/**
|
|
356
|
-
* `repos` and `
|
|
372
|
+
* `repos`, `vibeables` and `projects` share one shape: { enabled?, root? }. The root
|
|
357
373
|
* must never be the project itself or anything outside it — it is what
|
|
358
374
|
* remove/delete operate under, and what the workspace git may stage.
|
|
359
375
|
*/
|
package/dist/inspector/agents.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { parse, stringify } from "yaml";
|
|
4
4
|
import { resolveProject } from "../config.js";
|
|
5
5
|
import { getProjectRoot } from "./context.js";
|
|
6
|
-
import {
|
|
6
|
+
import { syncWorkspaceAgents } from "./instances.js";
|
|
7
7
|
/**
|
|
8
8
|
* Agents: persistent agents, defined on the filesystem. Each one
|
|
9
9
|
* is one folder under the project's agents/ root:
|
|
@@ -88,7 +88,7 @@ export async function listAgents() {
|
|
|
88
88
|
// palettes. Awaited so a read that follows a save sees the record updated;
|
|
89
89
|
// it only writes when the roster changed.
|
|
90
90
|
await resolveProject(getProjectRoot())
|
|
91
|
-
.then((p) =>
|
|
91
|
+
.then((p) => syncWorkspaceAgents(p.root, list.filter((a) => !a.archived).map(toSummary)))
|
|
92
92
|
.catch(() => { });
|
|
93
93
|
return list;
|
|
94
94
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* channel where the coworkers are agents. The definition is git-tracked
|
|
4
4
|
* with the workspace so the whole team sees the same channels:
|
|
5
5
|
*
|
|
6
|
-
* channels/<slug>/channel.yml # name, purpose, members (agent slugs), responder, maxHops
|
|
6
|
+
* channels/<slug>/channel.yml # name, purpose, members (agent slugs), responder, maxHops, project
|
|
7
7
|
*
|
|
8
8
|
* The transcript itself is a chat (scope { kind: "channel", slug }) under
|
|
9
9
|
* <output>/chats/, run-state like every other chat. sessions.ts gives each
|
|
@@ -22,6 +22,13 @@ export interface Channel {
|
|
|
22
22
|
responder?: string;
|
|
23
23
|
/** Agent-to-agent handovers allowed per human message. */
|
|
24
24
|
maxHops: number;
|
|
25
|
+
/**
|
|
26
|
+
* The project this channel works in (a slug under the projects root):
|
|
27
|
+
* every member gets the project's brief, state, records and links as
|
|
28
|
+
* context, and the channel lists under the project. Set when a project
|
|
29
|
+
* chat gets its first coworker; see convertChatToChannel.
|
|
30
|
+
*/
|
|
31
|
+
project?: string;
|
|
25
32
|
}
|
|
26
33
|
/** What the ⌘K palette needs of a channel (and what the project registry records). */
|
|
27
34
|
export type ChannelSummary = Pick<Channel, "slug" | "name" | "purpose">;
|
|
@@ -39,6 +46,8 @@ export interface SaveChannelInput {
|
|
|
39
46
|
/** Agent slug, or "" / null to clear. */
|
|
40
47
|
responder?: string | null;
|
|
41
48
|
maxHops?: number;
|
|
49
|
+
/** Project slug, or "" / null to detach. */
|
|
50
|
+
project?: string | null;
|
|
42
51
|
}
|
|
43
52
|
/** Create (no slug: derived from the name) or update a channel definition. */
|
|
44
53
|
export declare function saveChannel(input: SaveChannelInput): Promise<Channel>;
|
|
@@ -4,7 +4,7 @@ import { parse, stringify } from "yaml";
|
|
|
4
4
|
import { getProjectRoot } from "./context.js";
|
|
5
5
|
import { resolveProject } from "../config.js";
|
|
6
6
|
import { listAgents, safeAgentSlug, slugFromName } from "./agents.js";
|
|
7
|
-
import {
|
|
7
|
+
import { syncWorkspaceChannels } from "./instances.js";
|
|
8
8
|
export const toChannelSummary = (c) => ({
|
|
9
9
|
slug: c.slug,
|
|
10
10
|
name: c.name,
|
|
@@ -21,6 +21,7 @@ export function safeChannelSlug(slug) {
|
|
|
21
21
|
throw new Error(`invalid channel slug "${slug}"`);
|
|
22
22
|
return slug;
|
|
23
23
|
}
|
|
24
|
+
const PROJECT_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
|
|
24
25
|
function normalize(slug, raw) {
|
|
25
26
|
const members = Array.isArray(raw.members) ? [...new Set(raw.members.map(String))] : [];
|
|
26
27
|
const responder = typeof raw.responder === "string" && members.includes(raw.responder) ? raw.responder : undefined;
|
|
@@ -32,6 +33,7 @@ function normalize(slug, raw) {
|
|
|
32
33
|
members,
|
|
33
34
|
...(responder ? { responder } : {}),
|
|
34
35
|
maxHops: hops,
|
|
36
|
+
...(typeof raw.project === "string" && PROJECT_SLUG_RE.test(raw.project) ? { project: raw.project } : {}),
|
|
35
37
|
};
|
|
36
38
|
}
|
|
37
39
|
export async function listChannels() {
|
|
@@ -49,7 +51,7 @@ export async function listChannels() {
|
|
|
49
51
|
const list = channels.filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
|
|
50
52
|
// Like the agent roster: the registry copy feeds other instances' palettes.
|
|
51
53
|
await resolveProject(getProjectRoot())
|
|
52
|
-
.then((p) =>
|
|
54
|
+
.then((p) => syncWorkspaceChannels(p.root, list.map(toChannelSummary)))
|
|
53
55
|
.catch(() => { });
|
|
54
56
|
return list;
|
|
55
57
|
}
|
|
@@ -100,6 +102,12 @@ export async function saveChannel(input) {
|
|
|
100
102
|
throw new Error("maxHops must be a whole number");
|
|
101
103
|
})()
|
|
102
104
|
: existing?.maxHops ?? DEFAULT_MAX_HOPS;
|
|
105
|
+
let project = existing?.project;
|
|
106
|
+
if (input.project !== undefined) {
|
|
107
|
+
if (input.project && !PROJECT_SLUG_RE.test(input.project))
|
|
108
|
+
throw new Error(`invalid project name "${input.project}"`);
|
|
109
|
+
project = input.project || undefined;
|
|
110
|
+
}
|
|
103
111
|
const channel = {
|
|
104
112
|
slug,
|
|
105
113
|
name,
|
|
@@ -113,6 +121,7 @@ export async function saveChannel(input) {
|
|
|
113
121
|
members,
|
|
114
122
|
...(responder ? { responder } : {}),
|
|
115
123
|
maxHops,
|
|
124
|
+
...(project ? { project } : {}),
|
|
116
125
|
};
|
|
117
126
|
const dir = path.join(await channelsRoot(), slug);
|
|
118
127
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -44,9 +44,14 @@ export declare function postMessage(id: string, text: string, opts?: {
|
|
|
44
44
|
/** The chat that holds a channel's transcript; created on first use. */
|
|
45
45
|
export declare function ensureChannelChat(channel: Channel): Promise<ChatMeta>;
|
|
46
46
|
/**
|
|
47
|
-
* "Add a coworker"
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* "Add a coworker": the chat becomes the channel's transcript.
|
|
48
|
+
*
|
|
49
|
+
* From an agent session, the agent keeps its process and memory — it has
|
|
50
|
+
* seen every message so far — and learns the channel rules with its next
|
|
51
|
+
* prompt. From a project chat, the project assistant has no agent identity
|
|
52
|
+
* to keep, so its process goes; the members join with the whole transcript
|
|
53
|
+
* (a fresh seat starts at seq 0) and the project's context, and the chat
|
|
54
|
+
* stays listed under the project through meta.project.
|
|
50
55
|
*/
|
|
51
56
|
export declare function convertChatToChannel(chatId: string, channel: Channel): Promise<{
|
|
52
57
|
error?: string;
|
|
@@ -8,6 +8,7 @@ import { listWorkflows } from "../workflows.js";
|
|
|
8
8
|
import { getAgent, listAgents } from "../agents.js";
|
|
9
9
|
import { listRepos } from "../repos.js";
|
|
10
10
|
import { resolveVibeable, vibeableStatus, VIBEABLE_CONFIG_FILE } from "../vibeables.js";
|
|
11
|
+
import { getProject, projectContext } from "../projects.js";
|
|
11
12
|
import { deleteAgentSession, listAgentSessions, startAcpBackend } from "./acp.js";
|
|
12
13
|
import { startPiBackend } from "./pi.js";
|
|
13
14
|
import { UNATTENDED_PERMISSION_TIMEOUT_MS, declineOption, unattendedTimeoutLabel } from "./permissions.js";
|
|
@@ -401,6 +402,11 @@ async function baseScopeContext(scope, agent) {
|
|
|
401
402
|
`not retry it another way. End with a clear, self-contained summary of what you did and found.`
|
|
402
403
|
: ""));
|
|
403
404
|
}
|
|
405
|
+
if (scope.kind === "project") {
|
|
406
|
+
// Everything the project gathered — brief, state, records, links — plus
|
|
407
|
+
// how to keep it current; see projects.ts for the block.
|
|
408
|
+
return projectContext(scope.slug, `kraftwerk-chat/${agent}`);
|
|
409
|
+
}
|
|
404
410
|
if (scope.kind === "knowledge") {
|
|
405
411
|
const { root, bundles } = await knowledgeIndex().catch(() => ({ root: "", bundles: [] }));
|
|
406
412
|
const bundleLines = bundles
|
|
@@ -502,14 +508,16 @@ async function scopeContext(meta, seat) {
|
|
|
502
508
|
const agent = seat.harness;
|
|
503
509
|
// The repositories block reads every clone from git, so it runs alongside
|
|
504
510
|
// the rest instead of adding its spawns to the first prompt's latency.
|
|
505
|
-
const [base, repos, vibe, skills, channel] = await Promise.all([
|
|
511
|
+
const [base, repos, vibe, skills, channel, project] = await Promise.all([
|
|
506
512
|
baseScopeContext(scope, agent),
|
|
507
513
|
scope.kind === "agent" || scope.kind === "kraftwerk" ? reposContext() : Promise.resolve(""),
|
|
508
514
|
meta.vibeable ? vibeableContext(meta.vibeable, meta.cwd) : Promise.resolve(""),
|
|
509
515
|
availableSkills(scope),
|
|
510
516
|
meta.scope.kind === "channel" ? channelContext(meta.scope.slug, seat.key) : Promise.resolve(""),
|
|
517
|
+
// A channel that works in a project: every member reads the project too, as a member, not as its assistant.
|
|
518
|
+
meta.scope.kind === "channel" && meta.project ? projectContext(meta.project, `${seat.key}/${agent}`, { member: true }) : Promise.resolve(""),
|
|
511
519
|
]);
|
|
512
|
-
return [base, channel, repos, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
|
|
520
|
+
return [base, channel, project, repos, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
|
|
513
521
|
}
|
|
514
522
|
/** The channel block a member agent gets once per process: who is here, how turns work, how to hand over. */
|
|
515
523
|
async function channelContext(slug, self) {
|
|
@@ -554,6 +562,14 @@ async function backendTuning(meta, seat) {
|
|
|
554
562
|
if (agent === "claude" && def?.skills)
|
|
555
563
|
tuning.skills = def.skills;
|
|
556
564
|
}
|
|
565
|
+
// Project chats carry the project's model/effort the same way.
|
|
566
|
+
if (scope.kind === "project") {
|
|
567
|
+
const def = await getProject(scope.slug).catch(() => null);
|
|
568
|
+
if (def?.model)
|
|
569
|
+
tuning.model = def.model;
|
|
570
|
+
if (def?.effort)
|
|
571
|
+
tuning.effort = def.effort;
|
|
572
|
+
}
|
|
557
573
|
// Run chats live in the run folder — grant claude the project root so
|
|
558
574
|
// project-level skills and files stay reachable.
|
|
559
575
|
// A vibeable session works inside the app folder for the same reason.
|
|
@@ -944,34 +960,57 @@ export async function ensureChannelChat(channel) {
|
|
|
944
960
|
const existing = metas.find((m) => m.scope.kind === "channel" && m.scope.slug === channel.slug);
|
|
945
961
|
if (existing)
|
|
946
962
|
return states.get(existing.id)?.meta ?? existing;
|
|
947
|
-
|
|
963
|
+
const meta = await createChat({ agent: "claude", scope: { kind: "channel", slug: channel.slug }, title: channel.name });
|
|
964
|
+
if (channel.project) {
|
|
965
|
+
meta.project = channel.project;
|
|
966
|
+
await writeMeta(meta);
|
|
967
|
+
}
|
|
968
|
+
return meta;
|
|
948
969
|
}
|
|
949
970
|
/**
|
|
950
|
-
* "Add a coworker"
|
|
951
|
-
*
|
|
952
|
-
*
|
|
971
|
+
* "Add a coworker": the chat becomes the channel's transcript.
|
|
972
|
+
*
|
|
973
|
+
* From an agent session, the agent keeps its process and memory — it has
|
|
974
|
+
* seen every message so far — and learns the channel rules with its next
|
|
975
|
+
* prompt. From a project chat, the project assistant has no agent identity
|
|
976
|
+
* to keep, so its process goes; the members join with the whole transcript
|
|
977
|
+
* (a fresh seat starts at seq 0) and the project's context, and the chat
|
|
978
|
+
* stays listed under the project through meta.project.
|
|
953
979
|
*/
|
|
954
980
|
export async function convertChatToChannel(chatId, channel) {
|
|
955
981
|
const state = await loadState(chatId);
|
|
956
982
|
if (!state)
|
|
957
983
|
return { error: "chat not found" };
|
|
958
984
|
const scope = state.meta.scope;
|
|
959
|
-
if (scope.kind !== "agent")
|
|
960
|
-
return { error: "only an agent session can become a channel" };
|
|
961
|
-
if (!channel.members.includes(scope.slug))
|
|
985
|
+
if (scope.kind !== "agent" && scope.kind !== "project")
|
|
986
|
+
return { error: "only an agent session or a project chat can become a channel" };
|
|
987
|
+
if (scope.kind === "agent" && !channel.members.includes(scope.slug))
|
|
962
988
|
return { error: `the channel must include @${scope.slug}` };
|
|
963
989
|
if (isBusy(state))
|
|
964
990
|
return { error: "agent is still working — wait for the turn to finish" };
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
state.meta.
|
|
991
|
+
if (scope.kind === "project") {
|
|
992
|
+
if (channel.project !== scope.slug)
|
|
993
|
+
return { error: `the channel must belong to project "${scope.slug}"` };
|
|
994
|
+
dropBackend(state);
|
|
995
|
+
state.seats.delete(MAIN);
|
|
996
|
+
if (state.meta.sessions?.[MAIN]) {
|
|
997
|
+
const { [MAIN]: _gone, ...rest } = state.meta.sessions;
|
|
998
|
+
state.meta.sessions = rest;
|
|
999
|
+
}
|
|
1000
|
+
state.meta.project = scope.slug;
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
const main = state.seats.get(MAIN);
|
|
1004
|
+
state.seats.delete(MAIN);
|
|
1005
|
+
const seat = main ?? newSeat(scope.slug, state.meta.agent);
|
|
1006
|
+
seat.key = scope.slug;
|
|
1007
|
+
seat.needsContext = true;
|
|
1008
|
+
seat.seenSeq = state.events[state.events.length - 1]?.seq ?? 0;
|
|
1009
|
+
state.seats.set(scope.slug, seat);
|
|
1010
|
+
if (state.meta.sessions?.[MAIN]) {
|
|
1011
|
+
const { [MAIN]: session, ...rest } = state.meta.sessions;
|
|
1012
|
+
state.meta.sessions = { ...rest, [scope.slug]: session };
|
|
1013
|
+
}
|
|
975
1014
|
}
|
|
976
1015
|
state.meta.scope = { kind: "channel", slug: channel.slug };
|
|
977
1016
|
state.meta.title = channel.name;
|
|
@@ -21,6 +21,11 @@ export type ChatScope = {
|
|
|
21
21
|
slug: string;
|
|
22
22
|
routine?: string;
|
|
23
23
|
}
|
|
24
|
+
/** A project: the chat carries its brief, state, systems of record and links; see projects.ts. */
|
|
25
|
+
| {
|
|
26
|
+
kind: "project";
|
|
27
|
+
slug: string;
|
|
28
|
+
}
|
|
24
29
|
/** A channel: several agents (and humans) share one transcript; see channels.ts. */
|
|
25
30
|
| {
|
|
26
31
|
kind: "channel";
|
|
@@ -42,6 +47,12 @@ export interface ChatMeta {
|
|
|
42
47
|
scope: ChatScope;
|
|
43
48
|
/** Slug of the vibeable open in this chat's preview pane (cwd is its folder while set). */
|
|
44
49
|
vibeable?: string;
|
|
50
|
+
/**
|
|
51
|
+
* The project this chat belongs to when its scope is not the project
|
|
52
|
+
* itself: a channel that started as a project chat. Lists the chat under
|
|
53
|
+
* the project and hands every seat the project's context.
|
|
54
|
+
*/
|
|
55
|
+
project?: string;
|
|
45
56
|
/**
|
|
46
57
|
* The agent's own session id per seat ("main", or the agent slug in a
|
|
47
58
|
* channel): after a restart or the idle reaper, the next backend resumes
|
package/dist/inspector/git.d.ts
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace git sync. Several people run kraftwerk against one git repo, so
|
|
3
|
+
* knowledge, agents, skills, channels and workflows travel between them like
|
|
4
|
+
* any other tracked file. The inspector shows what changed, lets a human pick files and
|
|
5
|
+
* commit them, and pushes on request.
|
|
6
|
+
*
|
|
7
|
+
* Deliberate limits, because this runs git on someone's repo from a web UI
|
|
8
|
+
* that has no authentication of its own:
|
|
9
|
+
*
|
|
10
|
+
* - Only the roots kraftwerk.yml already declares are ever staged, never the
|
|
11
|
+
* whole repo, and never the output directory (runs and chats are noise).
|
|
12
|
+
* - DENY matches (.env, keys) are never staged, and `git add -f` is never
|
|
13
|
+
* called, so .gitignore keeps its meaning.
|
|
14
|
+
* - Reads are scoped too: the diff endpoint only shows a path the status
|
|
15
|
+
* screen lists as syncable, and status never lists what .gitignore hides,
|
|
16
|
+
* so an untracked .env cannot be read through it.
|
|
17
|
+
* - Commit and push are manual. The background timer only fetches, and pulls
|
|
18
|
+
* fast-forward when `autosync: pull` is set, the configured branch is the
|
|
19
|
+
* one checked out, and no tracked file is modified.
|
|
20
|
+
* - A diverged branch is reported, never merged or rebased from here.
|
|
21
|
+
*
|
|
22
|
+
* Every git call passes an argument array, so nothing reaches a shell, and
|
|
23
|
+
* every call is async with a timeout: git runs inside the single-threaded
|
|
24
|
+
* inspector, so a network operation must never block the event loop, and a
|
|
25
|
+
* remote asking for credentials must fail instead of waiting forever on a
|
|
26
|
+
* terminal nobody is watching.
|
|
27
|
+
*/
|
|
28
|
+
/** Files that must never be staged, whatever the scope says. */
|
|
29
|
+
/** Paths whose content is never shown or synced, whichever repo they sit in. */
|
|
30
|
+
export declare const DENY: RegExp[];
|
|
1
31
|
export interface GitFile {
|
|
2
32
|
/** Path relative to the repo root, which is what git speaks. */
|
|
3
33
|
path: string;
|
|
@@ -52,8 +82,20 @@ export declare function git(args: string[], cwd: string, timeoutMs?: number, env
|
|
|
52
82
|
export declare function sshCommandFor(repoRoot: string): Promise<string>;
|
|
53
83
|
/** A git call that may touch the network: longer timeout, non-interactive ssh. */
|
|
54
84
|
export declare function gitNet(args: string[], repoRoot: string): Promise<GitResult>;
|
|
85
|
+
/**
|
|
86
|
+
* Pathspecs are globs by default, so a file called `note[1].md` would not
|
|
87
|
+
* match itself. `:(literal)` turns the pattern off.
|
|
88
|
+
*/
|
|
89
|
+
export declare const literal: (p: string) => string;
|
|
55
90
|
/** Repo root for a directory, or null when it is not inside a git repo. */
|
|
56
91
|
export declare function repoRootFor(dir: string): Promise<string | null>;
|
|
92
|
+
/** Label for a porcelain code, using whichever half is set. */
|
|
93
|
+
export declare function label(code: string): string;
|
|
94
|
+
/** Parse `git status --porcelain=v1 -z`, which NUL-separates and doubles up on renames and copies. */
|
|
95
|
+
export declare function parseStatus(out: string): {
|
|
96
|
+
path: string;
|
|
97
|
+
code: string;
|
|
98
|
+
}[];
|
|
57
99
|
/**
|
|
58
100
|
* Everything the git screen renders. Each call shells out to git a handful
|
|
59
101
|
* of times, and the nav badge polls it from every open tab, so results are
|
|
@@ -68,6 +110,15 @@ export declare function gitStatus(fresh?: boolean): Promise<GitStatus>;
|
|
|
68
110
|
* Status never lists ignored files, which covers .gitignore too.
|
|
69
111
|
*/
|
|
70
112
|
export declare function gitDiff(file: string): Promise<GitDiff>;
|
|
113
|
+
/**
|
|
114
|
+
* Unified diff of one path in a repo against HEAD, capped. The caller has
|
|
115
|
+
* checked that the path is a listed, showable change. A repo without
|
|
116
|
+
* commits yet has nothing for `diff HEAD` to compare with; there, as for an
|
|
117
|
+
* untracked file, the whole file is the change. Shared by the workspace
|
|
118
|
+
* diff and the repositories screen so the two never drift.
|
|
119
|
+
*/
|
|
120
|
+
export declare function diffAgainstHead(repoRoot: string, file: string, code: string): Promise<GitDiff>;
|
|
121
|
+
export declare function capDiff(diff: string): GitDiff;
|
|
71
122
|
/** Stage the given repo-relative paths and commit just those. */
|
|
72
123
|
export declare function gitCommit(paths: string[], message: string): Promise<{
|
|
73
124
|
ok: boolean;
|