@netnodeag/kraftwerk 0.34.2 → 0.36.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 +40 -19
- package/dist/cli/init.js +3 -3
- package/dist/cli/routines.js +9 -9
- package/dist/config.d.ts +26 -2
- package/dist/config.js +43 -3
- package/dist/inspector/{team.d.ts → agents.d.ts} +21 -12
- package/dist/inspector/{team.js → agents.js} +34 -19
- package/dist/inspector/chat/backend.d.ts +1 -1
- package/dist/inspector/chat/sessions.js +43 -43
- package/dist/inspector/chat/store.js +13 -1
- package/dist/inspector/chat/types.d.ts +2 -2
- package/dist/inspector/git.d.ts +83 -0
- package/dist/inspector/git.js +651 -0
- package/dist/inspector/instances.d.ts +12 -0
- package/dist/inspector/instances.js +37 -1
- package/dist/inspector/routines.d.ts +4 -4
- package/dist/inspector/routines.js +11 -11
- package/dist/inspector/search.d.ts +25 -0
- package/dist/inspector/search.js +31 -0
- package/dist/inspector/server.js +166 -32
- package/dist/inspector/settings.d.ts +11 -1
- package/dist/inspector/settings.js +45 -0
- package/dist/inspector/skills.d.ts +6 -6
- package/dist/inspector/skills.js +8 -8
- package/dist/runner/docker.d.ts +1 -0
- package/dist/runner/docker.js +1 -1
- package/inspector/dist/assets/{editor-CXrDJOv_.js → editor-7ImnDC3D.js} +3 -3
- package/inspector/dist/assets/{index-BxEG-IdY.js → index--2I7vpcS.js} +1 -1
- package/inspector/dist/assets/index-B2ThRS2S.js +217 -0
- package/inspector/dist/assets/index-BA7nx4V5.css +1 -0
- package/inspector/dist/assets/{index-Bumwg9SM.js → index-BC7aIJ-h.js} +1 -1
- package/inspector/dist/assets/{index-BULB2rC_.js → index-BIXYBiw7.js} +1 -1
- package/inspector/dist/assets/{index-C3hpUVfp.js → index-BZeYO5wT.js} +1 -1
- package/inspector/dist/assets/{index-BQMgADlc.js → index-B_CIhKa4.js} +1 -1
- package/inspector/dist/assets/{index-DrSWAGft.js → index-BuRajvjQ.js} +1 -1
- package/inspector/dist/assets/{index-BNcXQYbv.js → index-C3Ob1O7W.js} +1 -1
- package/inspector/dist/assets/{index-BIDsfw8C.js → index-CIjv7yxE.js} +1 -1
- package/inspector/dist/assets/{index-ChuqpBnv.js → index-CRAwmMfk.js} +1 -1
- package/inspector/dist/assets/{index-x5G6Np7T.js → index-Ct_jZSyD.js} +1 -1
- package/inspector/dist/assets/{index-7J4zAS6J.js → index-DKrBjjGQ.js} +1 -1
- package/inspector/dist/assets/{index-CUCCMUMY.js → index-DL_bJiOj.js} +1 -1
- package/inspector/dist/assets/{index-DAi1VtJ8.js → index-DPBAv_kj.js} +1 -1
- package/inspector/dist/assets/{index-Upf-BEsx.js → index-DbmSSHvr.js} +1 -1
- package/inspector/dist/assets/{index-CNJyAniG.js → index-DjRBLY9N.js} +1 -1
- package/inspector/dist/assets/{index-CszaAR9T.js → index-YZtBJhWD.js} +1 -1
- package/inspector/dist/assets/{index-CMMYSoL3.js → index-fWUFhIGR.js} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +5 -2
- package/schema/kraftwerk.schema.json +35 -1
- package/inspector/dist/assets/index-B7rwNdVj.css +0 -1
- package/inspector/dist/assets/index-BBGjdXk1.js +0 -213
|
@@ -20,6 +20,9 @@ import { selfCommand } from "./self-command.js";
|
|
|
20
20
|
* the root's kraftwerk.yml at read time, so it is always current. A
|
|
21
21
|
* project that is not running can be started again from the switcher or
|
|
22
22
|
* `kraftwerk projects start` — that is what makes a killed UI findable.
|
|
23
|
+
* The record also carries the project's agent roster (written whenever the
|
|
24
|
+
* instance reads it), so the ⌘K palette can list every workspace's agents
|
|
25
|
+
* from a handful of small files without probing anything.
|
|
23
26
|
*/
|
|
24
27
|
const HOME = path.join(os.homedir(), ".kraftwerk");
|
|
25
28
|
const INSTANCES_DIR = path.join(HOME, "instances");
|
|
@@ -47,6 +50,8 @@ export async function registerInstance(port, root) {
|
|
|
47
50
|
}
|
|
48
51
|
catch { } // best-effort — without it discovery just won't see us
|
|
49
52
|
}
|
|
53
|
+
/** Where this instance answers, as the switcher links it (null before listen). */
|
|
54
|
+
export const currentInstanceUrl = () => (selfPort ? `http://localhost:${selfPort}` : null);
|
|
50
55
|
/** Remove this instance's registry file. Sync so exit handlers can call it. */
|
|
51
56
|
export function unregisterInstance() {
|
|
52
57
|
try {
|
|
@@ -60,8 +65,16 @@ export function unregisterInstance() {
|
|
|
60
65
|
* each other's /api/meta must not recurse.
|
|
61
66
|
*/
|
|
62
67
|
async function probe(port) {
|
|
68
|
+
// `listen(port, "localhost")` binds one address family, whichever the
|
|
69
|
+
// resolver returns first (::1 on this platform), so an instance started
|
|
70
|
+
// with KRAFTWERK_UI_HOST=localhost or ::1 is not on 127.0.0.1. A refused
|
|
71
|
+
// connection returns at once, so the second try costs nothing when the
|
|
72
|
+
// first one answers.
|
|
73
|
+
return (await probeAt("127.0.0.1", port)) ?? (await probeAt("[::1]", port));
|
|
74
|
+
}
|
|
75
|
+
async function probeAt(host, port) {
|
|
63
76
|
try {
|
|
64
|
-
const r = await fetch(`http
|
|
77
|
+
const r = await fetch(`http://${host}:${port}/api/meta?probe=1`, {
|
|
65
78
|
signal: AbortSignal.timeout(400),
|
|
66
79
|
});
|
|
67
80
|
if (!r.ok)
|
|
@@ -129,6 +142,7 @@ async function readProject(file) {
|
|
|
129
142
|
lastStarted: rec.lastStarted ?? rec.firstSeen ?? "",
|
|
130
143
|
lastStopped: rec.lastStopped,
|
|
131
144
|
startCount: rec.startCount ?? 1,
|
|
145
|
+
...(Array.isArray(rec.agents) ? { agents: rec.agents } : {}),
|
|
132
146
|
};
|
|
133
147
|
}
|
|
134
148
|
catch {
|
|
@@ -147,11 +161,33 @@ export async function registerProject(root) {
|
|
|
147
161
|
firstSeen: prev?.firstSeen || now,
|
|
148
162
|
lastStarted: now,
|
|
149
163
|
startCount: (prev?.startCount ?? 0) + 1,
|
|
164
|
+
...(prev?.agents ? { agents: prev.agents } : {}),
|
|
150
165
|
};
|
|
151
166
|
await fs.writeFile(projectFile(abs), JSON.stringify(rec, null, 2));
|
|
152
167
|
}
|
|
153
168
|
catch { } // best-effort, like the instance file
|
|
154
169
|
}
|
|
170
|
+
let syncedAgents = "";
|
|
171
|
+
/**
|
|
172
|
+
* Write the roster into the project's record. Called on every roster read
|
|
173
|
+
* (the UI polls it, routines tick it), so a record only changes when the
|
|
174
|
+
* roster did. Roots with no record (a CLI run in a project that never
|
|
175
|
+
* started the inspector) are left alone.
|
|
176
|
+
*/
|
|
177
|
+
export async function syncProjectAgents(root, agents) {
|
|
178
|
+
const key = `${root}\n${JSON.stringify(agents)}`;
|
|
179
|
+
if (key === syncedAgents)
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
const file = projectFile(root);
|
|
183
|
+
const rec = await readProject(file);
|
|
184
|
+
if (!rec)
|
|
185
|
+
return;
|
|
186
|
+
await fs.writeFile(file, JSON.stringify({ ...rec, agents }, null, 2));
|
|
187
|
+
syncedAgents = key;
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
}
|
|
155
191
|
/** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
|
|
156
192
|
export function markProjectStopped() {
|
|
157
193
|
if (!selfRoot)
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Routines: per-
|
|
3
|
-
* weekday at 9". Definitions live next to the
|
|
2
|
+
* Routines: per-agent scheduled prompts — "message your agent every
|
|
3
|
+
* weekday at 9". Definitions live next to the agent (git-tracked):
|
|
4
4
|
*
|
|
5
5
|
* agents/<slug>/routines.yml # list of { id, name, schedule, prompt, enabled }
|
|
6
6
|
*
|
|
7
7
|
* Runtime state (last run / last session / errors) is run-state, not
|
|
8
8
|
* config, so it lives in <output>/routines-state.json. The scheduler is a
|
|
9
9
|
* plain interval inside the inspector server: each due routine opens a new
|
|
10
|
-
* session (chat) for the
|
|
11
|
-
* the
|
|
10
|
+
* session (chat) for the agent and posts the prompt — the run shows up in
|
|
11
|
+
* the agent's sessions sidebar like any conversation. Schedules use
|
|
12
12
|
* standard 5-field cron in the server's local time.
|
|
13
13
|
*/
|
|
14
14
|
export interface Routine {
|
|
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse, stringify } from "yaml";
|
|
4
4
|
import { getOutputDir } from "./context.js";
|
|
5
|
-
import {
|
|
5
|
+
import { getAgent, listAgents, safeAgentSlug, agentsRoot } from "./agents.js";
|
|
6
6
|
import { createChat, postMessage } from "./chat/sessions.js";
|
|
7
7
|
/* ---------- cron ---------- */
|
|
8
8
|
const ALIASES = {
|
|
@@ -86,7 +86,7 @@ export function nextRun(schedule, from = new Date()) {
|
|
|
86
86
|
}
|
|
87
87
|
/* ---------- definition CRUD (agents/<slug>/routines.yml) ---------- */
|
|
88
88
|
async function routinesFile(slug) {
|
|
89
|
-
return path.join(await
|
|
89
|
+
return path.join(await agentsRoot(), safeAgentSlug(slug), "routines.yml");
|
|
90
90
|
}
|
|
91
91
|
function normalizeRoutine(raw) {
|
|
92
92
|
const id = String(raw.id ?? "").trim();
|
|
@@ -179,14 +179,14 @@ export async function routineStatuses(slug) {
|
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
181
|
/* ---------- firing ---------- */
|
|
182
|
-
/** Open a fresh session for the
|
|
182
|
+
/** Open a fresh session for the agent and post the routine's prompt. */
|
|
183
183
|
async function fireRoutine(slug, routine) {
|
|
184
|
-
const
|
|
185
|
-
if (!
|
|
186
|
-
throw new Error(`
|
|
184
|
+
const def = await getAgent(slug);
|
|
185
|
+
if (!def)
|
|
186
|
+
throw new Error(`agent "${slug}" not found`);
|
|
187
187
|
const meta = await createChat({
|
|
188
|
-
agent:
|
|
189
|
-
scope: { kind: "
|
|
188
|
+
agent: def.harness,
|
|
189
|
+
scope: { kind: "agent", slug: slug, routine: routine.id },
|
|
190
190
|
title: `⏰ ${routine.name}`,
|
|
191
191
|
});
|
|
192
192
|
const { error } = await postMessage(meta.id, routine.prompt);
|
|
@@ -219,16 +219,16 @@ async function tick() {
|
|
|
219
219
|
const now = new Date();
|
|
220
220
|
const p = (n) => String(n).padStart(2, "0");
|
|
221
221
|
const minuteKey = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}:${p(now.getMinutes())}`;
|
|
222
|
-
let
|
|
222
|
+
let agents;
|
|
223
223
|
try {
|
|
224
|
-
|
|
224
|
+
agents = await listAgents();
|
|
225
225
|
}
|
|
226
226
|
catch {
|
|
227
227
|
return;
|
|
228
228
|
}
|
|
229
229
|
const state = await readState();
|
|
230
230
|
let dirty = false;
|
|
231
|
-
for (const m of
|
|
231
|
+
for (const m of agents) {
|
|
232
232
|
for (const r of await listRoutines(m.slug).catch(() => [])) {
|
|
233
233
|
if (!r.enabled)
|
|
234
234
|
continue;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type AgentSummary } from "./agents.js";
|
|
2
|
+
/**
|
|
3
|
+
* The ⌘K palette: every active agent in every workspace this machine
|
|
4
|
+
* knows, so a user can jump to one without switching workspaces first.
|
|
5
|
+
* This instance reads its own roster from disk; the others come from the
|
|
6
|
+
* project registry, where each instance records its roster whenever it
|
|
7
|
+
* reads it (see instances.ts). Stopped workspaces are listed too — the
|
|
8
|
+
* palette starts them on the way to the agent.
|
|
9
|
+
*/
|
|
10
|
+
export interface WorkspaceAgents {
|
|
11
|
+
name: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
/** Where the workspace's UI lives — hits link to `${url}/#/agents/<slug>`. */
|
|
14
|
+
url: string;
|
|
15
|
+
/** Project root (what /api/projects/start takes for a stopped workspace). */
|
|
16
|
+
root?: string;
|
|
17
|
+
/** The instance answering this request: hits navigate in place. */
|
|
18
|
+
current: boolean;
|
|
19
|
+
live: boolean;
|
|
20
|
+
agents: AgentSummary[];
|
|
21
|
+
}
|
|
22
|
+
export interface AgentSearch {
|
|
23
|
+
workspaces: WorkspaceAgents[];
|
|
24
|
+
}
|
|
25
|
+
export declare function searchAgents(): Promise<AgentSearch>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { resolveProject } from "../config.js";
|
|
3
|
+
import { getProjectRoot } from "./context.js";
|
|
4
|
+
import { listAgents, toSummary } from "./agents.js";
|
|
5
|
+
import { currentInstanceUrl, discoverWorkspaces, listProjects } from "./instances.js";
|
|
6
|
+
export async function searchAgents() {
|
|
7
|
+
const project = await resolveProject(getProjectRoot()).catch(() => null);
|
|
8
|
+
const self = {
|
|
9
|
+
name: project?.config.name ?? path.basename(project?.root ?? getProjectRoot()),
|
|
10
|
+
icon: project?.config.icon || undefined,
|
|
11
|
+
url: currentInstanceUrl() ?? "",
|
|
12
|
+
root: project?.root ?? getProjectRoot(),
|
|
13
|
+
current: true,
|
|
14
|
+
live: true,
|
|
15
|
+
agents: (await listAgents().catch(() => [])).filter((a) => !a.archived).map(toSummary),
|
|
16
|
+
};
|
|
17
|
+
const [workspaces, projects] = await Promise.all([discoverWorkspaces(), listProjects()]);
|
|
18
|
+
const rosters = new Map(projects.map((p) => [p.root, p.agents ?? []]));
|
|
19
|
+
const others = workspaces
|
|
20
|
+
.filter((w) => w.root && w.exists !== false)
|
|
21
|
+
.map((w) => ({
|
|
22
|
+
name: w.name,
|
|
23
|
+
icon: w.icon,
|
|
24
|
+
url: w.url,
|
|
25
|
+
root: w.root,
|
|
26
|
+
current: false,
|
|
27
|
+
live: w.live,
|
|
28
|
+
agents: rosters.get(w.root) ?? [],
|
|
29
|
+
}));
|
|
30
|
+
return { workspaces: [self, ...others] };
|
|
31
|
+
}
|
package/dist/inspector/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { promises as fs } from "node:fs";
|
|
1
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { setOutputDir, setProjectRoot, getOutputDir, getProjectRoot } from "./context.js";
|
|
@@ -8,10 +8,12 @@ import { listWorkflows, getWorkflow } from "./workflows.js";
|
|
|
8
8
|
import { dockerStatus, triggerRun, stopRun } from "./runner.js";
|
|
9
9
|
import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, subscribeChat, } from "./chat/sessions.js";
|
|
10
10
|
import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
|
|
11
|
-
import {
|
|
11
|
+
import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
|
|
12
12
|
import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
|
|
13
13
|
import { discoverWorkspaces, forgetProject, listWorkspacesDetailed, markProjectStopped, registerInstance, registerProject, startProject, stopProject, tildify, unregisterInstance, } from "./instances.js";
|
|
14
|
+
import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
|
|
14
15
|
import { getSettings, saveSettings } from "./settings.js";
|
|
16
|
+
import { searchAgents } from "./search.js";
|
|
15
17
|
import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
|
|
16
18
|
/**
|
|
17
19
|
* The inspector server: a plain node:http server with no dependencies.
|
|
@@ -57,6 +59,59 @@ function readBody(req) {
|
|
|
57
59
|
req.on("error", reject);
|
|
58
60
|
});
|
|
59
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Interface to bind. Loopback by default: the UI is unauthenticated and its
|
|
64
|
+
* chat runs coding agents against the repo, so it must not appear on the LAN
|
|
65
|
+
* because someone started it on a laptop in a café. Inside a container the
|
|
66
|
+
* default flips to all interfaces — loopback there would make the published
|
|
67
|
+
* port unreachable while the in-container health check keeps passing, and
|
|
68
|
+
* the port mapping (compose publishes to 127.0.0.1) is the boundary anyway.
|
|
69
|
+
* KRAFTWERK_UI_HOST overrides either way.
|
|
70
|
+
*/
|
|
71
|
+
const IN_CONTAINER = existsSync("/.dockerenv") || existsSync("/run/.containerenv");
|
|
72
|
+
const INSPECTOR_HOST = process.env.KRAFTWERK_UI_HOST || (IN_CONTAINER ? "0.0.0.0" : "127.0.0.1");
|
|
73
|
+
const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
74
|
+
const LOOPBACK_BIND = LOOPBACK_NAMES.has(INSPECTOR_HOST);
|
|
75
|
+
/**
|
|
76
|
+
* Whether the Host header names this server. A loopback bind alone does not
|
|
77
|
+
* keep other sites out: a page on evil.example can re-point that name at
|
|
78
|
+
* 127.0.0.1 after it loaded (DNS rebinding) and then talk to us as if it
|
|
79
|
+
* were same-origin — every check that compares Origin to Host passes,
|
|
80
|
+
* because both say evil.example. So while bound to loopback, only loopback
|
|
81
|
+
* names are served. Bound elsewhere (a container behind a proxy) the name
|
|
82
|
+
* is whatever the proxy forwards, and the proxy is the boundary.
|
|
83
|
+
*
|
|
84
|
+
* A reverse proxy on the same machine talks to the loopback bind with the
|
|
85
|
+
* browser's Host, which is not a loopback name. It says so in
|
|
86
|
+
* X-Forwarded-Host, and that header cannot come from a rebinding page: a
|
|
87
|
+
* browser only sends it after a CORS preflight, which this server never
|
|
88
|
+
* answers, and a form post cannot set headers at all.
|
|
89
|
+
*/
|
|
90
|
+
function hostAllowed(req) {
|
|
91
|
+
if (!LOOPBACK_BIND)
|
|
92
|
+
return true;
|
|
93
|
+
if (forwardedHost(req))
|
|
94
|
+
return true;
|
|
95
|
+
const host = req.headers.host;
|
|
96
|
+
if (!host)
|
|
97
|
+
return true;
|
|
98
|
+
const name = hostnameOf(host);
|
|
99
|
+
return LOOPBACK_NAMES.has(name) || name.endsWith(".localhost");
|
|
100
|
+
}
|
|
101
|
+
/** First X-Forwarded-Host value, or undefined when no proxy set one. */
|
|
102
|
+
function forwardedHost(req) {
|
|
103
|
+
const forwarded = req.headers["x-forwarded-host"];
|
|
104
|
+
return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0].trim() || undefined;
|
|
105
|
+
}
|
|
106
|
+
/** "localhost:1981" → "localhost", "[::1]:1981" → "[::1]"; "" when unparsable. */
|
|
107
|
+
function hostnameOf(host) {
|
|
108
|
+
try {
|
|
109
|
+
return new URL(`http://${host}`).hostname;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
60
115
|
/** Exit code the `kraftwerk ui` supervisor treats as "relaunch me". */
|
|
61
116
|
export const RESTART_EXIT_CODE = 75;
|
|
62
117
|
/** Whether a supervisor is around to respawn us (set by `kraftwerk ui`). */
|
|
@@ -87,9 +142,48 @@ async function getPkgName() {
|
|
|
87
142
|
return "";
|
|
88
143
|
}
|
|
89
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Whether a state-changing request came from this UI rather than from some
|
|
147
|
+
* other page the user happens to have open. The inspector has no login of
|
|
148
|
+
* its own: every POST here writes files, commits, pushes, or starts a coding
|
|
149
|
+
* agent, and a cross-site form post needs no preflight to reach us. Browsers
|
|
150
|
+
* attach Origin to every POST/PUT/DELETE, form submissions included, so a
|
|
151
|
+
* mismatch is a forgery. A missing Origin is a non-browser client (curl, a
|
|
152
|
+
* script), which was never the attack this guards against.
|
|
153
|
+
*/
|
|
154
|
+
function sameOrigin(req) {
|
|
155
|
+
if (req.headers["sec-fetch-site"] === "cross-site")
|
|
156
|
+
return false;
|
|
157
|
+
const origin = req.headers.origin;
|
|
158
|
+
if (origin === undefined)
|
|
159
|
+
return true;
|
|
160
|
+
// A sandboxed iframe or a redirected form sends "null", which matches no host.
|
|
161
|
+
if (origin === "null")
|
|
162
|
+
return false;
|
|
163
|
+
// A reverse proxy that rewrites Host to the upstream (nginx by default)
|
|
164
|
+
// says who the browser addressed in X-Forwarded-Host. Trusting it is safe
|
|
165
|
+
// here: a browser cannot set that header without a CORS preflight, which
|
|
166
|
+
// this API never answers, and a form post cannot set headers at all.
|
|
167
|
+
const host = forwardedHost(req) || req.headers.host;
|
|
168
|
+
if (!host)
|
|
169
|
+
return false;
|
|
170
|
+
try {
|
|
171
|
+
// Parse the host under the origin's scheme so a default port written
|
|
172
|
+
// out by the proxy ("kw.example.com:443") compares equal to the
|
|
173
|
+
// browser's Origin, which never carries one.
|
|
174
|
+
const o = new URL(origin);
|
|
175
|
+
return o.host === new URL(`${o.protocol}//${host}`).host;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
90
181
|
async function handleApi(req, res, url) {
|
|
91
182
|
const seg = url.pathname.split("/").filter(Boolean); // ["api", ...]
|
|
92
183
|
const method = req.method ?? "GET";
|
|
184
|
+
if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
|
|
185
|
+
return json(res, { error: "cross-origin request refused" }, 403);
|
|
186
|
+
}
|
|
93
187
|
// GET /api/meta — package version + project name ("environment") for the UI.
|
|
94
188
|
// ?probe=1 marks a discovery probe from another instance: answer identity
|
|
95
189
|
// only, skip our own discovery (two instances must not probe each other
|
|
@@ -120,9 +214,46 @@ async function handleApi(req, res, url) {
|
|
|
120
214
|
projectIcon: project?.config.icon ?? "",
|
|
121
215
|
projectRoot: project?.root ?? getProjectRoot(),
|
|
122
216
|
projectRootLabel: tildify(project?.root ?? getProjectRoot()),
|
|
217
|
+
git: (project?.config.git && project.config.git.enabled !== false) === true,
|
|
123
218
|
switcher,
|
|
124
219
|
});
|
|
125
220
|
}
|
|
221
|
+
// GET /api/git — branch, ahead/behind, changed files (the git screen polls
|
|
222
|
+
// this, so it is cached for a beat; ?fresh=1 bypasses the cache).
|
|
223
|
+
if (seg.length === 2 && seg[1] === "git" && method === "GET") {
|
|
224
|
+
return json(res, await gitStatus(url.searchParams.has("fresh")));
|
|
225
|
+
}
|
|
226
|
+
// GET /api/git/diff?path= — unified diff for one file
|
|
227
|
+
if (seg.length === 3 && seg[1] === "git" && seg[2] === "diff" && method === "GET") {
|
|
228
|
+
const file = url.searchParams.get("path");
|
|
229
|
+
if (!file)
|
|
230
|
+
return json(res, { error: "path required" }, 400);
|
|
231
|
+
return json(res, await gitDiff(file));
|
|
232
|
+
}
|
|
233
|
+
// POST /api/git/commit {paths, message} — stage and commit the selection.
|
|
234
|
+
// Commit and push stay manual; the background timer never writes history.
|
|
235
|
+
if (seg.length === 3 && seg[1] === "git" && seg[2] === "commit" && method === "POST") {
|
|
236
|
+
try {
|
|
237
|
+
const body = JSON.parse(await readBody(req));
|
|
238
|
+
const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === "string") : [];
|
|
239
|
+
const message = typeof body.message === "string" ? body.message : "";
|
|
240
|
+
const result = await gitCommit(paths, message);
|
|
241
|
+
return json(res, result, result.ok ? 200 : 409);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
return json(res, { error: err.message }, 400);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// POST /api/git/{fetch,pull,push}
|
|
248
|
+
if (seg.length === 3 && seg[1] === "git" && method === "POST" && ["fetch", "pull", "push"].includes(seg[2])) {
|
|
249
|
+
const run = seg[2] === "fetch" ? gitFetch : seg[2] === "pull" ? gitPull : gitPush;
|
|
250
|
+
const result = await run();
|
|
251
|
+
return json(res, result, result.ok ? 200 : 409);
|
|
252
|
+
}
|
|
253
|
+
// GET /api/search/agents — active agents of every reachable workspace (the ⌘K palette)
|
|
254
|
+
if (seg.length === 3 && seg[1] === "search" && seg[2] === "agents" && method === "GET") {
|
|
255
|
+
return json(res, await searchAgents());
|
|
256
|
+
}
|
|
126
257
|
// GET /api/projects — every known workspace incl. this one, with state + counts (admin screen)
|
|
127
258
|
if (seg.length === 2 && seg[1] === "projects" && method === "GET") {
|
|
128
259
|
return json(res, await listWorkspacesDetailed());
|
|
@@ -170,7 +301,7 @@ async function handleApi(req, res, url) {
|
|
|
170
301
|
if (seg.length === 2 && seg[1] === "settings" && method === "GET") {
|
|
171
302
|
return json(res, await getSettings());
|
|
172
303
|
}
|
|
173
|
-
// PUT /api/settings — write the UI-editable subset (name, icon, switcher) back
|
|
304
|
+
// PUT /api/settings — write the UI-editable subset (name, icon, switcher, git) back
|
|
174
305
|
if (seg.length === 2 && seg[1] === "settings" && method === "PUT") {
|
|
175
306
|
try {
|
|
176
307
|
const input = JSON.parse(await readBody(req));
|
|
@@ -390,33 +521,33 @@ async function handleApi(req, res, url) {
|
|
|
390
521
|
const skill = await getSkill(decodeURIComponent(seg[2]));
|
|
391
522
|
return skill ? json(res, skill) : json(res, { error: "not found" }, 404);
|
|
392
523
|
}
|
|
393
|
-
// GET/POST /api/
|
|
394
|
-
if (seg.length === 2 && seg[1] === "
|
|
524
|
+
// GET/POST /api/agents — member list / create a member
|
|
525
|
+
if (seg.length === 2 && seg[1] === "agents") {
|
|
395
526
|
if (method === "GET") {
|
|
396
|
-
return json(res, { root: await
|
|
527
|
+
return json(res, { root: await agentsRoot(), agents: await listAgents() });
|
|
397
528
|
}
|
|
398
529
|
if (method === "POST") {
|
|
399
530
|
try {
|
|
400
531
|
const body = JSON.parse(await readBody(req));
|
|
401
|
-
return json(res, await
|
|
532
|
+
return json(res, await saveAgent({ ...body, slug: undefined }));
|
|
402
533
|
}
|
|
403
534
|
catch (err) {
|
|
404
535
|
return json(res, { error: err.message }, 400);
|
|
405
536
|
}
|
|
406
537
|
}
|
|
407
538
|
}
|
|
408
|
-
// POST /api/
|
|
409
|
-
if (seg.length === 4 && seg[1] === "
|
|
539
|
+
// POST /api/agents/:slug/archive — archive/unarchive a member ({ archived: boolean })
|
|
540
|
+
if (seg.length === 4 && seg[1] === "agents" && seg[3] === "archive" && method === "POST") {
|
|
410
541
|
try {
|
|
411
542
|
const body = JSON.parse(await readBody(req));
|
|
412
|
-
return json(res, await
|
|
543
|
+
return json(res, await setAgentArchived(decodeURIComponent(seg[2]), body.archived === true));
|
|
413
544
|
}
|
|
414
545
|
catch (err) {
|
|
415
546
|
return json(res, { error: err.message }, 400);
|
|
416
547
|
}
|
|
417
548
|
}
|
|
418
|
-
// GET/POST /api/
|
|
419
|
-
if (seg.length === 4 && seg[1] === "
|
|
549
|
+
// GET/POST /api/agents/:slug/skills — the agent's own skills / create or update one
|
|
550
|
+
if (seg.length === 4 && seg[1] === "agents" && seg[3] === "skills") {
|
|
420
551
|
const slug = decodeURIComponent(seg[2]);
|
|
421
552
|
try {
|
|
422
553
|
if (method === "GET")
|
|
@@ -430,8 +561,8 @@ async function handleApi(req, res, url) {
|
|
|
430
561
|
return json(res, { error: err.message }, 400);
|
|
431
562
|
}
|
|
432
563
|
}
|
|
433
|
-
// GET/DELETE /api/
|
|
434
|
-
if (seg.length === 5 && seg[1] === "
|
|
564
|
+
// GET/DELETE /api/agents/:slug/skills/:name — one agent skill with content / delete
|
|
565
|
+
if (seg.length === 5 && seg[1] === "agents" && seg[3] === "skills") {
|
|
435
566
|
const slug = decodeURIComponent(seg[2]);
|
|
436
567
|
const name = decodeURIComponent(seg[4]);
|
|
437
568
|
try {
|
|
@@ -450,8 +581,8 @@ async function handleApi(req, res, url) {
|
|
|
450
581
|
return json(res, { error: err.message }, 400);
|
|
451
582
|
}
|
|
452
583
|
}
|
|
453
|
-
// GET/POST /api/
|
|
454
|
-
if (seg.length === 4 && seg[1] === "
|
|
584
|
+
// GET/POST /api/agents/:slug/routines — list (with run state) / upsert
|
|
585
|
+
if (seg.length === 4 && seg[1] === "agents" && seg[3] === "routines") {
|
|
455
586
|
const slug = decodeURIComponent(seg[2]);
|
|
456
587
|
try {
|
|
457
588
|
if (method === "GET")
|
|
@@ -465,8 +596,8 @@ async function handleApi(req, res, url) {
|
|
|
465
596
|
return json(res, { error: err.message }, 400);
|
|
466
597
|
}
|
|
467
598
|
}
|
|
468
|
-
// DELETE /api/
|
|
469
|
-
if (seg.length >= 5 && seg[1] === "
|
|
599
|
+
// DELETE /api/agents/:slug/routines/:id | POST /api/agents/:slug/routines/:id/run
|
|
600
|
+
if (seg.length >= 5 && seg[1] === "agents" && seg[3] === "routines") {
|
|
470
601
|
const slug = decodeURIComponent(seg[2]);
|
|
471
602
|
const id = decodeURIComponent(seg[4]);
|
|
472
603
|
try {
|
|
@@ -482,20 +613,20 @@ async function handleApi(req, res, url) {
|
|
|
482
613
|
return json(res, { error: err.message }, 400);
|
|
483
614
|
}
|
|
484
615
|
}
|
|
485
|
-
// GET/PUT/DELETE /api/
|
|
486
|
-
if (seg.length === 3 && seg[1] === "
|
|
616
|
+
// GET/PUT/DELETE /api/agents/:slug
|
|
617
|
+
if (seg.length === 3 && seg[1] === "agents") {
|
|
487
618
|
const slug = decodeURIComponent(seg[2]);
|
|
488
619
|
try {
|
|
489
620
|
if (method === "GET") {
|
|
490
|
-
const
|
|
491
|
-
return
|
|
621
|
+
const agent = await getAgent(slug);
|
|
622
|
+
return agent ? json(res, agent) : json(res, { error: "not found" }, 404);
|
|
492
623
|
}
|
|
493
624
|
if (method === "PUT") {
|
|
494
625
|
const body = JSON.parse(await readBody(req));
|
|
495
|
-
return json(res, await
|
|
626
|
+
return json(res, await saveAgent({ ...body, slug }));
|
|
496
627
|
}
|
|
497
628
|
if (method === "DELETE") {
|
|
498
|
-
await
|
|
629
|
+
await deleteAgent(slug);
|
|
499
630
|
return json(res, { ok: true });
|
|
500
631
|
}
|
|
501
632
|
}
|
|
@@ -517,13 +648,13 @@ async function handleApi(req, res, url) {
|
|
|
517
648
|
}
|
|
518
649
|
let agent = body.agent;
|
|
519
650
|
let scope;
|
|
520
|
-
if (body.scope?.kind === "
|
|
521
|
-
//
|
|
522
|
-
const
|
|
523
|
-
if (!
|
|
524
|
-
return json(res, { error: "
|
|
525
|
-
scope = { kind: "
|
|
526
|
-
agent =
|
|
651
|
+
if (body.scope?.kind === "agent" && body.scope.slug) {
|
|
652
|
+
// Agent sessions run on the agent's configured harness.
|
|
653
|
+
const def = await getAgent(body.scope.slug).catch(() => null);
|
|
654
|
+
if (!def)
|
|
655
|
+
return json(res, { error: "agent not found" }, 404);
|
|
656
|
+
scope = { kind: "agent", slug: def.slug };
|
|
657
|
+
agent = def.harness;
|
|
527
658
|
}
|
|
528
659
|
else if (body.scope?.kind === "run" && body.scope.runId) {
|
|
529
660
|
scope = { kind: "run", runId: body.scope.runId };
|
|
@@ -659,6 +790,7 @@ export function startInspector(opts) {
|
|
|
659
790
|
if (opts.projectRoot)
|
|
660
791
|
setProjectRoot(opts.projectRoot);
|
|
661
792
|
startRoutineScheduler();
|
|
793
|
+
startGitSync();
|
|
662
794
|
// Chat agent subprocesses must die with the server — signals bypass
|
|
663
795
|
// "exit" handlers, so hook the signals themselves.
|
|
664
796
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
@@ -679,6 +811,8 @@ export function startInspector(opts) {
|
|
|
679
811
|
const server = http.createServer(async (req, res) => {
|
|
680
812
|
try {
|
|
681
813
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
814
|
+
if (!hostAllowed(req))
|
|
815
|
+
return json(res, { error: "unexpected Host header" }, 421);
|
|
682
816
|
if (url.pathname.startsWith("/api/"))
|
|
683
817
|
await handleApi(req, res, url);
|
|
684
818
|
else
|
|
@@ -690,7 +824,7 @@ export function startInspector(opts) {
|
|
|
690
824
|
});
|
|
691
825
|
return new Promise((resolve, reject) => {
|
|
692
826
|
server.once("error", reject);
|
|
693
|
-
server.listen(opts.port, () => {
|
|
827
|
+
server.listen(opts.port, INSPECTOR_HOST, () => {
|
|
694
828
|
const addr = server.address();
|
|
695
829
|
const port = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
696
830
|
void registerInstance(port, getProjectRoot());
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ProjectConfig, type SwitcherEntry } from "../config.js";
|
|
2
2
|
/**
|
|
3
3
|
* Workspace settings: read kraftwerk.yml for the UI and write the
|
|
4
|
-
* UI-editable subset (name, icon, switcher) back. Edits go through the
|
|
4
|
+
* UI-editable subset (name, icon, switcher, git) back. Edits go through the
|
|
5
5
|
* yaml Document API so comments and every other key survive untouched;
|
|
6
6
|
* a missing kraftwerk.yml is created at the project root on first save.
|
|
7
7
|
*/
|
|
@@ -22,6 +22,16 @@ export interface SaveSettingsInput {
|
|
|
22
22
|
icon?: string;
|
|
23
23
|
/** Omitted = untouched; empty list = remove the key. */
|
|
24
24
|
switcher?: SwitcherEntry[];
|
|
25
|
+
/** Omitted = untouched. See cleanGit for how the block is written. */
|
|
26
|
+
git?: GitSettingsInput;
|
|
27
|
+
}
|
|
28
|
+
/** The git form as the settings screen posts it. Every field but `enabled` may be blank. */
|
|
29
|
+
export interface GitSettingsInput {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
remote?: string;
|
|
32
|
+
branch?: string;
|
|
33
|
+
interval?: number | string;
|
|
34
|
+
autosync?: "off" | "pull" | "";
|
|
25
35
|
}
|
|
26
36
|
export declare function getSettings(): Promise<SettingsView>;
|
|
27
37
|
export declare function saveSettings(input: SaveSettingsInput): Promise<SettingsView>;
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
4
|
import { resolveProject } from "../config.js";
|
|
5
5
|
import { getProjectRoot } from "./context.js";
|
|
6
|
+
const GIT_DEFAULTS = { remote: "origin", interval: 300, autosync: "pull" };
|
|
6
7
|
export async function getSettings() {
|
|
7
8
|
const project = await resolveProject(getProjectRoot());
|
|
8
9
|
return {
|
|
@@ -31,6 +32,43 @@ function cleanSwitcher(value) {
|
|
|
31
32
|
return icon ? { name, url, icon } : { name, url };
|
|
32
33
|
});
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The git block to write. Defaults are left out so the file only says what
|
|
37
|
+
* differs from them, and `enabled: false` keeps the other fields so turning
|
|
38
|
+
* the feature back on restores them. Returns null when the block should not
|
|
39
|
+
* exist at all: sync off and nothing else set, which is what no block means.
|
|
40
|
+
*/
|
|
41
|
+
function cleanGit(value) {
|
|
42
|
+
if (typeof value !== "object" || value === null)
|
|
43
|
+
throw new Error("git must be an object");
|
|
44
|
+
if (typeof value.enabled !== "boolean")
|
|
45
|
+
throw new Error("git.enabled must be true or false");
|
|
46
|
+
const out = {};
|
|
47
|
+
const remote = typeof value.remote === "string" ? value.remote.trim() : "";
|
|
48
|
+
if (remote && remote !== GIT_DEFAULTS.remote)
|
|
49
|
+
out.remote = remote;
|
|
50
|
+
const branch = typeof value.branch === "string" ? value.branch.trim() : "";
|
|
51
|
+
if (branch)
|
|
52
|
+
out.branch = branch;
|
|
53
|
+
if (value.interval !== undefined && value.interval !== "") {
|
|
54
|
+
const n = typeof value.interval === "string" ? Number(value.interval) : value.interval;
|
|
55
|
+
if (!Number.isInteger(n) || n < 0)
|
|
56
|
+
throw new Error("git.interval must be a whole number of seconds (0 disables the timer)");
|
|
57
|
+
if (n !== GIT_DEFAULTS.interval)
|
|
58
|
+
out.interval = n;
|
|
59
|
+
}
|
|
60
|
+
if (value.autosync !== undefined && value.autosync !== "") {
|
|
61
|
+
if (value.autosync !== "off" && value.autosync !== "pull")
|
|
62
|
+
throw new Error('git.autosync must be "off" or "pull"');
|
|
63
|
+
if (value.autosync !== GIT_DEFAULTS.autosync)
|
|
64
|
+
out.autosync = value.autosync;
|
|
65
|
+
}
|
|
66
|
+
if (value.enabled)
|
|
67
|
+
return out;
|
|
68
|
+
if (Object.keys(out).length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
return { enabled: false, ...out };
|
|
71
|
+
}
|
|
34
72
|
export async function saveSettings(input) {
|
|
35
73
|
if (input.name !== undefined && typeof input.name !== "string")
|
|
36
74
|
throw new Error("name must be a string");
|
|
@@ -54,6 +92,13 @@ export async function saveSettings(input) {
|
|
|
54
92
|
setOrDelete("name", input.name?.trim());
|
|
55
93
|
setOrDelete("icon", input.icon?.trim());
|
|
56
94
|
setOrDelete("switcher", switcher);
|
|
95
|
+
if (input.git !== undefined) {
|
|
96
|
+
const git = cleanGit(input.git);
|
|
97
|
+
if (git === null)
|
|
98
|
+
doc.delete("git");
|
|
99
|
+
else
|
|
100
|
+
doc.set("git", git);
|
|
101
|
+
}
|
|
57
102
|
await fs.writeFile(configPath, doc.toString());
|
|
58
103
|
return getSettings();
|
|
59
104
|
}
|