@netnodeag/kraftwerk 0.47.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.
- package/README.md +106 -5
- package/dist/cli/doctor.js +18 -1
- package/dist/cli/init.js +10 -8
- package/dist/cli/kraftwerk.js +16 -2
- 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/dotenv.d.ts +40 -0
- package/dist/dotenv.js +84 -0
- 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.js +5 -3
- 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/search.d.ts +1 -1
- package/dist/inspector/search.js +3 -3
- package/dist/inspector/server.js +111 -20
- package/dist/inspector/settings.d.ts +3 -1
- package/dist/inspector/settings.js +14 -3
- package/inspector/dist/assets/{dist-D8pjVMTl.js → dist-B-8ArbIm.js} +1 -1
- package/inspector/dist/assets/{dist-DfAFwiGQ.js → dist-BFJWVdBm.js} +1 -1
- package/inspector/dist/assets/{dist-C2Mu5bT-.js → dist-BIvcKBWo.js} +1 -1
- package/inspector/dist/assets/{dist-CdyUhzAt.js → dist-BgYnJawU.js} +1 -1
- package/inspector/dist/assets/dist-Bl1ogZZM.js +1 -0
- package/inspector/dist/assets/{dist-BNUGzZS7.js → dist-BrgwFqUR.js} +1 -1
- package/inspector/dist/assets/{dist-DnmDnMK2.js → dist-Bz0bwjja.js} +1 -1
- package/inspector/dist/assets/{dist-CjMETDfE.js → dist-CFvGR87v.js} +1 -1
- package/inspector/dist/assets/{dist-CUeUDZ9j.js → dist-CjtxxsZ2.js} +1 -1
- package/inspector/dist/assets/{dist-CmOlZvwa.js → dist-QwwTJ2wk.js} +1 -1
- package/inspector/dist/assets/{dist-BOQhQPN9.js → dist-tfmgozDB.js} +1 -1
- package/inspector/dist/assets/{editor-DJbeG376.js → editor-C6m40u-h.js} +3 -3
- package/inspector/dist/assets/{index-DgEUOA11.js → index-Cvq-RiV2.js} +14 -14
- package/inspector/dist/assets/{index-DQrsXEWt.css → index-DcQwGaV2.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-rrIMDK_-.js +0 -1
package/dist/dotenv.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
|
|
3
|
+
* this process at CLI start (a commander preAction hook in kraftwerk.ts),
|
|
4
|
+
* so every kraftwerk process sees them and everything it spawns inherits
|
|
5
|
+
* them — the inspector, the chat agents and routines, workflow runs, the
|
|
6
|
+
* tunnel (TUNNEL_TOKEN), `requires:` checks.
|
|
7
|
+
*
|
|
8
|
+
* A variable already set in the shell wins over the file, the dotenv
|
|
9
|
+
* convention, with one exception that makes restarts work: a value this
|
|
10
|
+
* process inherited from an earlier kraftwerk process that itself took it
|
|
11
|
+
* from a `.env` is not a shell setting. Those keys are listed in
|
|
12
|
+
* KRAFTWERK_DOTENV_KEYS by whoever injected them, so a restarted server
|
|
13
|
+
* (the `kraftwerk ui` supervisor respawns it with its own environment)
|
|
14
|
+
* re-reads the file and applies changed values, and a project launched
|
|
15
|
+
* from another workspace's inspector drops that workspace's variables
|
|
16
|
+
* instead of running with them.
|
|
17
|
+
*
|
|
18
|
+
* Never synced: `.env` is on the workspace git's deny list (git.ts) and in
|
|
19
|
+
* the .gitignore `kraftwerk init` writes.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DOTENV_FILE = ".env";
|
|
22
|
+
export declare const DOTENV_MARKER = "KRAFTWERK_DOTENV_KEYS";
|
|
23
|
+
/** Parse dotenv text: comments, `export ` prefixes, single/double quotes, `\n` in double quotes. */
|
|
24
|
+
export declare function parseDotenv(text: string): Record<string, string>;
|
|
25
|
+
export interface DotenvResult {
|
|
26
|
+
/** Absolute path of the file, undefined when the project has none. */
|
|
27
|
+
file?: string;
|
|
28
|
+
/** Keys set from the file. */
|
|
29
|
+
applied: string[];
|
|
30
|
+
/** Keys in the file left alone because the shell had set them. */
|
|
31
|
+
kept: string[];
|
|
32
|
+
/** Keys an earlier kraftwerk process had injected that this file no longer has. */
|
|
33
|
+
removed: string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Apply `<root>/.env` to process.env (see the module comment for the
|
|
37
|
+
* precedence). Idempotent: a second call re-applies the same keys. Without
|
|
38
|
+
* a file it still drops keys an earlier process injected.
|
|
39
|
+
*/
|
|
40
|
+
export declare function applyDotenv(root: string): Promise<DotenvResult>;
|
package/dist/dotenv.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The project's `.env`: KEY=VALUE lines next to kraftwerk.yml, loaded into
|
|
5
|
+
* this process at CLI start (a commander preAction hook in kraftwerk.ts),
|
|
6
|
+
* so every kraftwerk process sees them and everything it spawns inherits
|
|
7
|
+
* them — the inspector, the chat agents and routines, workflow runs, the
|
|
8
|
+
* tunnel (TUNNEL_TOKEN), `requires:` checks.
|
|
9
|
+
*
|
|
10
|
+
* A variable already set in the shell wins over the file, the dotenv
|
|
11
|
+
* convention, with one exception that makes restarts work: a value this
|
|
12
|
+
* process inherited from an earlier kraftwerk process that itself took it
|
|
13
|
+
* from a `.env` is not a shell setting. Those keys are listed in
|
|
14
|
+
* KRAFTWERK_DOTENV_KEYS by whoever injected them, so a restarted server
|
|
15
|
+
* (the `kraftwerk ui` supervisor respawns it with its own environment)
|
|
16
|
+
* re-reads the file and applies changed values, and a project launched
|
|
17
|
+
* from another workspace's inspector drops that workspace's variables
|
|
18
|
+
* instead of running with them.
|
|
19
|
+
*
|
|
20
|
+
* Never synced: `.env` is on the workspace git's deny list (git.ts) and in
|
|
21
|
+
* the .gitignore `kraftwerk init` writes.
|
|
22
|
+
*/
|
|
23
|
+
export const DOTENV_FILE = ".env";
|
|
24
|
+
export const DOTENV_MARKER = "KRAFTWERK_DOTENV_KEYS";
|
|
25
|
+
const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
26
|
+
/** Parse dotenv text: comments, `export ` prefixes, single/double quotes, `\n` in double quotes. */
|
|
27
|
+
export function parseDotenv(text) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line || line.startsWith("#"))
|
|
32
|
+
continue;
|
|
33
|
+
const eq = line.indexOf("=");
|
|
34
|
+
if (eq < 0)
|
|
35
|
+
continue;
|
|
36
|
+
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
|
37
|
+
if (!KEY.test(key))
|
|
38
|
+
continue;
|
|
39
|
+
let value = line.slice(eq + 1).trim();
|
|
40
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
41
|
+
value = value.slice(1, -1).replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
42
|
+
}
|
|
43
|
+
else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
44
|
+
value = value.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// An unquoted value ends at a comment: `PORT=1981 # inspector`.
|
|
48
|
+
value = value.replace(/\s+#.*$/, "").trim();
|
|
49
|
+
}
|
|
50
|
+
out[key] = value;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Apply `<root>/.env` to process.env (see the module comment for the
|
|
56
|
+
* precedence). Idempotent: a second call re-applies the same keys. Without
|
|
57
|
+
* a file it still drops keys an earlier process injected.
|
|
58
|
+
*/
|
|
59
|
+
export async function applyDotenv(root) {
|
|
60
|
+
const file = path.join(root, DOTENV_FILE);
|
|
61
|
+
const text = await readFile(file, "utf8").catch(() => undefined);
|
|
62
|
+
const parsed = text === undefined ? {} : parseDotenv(text);
|
|
63
|
+
const injected = new Set((process.env[DOTENV_MARKER] ?? "").split(",").filter(Boolean));
|
|
64
|
+
const applied = [];
|
|
65
|
+
const kept = [];
|
|
66
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
67
|
+
// An empty shell value is as good as unset (`requires:` treats it so too).
|
|
68
|
+
if (!process.env[key] || injected.has(key)) {
|
|
69
|
+
process.env[key] = value;
|
|
70
|
+
applied.push(key);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
kept.push(key);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const removed = [...injected].filter((key) => !(key in parsed));
|
|
77
|
+
for (const key of removed)
|
|
78
|
+
delete process.env[key];
|
|
79
|
+
if (applied.length > 0)
|
|
80
|
+
process.env[DOTENV_MARKER] = applied.join(",");
|
|
81
|
+
else
|
|
82
|
+
delete process.env[DOTENV_MARKER];
|
|
83
|
+
return { file: text === undefined ? undefined : file, applied, kept, removed };
|
|
84
|
+
}
|
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.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
64
|
-
export declare const
|
|
65
|
-
export declare const
|
|
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
|
|
68
|
-
/** All known
|
|
69
|
-
export declare function
|
|
70
|
-
/** Drop a
|
|
71
|
-
export declare function
|
|
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
|
|
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
|
|
130
|
+
export declare function stopWorkspace(target: {
|
|
129
131
|
root?: string;
|
|
130
132
|
url?: string;
|
|
131
133
|
}): Promise<{
|