@netnodeag/kraftwerk 0.48.0 → 0.49.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 (48) hide show
  1. package/README.md +96 -5
  2. package/dist/cli/doctor.js +7 -1
  3. package/dist/cli/init.js +1 -0
  4. package/dist/cli/kraftwerk.js +5 -1
  5. package/dist/cli/projects.d.ts +0 -14
  6. package/dist/cli/projects.js +181 -107
  7. package/dist/cli/repos.js +1 -1
  8. package/dist/cli/ui.js +1 -1
  9. package/dist/cli/vibeables.js +1 -1
  10. package/dist/cli/workspaces.d.ts +16 -0
  11. package/dist/cli/workspaces.js +149 -0
  12. package/dist/config.d.ts +22 -1
  13. package/dist/config.js +19 -3
  14. package/dist/inspector/agents.js +2 -2
  15. package/dist/inspector/channels.d.ts +10 -1
  16. package/dist/inspector/channels.js +11 -2
  17. package/dist/inspector/chat/sessions.d.ts +8 -3
  18. package/dist/inspector/chat/sessions.js +58 -19
  19. package/dist/inspector/chat/types.d.ts +11 -0
  20. package/dist/inspector/git.js +5 -3
  21. package/dist/inspector/instances.d.ts +14 -12
  22. package/dist/inspector/instances.js +71 -31
  23. package/dist/inspector/notifications.js +2 -0
  24. package/dist/inspector/projects.d.ts +167 -0
  25. package/dist/inspector/projects.js +520 -0
  26. package/dist/inspector/search.d.ts +1 -1
  27. package/dist/inspector/search.js +3 -3
  28. package/dist/inspector/server.js +111 -20
  29. package/dist/inspector/settings.d.ts +3 -1
  30. package/dist/inspector/settings.js +14 -3
  31. package/inspector/dist/assets/{dist-Dk5ZcwMb.js → dist-B-8ArbIm.js} +1 -1
  32. package/inspector/dist/assets/{dist-DUq31xBw.js → dist-BFJWVdBm.js} +1 -1
  33. package/inspector/dist/assets/{dist-D6fPO3Jz.js → dist-BIvcKBWo.js} +1 -1
  34. package/inspector/dist/assets/{dist-n8GR1Slo.js → dist-BgYnJawU.js} +1 -1
  35. package/inspector/dist/assets/dist-Bl1ogZZM.js +1 -0
  36. package/inspector/dist/assets/{dist-O97r5Ket.js → dist-BrgwFqUR.js} +1 -1
  37. package/inspector/dist/assets/{dist-CmH9PvLb.js → dist-Bz0bwjja.js} +1 -1
  38. package/inspector/dist/assets/{dist-Dv40dCIn.js → dist-CFvGR87v.js} +1 -1
  39. package/inspector/dist/assets/{dist-Bz3XVWXl.js → dist-CjtxxsZ2.js} +1 -1
  40. package/inspector/dist/assets/{dist-BNGsfwfO.js → dist-QwwTJ2wk.js} +1 -1
  41. package/inspector/dist/assets/{dist-BWkBM49t.js → dist-tfmgozDB.js} +1 -1
  42. package/inspector/dist/assets/{editor-toVFopCO.js → editor-C6m40u-h.js} +3 -3
  43. package/inspector/dist/assets/{index-CC0tY40i.js → index-Cvq-RiV2.js} +11 -11
  44. package/inspector/dist/assets/{index-DQrsXEWt.css → index-DcQwGaV2.css} +1 -1
  45. package/inspector/dist/index.html +2 -2
  46. package/package.json +1 -1
  47. package/schema/kraftwerk.schema.json +18 -0
  48. 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
- * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
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
- * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
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 `vibeables` share one shape: { enabled?, root? }. The root
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
  */
@@ -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 { syncProjectAgents } from "./instances.js";
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) => syncProjectAgents(p.root, list.filter((a) => !a.archived).map(toSummary)))
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 { syncProjectChannels } from "./instances.js";
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) => syncProjectChannels(p.root, list.map(toChannelSummary)))
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" to an agent session: the chat becomes the channel's
48
- * transcript. The agent keeps its process and memory — it has seen every
49
- * message so far — and learns the channel rules with its next prompt.
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
- return createChat({ agent: "claude", scope: { kind: "channel", slug: channel.slug }, title: channel.name });
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" to an agent session: the chat becomes the channel's
951
- * transcript. The agent keeps its process and memory — it has seen every
952
- * message so far — and learns the channel rules with its next prompt.
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
- const main = state.seats.get(MAIN);
966
- state.seats.delete(MAIN);
967
- const seat = main ?? newSeat(scope.slug, state.meta.agent);
968
- seat.key = scope.slug;
969
- seat.needsContext = true;
970
- seat.seenSeq = state.events[state.events.length - 1]?.seq ?? 0;
971
- state.seats.set(scope.slug, seat);
972
- if (state.meta.sessions?.[MAIN]) {
973
- const { [MAIN]: session, ...rest } = state.meta.sessions;
974
- state.meta.sessions = { ...rest, [scope.slug]: session };
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
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { reposRootFor, resolveProject, vibeablesRootFor, isSafeGitName } from "../config.js";
4
+ import { projectsRootFor, reposRootFor, resolveProject, vibeablesRootFor, isSafeGitName } from "../config.js";
5
5
  import { ENV_FILE } from "../runner/docker.js";
6
6
  import { getOutputDir, getProjectRoot } from "./context.js";
7
7
  /**
@@ -203,7 +203,7 @@ async function checkedOut(repo) {
203
203
  }
204
204
  /**
205
205
  * The roots that may be committed, as repo-relative paths: the workflows,
206
- * knowledge, agents, skills, channels and vibeables roots this project
206
+ * knowledge, agents, skills, channels, vibeables and projects roots this project
207
207
  * declares, plus kraftwerk.yml and the project README. The output directory
208
208
  * is excluded even when it lives inside one of them.
209
209
  */
@@ -234,6 +234,8 @@ function scopeFor({ project, repoRoot }) {
234
234
  path.resolve(project.root, "channels"),
235
235
  // Vibeables are the workspace's own apps: versioned with it, unlike clones.
236
236
  vibeablesRootFor(project),
237
+ // Projects too: briefs, state and logs are the workspace's own record.
238
+ projectsRootFor(project),
237
239
  project.configPath,
238
240
  path.resolve(project.root, "README.md"),
239
241
  ];
@@ -411,7 +413,7 @@ export async function gitStatus(fresh = false) {
411
413
  const blocked = all.filter((f) => !f.syncable).sort(byPath);
412
414
  const blockedHidden = Math.max(0, blocked.length - MAX_BLOCKED);
413
415
  const scopeError = scope.wholeRepo
414
- ? "A configured root points at the repository root, which would put the whole repo in scope. Narrow workflows/knowledge/agents/skills/vibeables in kraftwerk.yml."
416
+ ? "A configured root points at the repository root, which would put the whole repo in scope. Narrow workflows/knowledge/agents/skills/vibeables/projects in kraftwerk.yml."
415
417
  : undefined;
416
418
  const value = {
417
419
  ...base,
@@ -1,7 +1,7 @@
1
1
  import type { AgentSummary } from "./agents.js";
2
2
  import type { ChannelSummary } from "./channels.js";
3
3
  /** Durable per-project record. The root is the key; everything else derives from it. */
4
- export interface ProjectRecord {
4
+ export interface WorkspaceRecord {
5
5
  root: string;
6
6
  firstSeen: string;
7
7
  lastStarted: string;
@@ -29,7 +29,7 @@ export interface DiscoveredInstance {
29
29
  }
30
30
  /**
31
31
  * One workspace as the switcher shows it: a known project (running or
32
- * not) or a running instance the projects registry doesn't know yet.
32
+ * not) or a running instance the workspace registry doesn't know yet.
33
33
  */
34
34
  export interface WorkspaceEntry {
35
35
  name: string;
@@ -49,6 +49,8 @@ export interface WorkspaceEntry {
49
49
  lastStarted?: string;
50
50
  lastStopped?: string;
51
51
  }
52
+ /** File name of a root's record: a hash of the absolute root, the same under the old and the new directory. */
53
+ export declare const workspaceRecordName: (root: string) => string;
52
54
  /** ~/… for display. */
53
55
  export declare const tildify: (p: string) => string;
54
56
  /** Write this instance's registry file (call once the server listens). */
@@ -60,15 +62,15 @@ export declare function unregisterInstance(): void;
60
62
  /** All other running instances, verified live. Cached briefly — /api/meta is polled. */
61
63
  export declare function discoverInstances(): Promise<DiscoveredInstance[]>;
62
64
  /** Upsert the project record for a root (call on every inspector start). */
63
- export declare function registerProject(root: string): Promise<void>;
64
- export declare const syncProjectAgents: (root: string, agents: AgentSummary[]) => Promise<void>;
65
- export declare const syncProjectChannels: (root: string, channels: ChannelSummary[]) => Promise<void>;
65
+ export declare function registerWorkspace(root: string): Promise<void>;
66
+ export declare const syncWorkspaceAgents: (root: string, agents: AgentSummary[]) => Promise<void>;
67
+ export declare const syncWorkspaceChannels: (root: string, channels: ChannelSummary[]) => Promise<void>;
66
68
  /** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
67
- export declare function markProjectStopped(): void;
68
- /** All known projects, most recently started first. */
69
- export declare function listProjects(): Promise<ProjectRecord[]>;
70
- /** Drop a project record (the root itself is untouched). */
71
- export declare function forgetProject(root: string): Promise<boolean>;
69
+ export declare function markWorkspaceStopped(): void;
70
+ /** All known workspaces, most recently started first. */
71
+ export declare function listWorkspaceRecords(): Promise<WorkspaceRecord[]>;
72
+ /** Drop a workspace record (the root itself is untouched) — from the legacy directory too, see migrateRegistry. */
73
+ export declare function forgetWorkspace(root: string): Promise<boolean>;
72
74
  /**
73
75
  * The switcher's view: every known project joined with the live instances,
74
76
  * plus running instances with no project record yet. The calling instance
@@ -118,14 +120,14 @@ export interface StartResult {
118
120
  * Waits up to ~5s for the new inspector to answer so callers can link
119
121
  * straight to it.
120
122
  */
121
- export declare function startProject(root: string): Promise<StartResult>;
123
+ export declare function startWorkspace(root: string): Promise<StartResult>;
122
124
  /**
123
125
  * Stop a running workspace: SIGTERM to its server process, which
124
126
  * unregisters, stamps lastStopped and exits; the `kraftwerk ui` supervisor
125
127
  * follows. Matched by root, or by url for instances from older versions
126
128
  * that registered no root. Waits up to ~5s for the port to go quiet.
127
129
  */
128
- export declare function stopProject(target: {
130
+ export declare function stopWorkspace(target: {
129
131
  root?: string;
130
132
  url?: string;
131
133
  }): Promise<{