@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.
Files changed (51) hide show
  1. package/README.md +40 -19
  2. package/dist/cli/init.js +3 -3
  3. package/dist/cli/routines.js +9 -9
  4. package/dist/config.d.ts +26 -2
  5. package/dist/config.js +43 -3
  6. package/dist/inspector/{team.d.ts → agents.d.ts} +21 -12
  7. package/dist/inspector/{team.js → agents.js} +34 -19
  8. package/dist/inspector/chat/backend.d.ts +1 -1
  9. package/dist/inspector/chat/sessions.js +43 -43
  10. package/dist/inspector/chat/store.js +13 -1
  11. package/dist/inspector/chat/types.d.ts +2 -2
  12. package/dist/inspector/git.d.ts +83 -0
  13. package/dist/inspector/git.js +651 -0
  14. package/dist/inspector/instances.d.ts +12 -0
  15. package/dist/inspector/instances.js +37 -1
  16. package/dist/inspector/routines.d.ts +4 -4
  17. package/dist/inspector/routines.js +11 -11
  18. package/dist/inspector/search.d.ts +25 -0
  19. package/dist/inspector/search.js +31 -0
  20. package/dist/inspector/server.js +166 -32
  21. package/dist/inspector/settings.d.ts +11 -1
  22. package/dist/inspector/settings.js +45 -0
  23. package/dist/inspector/skills.d.ts +6 -6
  24. package/dist/inspector/skills.js +8 -8
  25. package/dist/runner/docker.d.ts +1 -0
  26. package/dist/runner/docker.js +1 -1
  27. package/inspector/dist/assets/{editor-CXrDJOv_.js → editor-7ImnDC3D.js} +3 -3
  28. package/inspector/dist/assets/{index-BxEG-IdY.js → index--2I7vpcS.js} +1 -1
  29. package/inspector/dist/assets/index-B2ThRS2S.js +217 -0
  30. package/inspector/dist/assets/index-BA7nx4V5.css +1 -0
  31. package/inspector/dist/assets/{index-Bumwg9SM.js → index-BC7aIJ-h.js} +1 -1
  32. package/inspector/dist/assets/{index-BULB2rC_.js → index-BIXYBiw7.js} +1 -1
  33. package/inspector/dist/assets/{index-C3hpUVfp.js → index-BZeYO5wT.js} +1 -1
  34. package/inspector/dist/assets/{index-BQMgADlc.js → index-B_CIhKa4.js} +1 -1
  35. package/inspector/dist/assets/{index-DrSWAGft.js → index-BuRajvjQ.js} +1 -1
  36. package/inspector/dist/assets/{index-BNcXQYbv.js → index-C3Ob1O7W.js} +1 -1
  37. package/inspector/dist/assets/{index-BIDsfw8C.js → index-CIjv7yxE.js} +1 -1
  38. package/inspector/dist/assets/{index-ChuqpBnv.js → index-CRAwmMfk.js} +1 -1
  39. package/inspector/dist/assets/{index-x5G6Np7T.js → index-Ct_jZSyD.js} +1 -1
  40. package/inspector/dist/assets/{index-7J4zAS6J.js → index-DKrBjjGQ.js} +1 -1
  41. package/inspector/dist/assets/{index-CUCCMUMY.js → index-DL_bJiOj.js} +1 -1
  42. package/inspector/dist/assets/{index-DAi1VtJ8.js → index-DPBAv_kj.js} +1 -1
  43. package/inspector/dist/assets/{index-Upf-BEsx.js → index-DbmSSHvr.js} +1 -1
  44. package/inspector/dist/assets/{index-CNJyAniG.js → index-DjRBLY9N.js} +1 -1
  45. package/inspector/dist/assets/{index-CszaAR9T.js → index-YZtBJhWD.js} +1 -1
  46. package/inspector/dist/assets/{index-CMMYSoL3.js → index-fWUFhIGR.js} +1 -1
  47. package/inspector/dist/index.html +2 -2
  48. package/package.json +5 -2
  49. package/schema/kraftwerk.schema.json +35 -1
  50. package/inspector/dist/assets/index-B7rwNdVj.css +0 -1
  51. 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://127.0.0.1:${port}/api/meta?probe=1`, {
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-team-member scheduled prompts — "message your agent every
3
- * weekday at 9". Definitions live next to the member (git-tracked):
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 member and posts the prompt — the run shows up in
11
- * the member's sessions sidebar like any conversation. Schedules use
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 { getMember, listMembers, safeMemberSlug, teamRoot } from "./team.js";
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 teamRoot(), safeMemberSlug(slug), "routines.yml");
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 member and post the routine's prompt. */
182
+ /** Open a fresh session for the agent and post the routine's prompt. */
183
183
  async function fireRoutine(slug, routine) {
184
- const member = await getMember(slug);
185
- if (!member)
186
- throw new Error(`team agent "${slug}" not found`);
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: member.harness,
189
- scope: { kind: "team", member: slug, routine: routine.id },
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 members;
222
+ let agents;
223
223
  try {
224
- members = await listMembers();
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 members) {
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
+ }
@@ -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 { deleteMember, getMember, listMembers, saveMember, setMemberArchived, teamRoot, } from "./team.js";
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/team — member list / create a member
394
- if (seg.length === 2 && seg[1] === "team") {
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 teamRoot(), members: await listMembers() });
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 saveMember({ ...body, slug: undefined }));
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/team/:slug/archive — archive/unarchive a member ({ archived: boolean })
409
- if (seg.length === 4 && seg[1] === "team" && seg[3] === "archive" && method === "POST") {
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 setMemberArchived(decodeURIComponent(seg[2]), body.archived === true));
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/team/:slug/skills — the agent's own skills / create or update one
419
- if (seg.length === 4 && seg[1] === "team" && seg[3] === "skills") {
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/team/:slug/skills/:name — one agent skill with content / delete
434
- if (seg.length === 5 && seg[1] === "team" && seg[3] === "skills") {
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/team/:slug/routines — list (with run state) / upsert
454
- if (seg.length === 4 && seg[1] === "team" && seg[3] === "routines") {
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/team/:slug/routines/:id | POST /api/team/:slug/routines/:id/run
469
- if (seg.length >= 5 && seg[1] === "team" && seg[3] === "routines") {
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/team/:slug
486
- if (seg.length === 3 && seg[1] === "team") {
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 member = await getMember(slug);
491
- return member ? json(res, member) : json(res, { error: "not found" }, 404);
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 saveMember({ ...body, slug }));
626
+ return json(res, await saveAgent({ ...body, slug }));
496
627
  }
497
628
  if (method === "DELETE") {
498
- await deleteMember(slug);
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 === "team" && body.scope.member) {
521
- // Team sessions run on the member's configured harness.
522
- const member = await getMember(body.scope.member).catch(() => null);
523
- if (!member)
524
- return json(res, { error: "team agent not found" }, 404);
525
- scope = { kind: "team", member: member.slug };
526
- agent = member.harness;
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
  }