@netnodeag/kraftwerk 0.39.0 → 0.40.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 (40) hide show
  1. package/README.md +29 -4
  2. package/dist/config.d.ts +7 -0
  3. package/dist/config.js +18 -0
  4. package/dist/inspector/chat/acp.js +23 -0
  5. package/dist/inspector/chat/backend.d.ts +6 -0
  6. package/dist/inspector/chat/permissions.d.ts +36 -0
  7. package/dist/inspector/chat/permissions.js +42 -0
  8. package/dist/inspector/chat/sessions.d.ts +17 -0
  9. package/dist/inspector/chat/sessions.js +149 -26
  10. package/dist/inspector/diagnose.d.ts +32 -0
  11. package/dist/inspector/diagnose.js +80 -0
  12. package/dist/inspector/git.js +17 -4
  13. package/dist/inspector/notifications.d.ts +69 -0
  14. package/dist/inspector/notifications.js +89 -0
  15. package/dist/inspector/routines.d.ts +2 -0
  16. package/dist/inspector/routines.js +15 -1
  17. package/dist/inspector/runner.js +20 -1
  18. package/dist/inspector/server.js +50 -1
  19. package/dist/inspector/settings.js +13 -2
  20. package/dist/inspector/vibeables.d.ts +6 -5
  21. package/dist/inspector/vibeables.js +131 -12
  22. package/inspector/dist/assets/{dist-BECiZEqH.js → dist-AXonJXol.js} +1 -1
  23. package/inspector/dist/assets/{dist-BCM6ehYa.js → dist-BJLTdJRF.js} +1 -1
  24. package/inspector/dist/assets/{dist-3Nzkrylv.js → dist-Bv3fOisq.js} +1 -1
  25. package/inspector/dist/assets/{dist-Dd-u7fXY.js → dist-Cex4_N6v.js} +1 -1
  26. package/inspector/dist/assets/{dist-DXIk6Ebu.js → dist-D9-MFS0x.js} +1 -1
  27. package/inspector/dist/assets/{dist-D3tgdU3w.js → dist-DdbyO9w9.js} +1 -1
  28. package/inspector/dist/assets/{dist-CI-2-8Tz.js → dist-Dyv2EX0S.js} +1 -1
  29. package/inspector/dist/assets/{dist-nc_mc1w0.js → dist-HuQTcyVK.js} +1 -1
  30. package/inspector/dist/assets/dist-UTSke4yL.js +1 -0
  31. package/inspector/dist/assets/{dist-DwyxsFyN.js → dist-XL4xXfDo.js} +1 -1
  32. package/inspector/dist/assets/{dist-DTW4FhJ_.js → dist-uQIhtGKj.js} +1 -1
  33. package/inspector/dist/assets/{editor-6yCJBeDg.js → editor--4qv8VP5.js} +3 -3
  34. package/inspector/dist/assets/index-BSqc9eg2.js +218 -0
  35. package/inspector/dist/assets/index-E1E7-_NW.css +1 -0
  36. package/inspector/dist/index.html +2 -2
  37. package/package.json +1 -1
  38. package/inspector/dist/assets/dist-BbhR2mEU.js +0 -1
  39. package/inspector/dist/assets/index-Dom87GNV.css +0 -1
  40. package/inspector/dist/assets/index-DurTvcNI.js +0 -218
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import { getOutputDir } from "./context.js";
3
+ import { getAgent } from "./agents.js";
4
+ import { getRun } from "./runs.js";
5
+ import { listRoutines } from "./routines.js";
6
+ import { createChat, getChat, postMessage } from "./chat/sessions.js";
7
+ import { chatHref, getNotification, markNotificationsRead } from "./notifications.js";
8
+ const FIX_INSTRUCTIONS = `Work in this order:\n` +
9
+ `1. Find the root cause. Read the evidence before guessing, and say in plain words what went wrong and where.\n` +
10
+ `2. Decide whether the fix belongs in the workflow definition (prompts, steps, gates, agent configs), in the project, ` +
11
+ `or outside (credentials, network, a missing tool). Say which.\n` +
12
+ `3. If the fix is in the workflow or project files, apply it and show the diff. If it is outside, say exactly what the user must do.\n` +
13
+ `4. End with how to verify: the command to re-run, and what a good result looks like.`;
14
+ function runPrompt(run) {
15
+ const phases = run.phases
16
+ .map((p) => `- ${p.phase} [${p.status}]${p.summary ? `: ${p.summary}` : ""}`)
17
+ .join("\n");
18
+ const failedPhase = run.phases.find((p) => p.status === "failed" || p.status === "blocked");
19
+ return (`Workflow run ${run.id} of "${run.workflow ?? "?"}" ended with status ${run.status}` +
20
+ (failedPhase ? ` in phase "${failedPhase.phase}" (${failedPhase.status})` : "") +
21
+ `.\nRequest: ${run.request ?? "(none)"}\n\nPhases:\n${phases || "(none)"}\n\n` +
22
+ `Please diagnose this run. The evidence is in this folder: trace.jsonl (every event), trigger.log ` +
23
+ `(process output), and the phase outputs. The workflow definition lives in the project root, which you may edit.\n\n` +
24
+ FIX_INSTRUCTIONS +
25
+ `\nRe-run with: KRAFTWERK_YES=1 npx kraftwerk run ${run.workflow ?? "<workflow>"} "${(run.request ?? "").replace(/"/g, '\\"')}"`);
26
+ }
27
+ async function routinePrompt(ref, agentExists) {
28
+ const routine = (await listRoutines(ref.agent).catch(() => []))
29
+ .find((r) => r.id === ref.routine);
30
+ const head = routine
31
+ ? `The routine "${routine.name}" (schedule ${routine.schedule}) of agent "${ref.agent}" failed.\nIts prompt was:\n"""\n${routine.prompt}\n"""\n`
32
+ : `A routine (${ref.routine}) of agent "${ref.agent}" failed.\n`;
33
+ let evidence;
34
+ if (ref.chatId) {
35
+ const chat = await getChat(ref.chatId).catch(() => null);
36
+ const errors = chat?.events.filter((e) => e.type === "error").map((e) => e.message) ?? [];
37
+ evidence =
38
+ `The failed session's full event log is ${path.join(getOutputDir(), "chats", ref.chatId, "events.jsonl")} ` +
39
+ `(one JSON event per line: user_message, text, tool_call, error, turn_end).` +
40
+ (errors.length ? `\nErrors recorded:\n${errors.map((m) => `- ${m}`).join("\n")}` : "");
41
+ }
42
+ else {
43
+ evidence = agentExists
44
+ ? `The session could not even start. Check the agent's definition under agents/${ref.agent}/ (agent.yml, routines.yml) and whether its harness is installed and authenticated.`
45
+ : `The session could not start because agent "${ref.agent}" was not found — check agents/${ref.agent}/agent.yml and the routine in agents/${ref.agent}/routines.yml.`;
46
+ }
47
+ return `${head}\n${evidence}\n\nPlease diagnose this failure.\n\n${FIX_INSTRUCTIONS}\nRe-run from the agent page ("run" next to the routine) or wait for the next scheduled time.`;
48
+ }
49
+ /** Where and how the diagnosis chat starts, for a failure item's reference. */
50
+ export async function diagnosePlan(ref, title) {
51
+ if (ref.kind === "run") {
52
+ const run = await getRun(ref.runId).catch(() => null);
53
+ if (!run)
54
+ return null;
55
+ return { agent: "claude", scope: { kind: "run", runId: run.id }, title: `🩺 ${title}`, prompt: runPrompt(run) };
56
+ }
57
+ const def = await getAgent(ref.agent).catch(() => null);
58
+ const prompt = await routinePrompt(ref, !!def);
59
+ // A session that died mid-run: the agent itself looks at its own log.
60
+ // No session at all (agent or harness broken): the kraftwerk-aware chat.
61
+ const scope = def && ref.chatId ? { kind: "agent", slug: def.slug } : { kind: "kraftwerk" };
62
+ return { agent: def && ref.chatId ? def.harness : "claude", scope, title: `🩺 ${title}`, prompt };
63
+ }
64
+ /** Create the chat, send the first message, mark the item read. */
65
+ export async function startDiagnosis(notificationId) {
66
+ const item = await getNotification(notificationId);
67
+ if (!item)
68
+ return { error: "notification not found", status: 404 };
69
+ if (!item.diagnose)
70
+ return { error: "nothing to diagnose for this item", status: 409 };
71
+ const plan = await diagnosePlan(item.diagnose, item.title);
72
+ if (!plan)
73
+ return { error: "the run this item refers to is gone", status: 410 };
74
+ const meta = await createChat({ agent: plan.agent, scope: plan.scope, title: plan.title });
75
+ const { error } = await postMessage(meta.id, plan.prompt);
76
+ if (error)
77
+ return { error, status: 409 };
78
+ void markNotificationsRead([item.id]);
79
+ return { chatId: meta.id, href: chatHref(meta) };
80
+ }
@@ -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 } from "../config.js";
4
+ import { 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
  /**
@@ -130,6 +130,19 @@ export async function sshCommandFor(repoRoot) {
130
130
  export async function gitNet(args, repoRoot) {
131
131
  return git(args, repoRoot, NET_TIMEOUT, { GIT_SSH_COMMAND: await sshCommandFor(repoRoot) });
132
132
  }
133
+ /**
134
+ * Remote/branch names from kraftwerk.yml become git positionals. Config
135
+ * load and the settings API already refuse dash-led names, but the file is
136
+ * shared through the workspace repo itself, so the call site checks again
137
+ * and separates options from positionals.
138
+ */
139
+ function netArgs(cmd, ...names) {
140
+ for (const n of names) {
141
+ if (!isSafeGitName(n))
142
+ throw new Error(`refusing git name "${n}" (looks like an option)`);
143
+ }
144
+ return [...cmd, "--end-of-options", ...names];
145
+ }
133
146
  /**
134
147
  * Pathspecs are globs by default, so a file called `note[1].md` would not
135
148
  * match itself. `:(literal)` turns the pattern off.
@@ -522,7 +535,7 @@ async function fetchNow() {
522
535
  const { repo, error } = await openRepo();
523
536
  if (!repo)
524
537
  return { ok: false, error };
525
- const r = await gitNet(["fetch", repo.cfg.remote], repo.repoRoot);
538
+ const r = await gitNet(netArgs(["fetch"], repo.cfg.remote), repo.repoRoot);
526
539
  touched();
527
540
  // Only a fetch that worked counts. Stamping the time on a failure would
528
541
  // put "fetched 5s ago" next to ahead/behind counts that stopped moving
@@ -550,7 +563,7 @@ async function pullNow() {
550
563
  const upstream = (await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot)).ok;
551
564
  const r = upstream
552
565
  ? await gitNet(["pull", "--ff-only"], repoRoot)
553
- : await gitNet(["pull", "--ff-only", cfg.remote, head.branch], repoRoot);
566
+ : await gitNet(netArgs(["pull", "--ff-only"], cfg.remote, head.branch), repoRoot);
554
567
  touched();
555
568
  if (!r.ok) {
556
569
  // git's own non-fast-forward output is a wall of hints. Say the one
@@ -579,7 +592,7 @@ async function pushNow() {
579
592
  const upstream = (await git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], repoRoot)).ok;
580
593
  const r = upstream
581
594
  ? await gitNet(["push"], repoRoot)
582
- : await gitNet(["push", "--set-upstream", cfg.remote, `HEAD:${head.branch}`], repoRoot);
595
+ : await gitNet(netArgs(["push", "--set-upstream"], cfg.remote, `HEAD:${head.branch}`), repoRoot);
583
596
  touched();
584
597
  if (!r.ok) {
585
598
  lastError = r.stderr || "push failed";
@@ -0,0 +1,69 @@
1
+ import type { ChatMeta } from "./chat/types.js";
2
+ /**
3
+ * Attention items: things that happened while nobody was looking and that
4
+ * the user should see — a session waiting for a permission answer, a routine
5
+ * that finished or failed, a workflow run that ended. The inspector shows
6
+ * them behind the bell in the top bar (count, list, browser notification);
7
+ * this module is the one writer. Items are run-state, not config, so they
8
+ * live in <output>/notifications.json (capped, newest first).
9
+ *
10
+ * Approval items carry a `key` so the same request is never listed twice
11
+ * and disappears again the moment someone answers (dismissKey).
12
+ */
13
+ export type NotificationKind = "approval" | "routine_done" | "routine_failed" | "run_done" | "run_failed";
14
+ /**
15
+ * What a "diagnose" on a failure item should look at: a workflow run (chat
16
+ * in the run folder) or a routine (its agent, and the session that failed
17
+ * when there was one). The prompt is built when the chat starts, from the
18
+ * current state of the run/session.
19
+ */
20
+ export type DiagnoseRef = {
21
+ kind: "run";
22
+ runId: string;
23
+ } | {
24
+ kind: "routine";
25
+ agent: string;
26
+ routine: string;
27
+ chatId?: string;
28
+ };
29
+ export interface Notification {
30
+ id: string;
31
+ kind: NotificationKind;
32
+ /** One line: who/what — "⏰ Morning digest needs approval". */
33
+ title: string;
34
+ /** Optional detail: the tool title, the summary's first lines, the error. */
35
+ body?: string;
36
+ /** Inspector route without the leading "#" — "/agents/<slug>/chat/<id>". */
37
+ href: string;
38
+ at: string;
39
+ readAt?: string;
40
+ /** Dedupe/dismiss handle for items that represent a live state. */
41
+ key?: string;
42
+ /** Present on failure items that a chat can investigate ("diagnose" in the bell). */
43
+ diagnose?: DiagnoseRef;
44
+ }
45
+ export interface NotificationsView {
46
+ items: Notification[];
47
+ unread: number;
48
+ }
49
+ /** Trim a summary/error to a short body; first paragraph, no markdown noise. */
50
+ export declare function clipBody(text: string | undefined): string | undefined;
51
+ /** Inspector route for a chat — mirrors chatHref in the UI. */
52
+ export declare function chatHref(meta: ChatMeta): string;
53
+ export declare function pushNotification(input: {
54
+ kind: NotificationKind;
55
+ title: string;
56
+ body?: string;
57
+ href: string;
58
+ key?: string;
59
+ diagnose?: DiagnoseRef;
60
+ }): Promise<Notification>;
61
+ /** Drop the item(s) for a live state that no longer exists (a permission got answered). */
62
+ export declare function dismissKey(key: string): Promise<void>;
63
+ export declare function getNotification(id: string): Promise<Notification | null>;
64
+ export declare function listNotifications(): Promise<NotificationsView>;
65
+ /** Mark the given ids (or everything) read. */
66
+ export declare function markNotificationsRead(ids: string[] | "all"): Promise<NotificationsView>;
67
+ export declare function clearNotifications(): Promise<void>;
68
+ /** Tests / project switches: forget the in-memory copy so the next read hits disk. */
69
+ export declare function resetNotificationsCache(): void;
@@ -0,0 +1,89 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { getOutputDir } from "./context.js";
5
+ const MAX_ITEMS = 200;
6
+ const BODY_MAX = 280;
7
+ let cache = null;
8
+ let writeChain = Promise.resolve();
9
+ const file = () => path.join(getOutputDir(), "notifications.json");
10
+ async function load() {
11
+ if (cache)
12
+ return cache;
13
+ try {
14
+ const parsed = JSON.parse(await fs.readFile(file(), "utf8"));
15
+ cache = Array.isArray(parsed) ? parsed : [];
16
+ }
17
+ catch {
18
+ cache = [];
19
+ }
20
+ return cache;
21
+ }
22
+ function persist(items) {
23
+ cache = items;
24
+ writeChain = writeChain
25
+ .then(async () => {
26
+ await fs.mkdir(getOutputDir(), { recursive: true });
27
+ await fs.writeFile(file(), JSON.stringify(items, null, 2));
28
+ })
29
+ .catch(() => { });
30
+ }
31
+ /** Trim a summary/error to a short body; first paragraph, no markdown noise. */
32
+ export function clipBody(text) {
33
+ const t = (text ?? "").replace(/\r/g, "").trim();
34
+ if (!t)
35
+ return undefined;
36
+ const para = t.split(/\n{2,}/)[0].replace(/\s+/g, " ").replace(/^#+\s*/, "").trim();
37
+ return para.length > BODY_MAX ? para.slice(0, BODY_MAX - 1).trimEnd() + "…" : para;
38
+ }
39
+ /** Inspector route for a chat — mirrors chatHref in the UI. */
40
+ export function chatHref(meta) {
41
+ return meta.scope.kind === "agent" ? `/agents/${meta.scope.slug}/chat/${meta.id}` : `/agents/chats/${meta.id}`;
42
+ }
43
+ export async function pushNotification(input) {
44
+ const items = await load();
45
+ const item = {
46
+ id: randomUUID(),
47
+ kind: input.kind,
48
+ title: input.title,
49
+ ...(input.body ? { body: clipBody(input.body) } : {}),
50
+ href: input.href,
51
+ at: new Date().toISOString(),
52
+ ...(input.key ? { key: input.key } : {}),
53
+ ...(input.diagnose ? { diagnose: input.diagnose } : {}),
54
+ };
55
+ // Same live state again (e.g. the request re-emitted): replace, don't stack.
56
+ const rest = input.key ? items.filter((n) => n.key !== input.key) : items;
57
+ persist([item, ...rest].slice(0, MAX_ITEMS));
58
+ return item;
59
+ }
60
+ /** Drop the item(s) for a live state that no longer exists (a permission got answered). */
61
+ export async function dismissKey(key) {
62
+ const items = await load();
63
+ const next = items.filter((n) => n.key !== key);
64
+ if (next.length !== items.length)
65
+ persist(next);
66
+ }
67
+ export async function getNotification(id) {
68
+ return (await load()).find((n) => n.id === id) ?? null;
69
+ }
70
+ export async function listNotifications() {
71
+ const items = await load();
72
+ return { items, unread: items.filter((n) => !n.readAt).length };
73
+ }
74
+ /** Mark the given ids (or everything) read. */
75
+ export async function markNotificationsRead(ids) {
76
+ const items = await load();
77
+ const now = new Date().toISOString();
78
+ const pick = ids === "all" ? null : new Set(ids);
79
+ persist(items.map((n) => (!n.readAt && (!pick || pick.has(n.id)) ? { ...n, readAt: now } : n)));
80
+ return listNotifications();
81
+ }
82
+ export async function clearNotifications() {
83
+ await load();
84
+ persist([]);
85
+ }
86
+ /** Tests / project switches: forget the in-memory copy so the next read hits disk. */
87
+ export function resetNotificationsCache() {
88
+ cache = null;
89
+ }
@@ -24,6 +24,8 @@ export interface RoutineStatus extends Routine {
24
24
  lastRunAt?: string;
25
25
  lastChatId?: string;
26
26
  lastError?: string;
27
+ /** The last run's session holds a permission request waiting for a human. */
28
+ awaitingApproval?: boolean;
27
29
  }
28
30
  interface CronSpec {
29
31
  minute: Set<number>;
@@ -3,7 +3,8 @@ import path from "node:path";
3
3
  import { parse, stringify } from "yaml";
4
4
  import { getOutputDir } from "./context.js";
5
5
  import { getAgent, listAgents, safeAgentSlug, agentsRoot } from "./agents.js";
6
- import { createChat, postMessage } from "./chat/sessions.js";
6
+ import { chatAwaitingApproval, createChat, postMessage } from "./chat/sessions.js";
7
+ import { pushNotification } from "./notifications.js";
7
8
  /* ---------- cron ---------- */
8
9
  const ALIASES = {
9
10
  "@hourly": "0 * * * *",
@@ -174,6 +175,7 @@ export async function routineStatuses(slug) {
174
175
  ...(r.enabled ? { nextRunAt: nextRun(r.schedule) } : {}),
175
176
  ...(s.lastRunAt ? { lastRunAt: s.lastRunAt } : {}),
176
177
  ...(s.lastChatId ? { lastChatId: s.lastChatId } : {}),
178
+ ...(s.lastChatId && chatAwaitingApproval(s.lastChatId) ? { awaitingApproval: true } : {}),
177
179
  ...(s.lastError ? { lastError: s.lastError } : {}),
178
180
  };
179
181
  });
@@ -194,6 +196,16 @@ async function fireRoutine(slug, routine) {
194
196
  throw new Error(error);
195
197
  return meta.id;
196
198
  }
199
+ /** A routine could not even start (agent gone, harness missing): the bell says so. */
200
+ function notifyRoutineFailed(slug, routine, message) {
201
+ return pushNotification({
202
+ kind: "routine_failed",
203
+ title: `⏰ ${routine.name} could not start`,
204
+ body: message,
205
+ href: `/agents/${slug}`,
206
+ diagnose: { kind: "routine", agent: slug, routine: routine.id },
207
+ }).catch(() => { });
208
+ }
197
209
  export async function runRoutineNow(slug, id) {
198
210
  const routine = (await listRoutines(slug)).find((r) => r.id === id);
199
211
  if (!routine)
@@ -210,6 +222,7 @@ export async function runRoutineNow(slug, id) {
210
222
  catch (err) {
211
223
  state[key] = { ...state[key], lastError: err.message };
212
224
  await writeState(state);
225
+ void notifyRoutineFailed(slug, routine, err.message);
213
226
  throw err;
214
227
  }
215
228
  }
@@ -251,6 +264,7 @@ async function tick() {
251
264
  }
252
265
  catch (err) {
253
266
  state[key] = { ...state[key], lastError: err.message };
267
+ void notifyRoutineFailed(m.slug, r, err.message);
254
268
  }
255
269
  }
256
270
  }
@@ -2,7 +2,8 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { mkdirSync, openSync, closeSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { getProjectRoot } from "./context.js";
5
- import { RUN_ID_RE, safeRunDir } from "./runs.js";
5
+ import { RUN_ID_RE, getRun, safeRunDir } from "./runs.js";
6
+ import { pushNotification } from "./notifications.js";
6
7
  import { newRunId } from "../workflow.js";
7
8
  /**
8
9
  * Workflow trigger for the web UI. The inspector stays decoupled from the
@@ -54,6 +55,24 @@ export function triggerRun(opts) {
54
55
  });
55
56
  child.unref();
56
57
  closeSync(log);
58
+ // Detached, but as long as the inspector lives it still hears the exit:
59
+ // that is when the run's outcome goes to the bell (read from the trace,
60
+ // the exit code alone does not say whether a gate blocked).
61
+ child.on("exit", () => {
62
+ void getRun(runId)
63
+ .then((run) => {
64
+ const ok = run?.status === "ok";
65
+ const lastSummary = run?.phases.filter((p) => p.summary).at(-1)?.summary;
66
+ return pushNotification({
67
+ kind: ok ? "run_done" : "run_failed",
68
+ title: `${opts.workflowName} run ${ok ? "finished" : (run?.status ?? "failed")}`,
69
+ body: lastSummary ?? opts.request,
70
+ href: `/runs/${runId}`,
71
+ ...(ok ? {} : { diagnose: { kind: "run", runId } }),
72
+ });
73
+ })
74
+ .catch(() => { });
75
+ });
57
76
  return { runId };
58
77
  }
59
78
  export function stopRun(runId) {
@@ -6,6 +6,8 @@ import { resolveProject } from "../config.js";
6
6
  import { listRuns, getRun, readRunFile } from "./runs.js";
7
7
  import { listWorkflows, getWorkflow } from "./workflows.js";
8
8
  import { dockerStatus, triggerRun, stopRun } from "./runner.js";
9
+ import { clearNotifications, listNotifications, markNotificationsRead } from "./notifications.js";
10
+ import { startDiagnosis } from "./diagnose.js";
9
11
  import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, } from "./chat/sessions.js";
10
12
  import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
11
13
  import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
@@ -14,7 +16,7 @@ import { discoverWorkspaces, forgetProject, listWorkspacesDetailed, markProjectS
14
16
  import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
15
17
  import { getSettings, saveSettings } from "./settings.js";
16
18
  import { addRepo, listRepos, openRepos, removeRepo, updateRepo } from "./repos.js";
17
- import { createVibeable, deleteVibeable, disposeAllDevs, listVibeables, openVibeables, resolveVibeable, serveVibeable, startDev, stopDev, subscribeVibeable, vibeableStatus, } from "./vibeables.js";
19
+ import { createVibeable, deleteVibeable, disposeAllDevs, listVibeables, openVibeables, proxyUpgrade, resolveVibeable, serveVibeable, startDev, stopDev, subscribeVibeable, vibeableStatus, } from "./vibeables.js";
18
20
  import { searchAgents } from "./search.js";
19
21
  import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
20
22
  /**
@@ -23,6 +25,8 @@ import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutin
23
25
  * frontend polls. Realtime is polling — no daemon, no socket, works on a
24
26
  * plain filesystem.
25
27
  */
28
+ /** CSP for raw run files: same shape as the vibeable preview — scripts allowed, origin opaque. */
29
+ const RUN_FILE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads";
26
30
  const MIME = {
27
31
  ".html": "text/html; charset=utf-8",
28
32
  ".js": "text/javascript; charset=utf-8",
@@ -186,6 +190,38 @@ async function handleApi(req, res, url) {
186
190
  if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
187
191
  return json(res, { error: "cross-origin request refused" }, 403);
188
192
  }
193
+ // GET /api/notifications — the bell: attention items, newest first, plus unread count.
194
+ // POST /api/notifications/read {ids?: string[]} — mark some (or all) read.
195
+ // DELETE /api/notifications — clear the list.
196
+ if (seg.length >= 2 && seg[1] === "notifications") {
197
+ if (seg.length === 2 && method === "GET")
198
+ return json(res, await listNotifications());
199
+ if (seg.length === 2 && method === "DELETE") {
200
+ await clearNotifications();
201
+ return json(res, { ok: true });
202
+ }
203
+ // POST /api/notifications/:id/diagnose — open a chat that investigates the failure behind an item.
204
+ if (seg.length === 4 && seg[3] === "diagnose" && method === "POST") {
205
+ const result = await startDiagnosis(seg[2]);
206
+ if ("error" in result)
207
+ return json(res, result, result.status);
208
+ return json(res, result);
209
+ }
210
+ if (seg.length === 3 && seg[2] === "read" && method === "POST") {
211
+ let body = {};
212
+ try {
213
+ const raw = await readBody(req);
214
+ if (raw)
215
+ body = JSON.parse(raw);
216
+ }
217
+ catch {
218
+ return json(res, { error: "invalid JSON body" }, 400);
219
+ }
220
+ const ids = Array.isArray(body.ids) ? body.ids.map(String) : "all";
221
+ return json(res, await markNotificationsRead(ids));
222
+ }
223
+ return json(res, { error: "not found" }, 404);
224
+ }
189
225
  // GET /api/meta — package version + project name ("environment") for the UI.
190
226
  // ?probe=1 marks a discovery probe from another instance: answer identity
191
227
  // only, skip our own discovery (two instances must not probe each other
@@ -486,9 +522,16 @@ async function handleApi(req, res, url) {
486
522
  const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
487
523
  const buf = await fs.readFile(file.absPath);
488
524
  if (raw) {
525
+ // Agent-written files must not run as the inspector's origin: the
526
+ // sandbox keeps scripts working (interactive reports) but makes the
527
+ // origin opaque, so no fetch() into /api and no cookies. PDFs are
528
+ // exempt — Chrome's viewer refuses sandboxed documents, and PDF
529
+ // scripting has no DOM or origin access.
489
530
  res.writeHead(200, {
490
531
  "content-type": MIME[ext] ?? "text/plain; charset=utf-8",
491
532
  "cache-control": "no-store",
533
+ "x-content-type-options": "nosniff",
534
+ ...(ext === ".pdf" ? {} : { "content-security-policy": RUN_FILE_CSP }),
492
535
  });
493
536
  return void res.end(buf);
494
537
  }
@@ -955,6 +998,12 @@ export function startInspector(opts) {
955
998
  json(res, { error: err.message }, 500);
956
999
  }
957
1000
  });
1001
+ // WebSocket upgrades exist for one reason: a vibeable's dev server (HMR).
1002
+ server.on("upgrade", (req, socket, head) => {
1003
+ if (!hostAllowed(req))
1004
+ return void socket.destroy();
1005
+ proxyUpgrade(req, socket, head);
1006
+ });
958
1007
  return new Promise((resolve, reject) => {
959
1008
  server.once("error", reject);
960
1009
  server.listen(opts.port, INSPECTOR_HOST, () => {
@@ -1,8 +1,8 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseDocument } from "yaml";
4
- import { ignoreEntryFor, REPOS_DEFAULT_ROOT, resolveProject, VIBEABLES_DEFAULT_ROOT } from "../config.js";
5
- import { ensureVibeablesRoot } from "./vibeables.js";
4
+ import { ignoreEntryFor, REPOS_DEFAULT_ROOT, resolveProject, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
5
+ import { disposeAllDevs, ensureVibeablesRoot } from "./vibeables.js";
6
6
  import { getProjectRoot } from "./context.js";
7
7
  import { ensureReposRoot } from "./repos.js";
8
8
  const GIT_DEFAULTS = { remote: "origin", interval: 300, autosync: "pull" };
@@ -47,9 +47,13 @@ function cleanGit(value) {
47
47
  throw new Error("git.enabled must be true or false");
48
48
  const out = {};
49
49
  const remote = typeof value.remote === "string" ? value.remote.trim() : "";
50
+ if (remote && !isSafeGitName(remote))
51
+ throw new Error('git.remote must be a plain name (no leading "-", no whitespace)');
50
52
  if (remote && remote !== GIT_DEFAULTS.remote)
51
53
  out.remote = remote;
52
54
  const branch = typeof value.branch === "string" ? value.branch.trim() : "";
55
+ if (branch && !isSafeGitName(branch))
56
+ throw new Error('git.branch must be a plain name (no leading "-", no whitespace)');
53
57
  if (branch)
54
58
  out.branch = branch;
55
59
  if (value.interval !== undefined && value.interval !== "") {
@@ -142,6 +146,13 @@ export async function saveSettings(input) {
142
146
  doc.delete("vibeables");
143
147
  else
144
148
  doc.set("vibeables", vibeables);
149
+ // Off, or a different root: running dev servers belong to folders the
150
+ // API can no longer address, so they must not outlive this save.
151
+ const before = project.config.vibeables;
152
+ const rootBefore = before && before.enabled !== false ? before.root ?? VIBEABLES_DEFAULT_ROOT : undefined;
153
+ const rootAfter = vibeables && vibeables.enabled !== false ? vibeables.root ?? VIBEABLES_DEFAULT_ROOT : undefined;
154
+ if (rootBefore !== rootAfter)
155
+ disposeAllDevs();
145
156
  }
146
157
  await fs.writeFile(configPath, doc.toString());
147
158
  // Turning repositories on prepares the folder so the first clone and the
@@ -132,6 +132,12 @@ export declare function vibeableStatus(slug: string): Promise<VibeableStatus>;
132
132
  * trailing slash redirects so the page's relative links resolve.
133
133
  */
134
134
  export declare function serveVibeable(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void>;
135
+ /**
136
+ * WebSocket upgrades under /vibeables/<slug>/ go to the dev server too — that
137
+ * is how Vite-style HMR reaches the pane. Anything else is closed: the
138
+ * inspector has no sockets of its own.
139
+ */
140
+ export declare function proxyUpgrade(req: http.IncomingMessage, socket: import("node:stream").Duplex, head: Buffer): void;
135
141
  /**
136
142
  * Stream a vibeable's events: file changes (debounced, node_modules and
137
143
  * the like excluded) and dev-server state. The watcher exists while
@@ -139,11 +145,6 @@ export declare function serveVibeable(req: http.IncomingMessage, res: http.Serve
139
145
  * where it cannot watch, the pane still has its manual reload.
140
146
  */
141
147
  export declare function subscribeVibeable(slug: string, dir: string, fn: (ev: VibeableEvent) => void): () => void;
142
- /**
143
- * Start the vibeable's dev command. Idempotent while it runs. The child
144
- * leads its own process group so stopping it also stops what it spawned
145
- * (npm → vite → esbuild).
146
- */
147
148
  export declare function startDev(slug: string): Promise<VibeableStatus>;
148
149
  /** SIGTERM the dev process group, SIGKILL what is still there after 3 s. */
149
150
  export declare function stopDev(slug: string): Promise<VibeableStatus>;