@netnodeag/kraftwerk 0.39.1 → 0.41.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 (41) hide show
  1. package/README.md +72 -4
  2. package/dist/config.d.ts +7 -0
  3. package/dist/config.js +18 -0
  4. package/dist/inspector/channels.d.ts +48 -0
  5. package/dist/inspector/channels.js +130 -0
  6. package/dist/inspector/chat/acp.js +17 -0
  7. package/dist/inspector/chat/backend.d.ts +6 -0
  8. package/dist/inspector/chat/permissions.d.ts +36 -0
  9. package/dist/inspector/chat/permissions.js +42 -0
  10. package/dist/inspector/chat/sessions.d.ts +20 -1
  11. package/dist/inspector/chat/sessions.js +384 -59
  12. package/dist/inspector/chat/types.d.ts +18 -0
  13. package/dist/inspector/diagnose.d.ts +32 -0
  14. package/dist/inspector/diagnose.js +80 -0
  15. package/dist/inspector/git.js +17 -4
  16. package/dist/inspector/notifications.d.ts +69 -0
  17. package/dist/inspector/notifications.js +91 -0
  18. package/dist/inspector/routines.d.ts +2 -0
  19. package/dist/inspector/routines.js +15 -1
  20. package/dist/inspector/runner.js +20 -1
  21. package/dist/inspector/server.js +117 -2
  22. package/dist/inspector/settings.js +5 -1
  23. package/inspector/dist/assets/{dist-DYqdkWRb.js → dist-BO4gDbWB.js} +1 -1
  24. package/inspector/dist/assets/{dist-D01HLRsD.js → dist-BiIrAs8U.js} +1 -1
  25. package/inspector/dist/assets/{dist-Cn3OeZ9x.js → dist-Bj7XitTo.js} +1 -1
  26. package/inspector/dist/assets/{dist-DQujkoko.js → dist-BvHfHbzl.js} +1 -1
  27. package/inspector/dist/assets/{dist-BUzvZfC2.js → dist-CASrSR5E.js} +1 -1
  28. package/inspector/dist/assets/{dist-DfmPNdMI.js → dist-CoT4dTT9.js} +1 -1
  29. package/inspector/dist/assets/{dist-BAHymHVh.js → dist-CqbReWDG.js} +1 -1
  30. package/inspector/dist/assets/{dist-D0WPeXAf.js → dist-DKds1beX.js} +1 -1
  31. package/inspector/dist/assets/dist-Dg2YcNTD.js +1 -0
  32. package/inspector/dist/assets/{dist-CMK0Dv1Q.js → dist-g0pveAaF.js} +1 -1
  33. package/inspector/dist/assets/{dist-FnKl1oDp.js → dist-h7tJ3T-Z.js} +1 -1
  34. package/inspector/dist/assets/{editor-CVAfyi-9.js → editor-BiyVJ7Wz.js} +3 -3
  35. package/inspector/dist/assets/index-CHI0eOQa.css +1 -0
  36. package/inspector/dist/assets/index-lwVGUk9H.js +218 -0
  37. package/inspector/dist/index.html +2 -2
  38. package/package.json +1 -1
  39. package/inspector/dist/assets/dist-CWxCXHGC.js +0 -1
  40. package/inspector/dist/assets/index-Ba-uGp4N.js +0 -218
  41. package/inspector/dist/assets/index-CL7d8WY1.css +0 -1
@@ -0,0 +1,32 @@
1
+ import type { ChatAgentId, ChatScope } from "./chat/types.js";
2
+ import { type DiagnoseRef } from "./notifications.js";
3
+ /**
4
+ * "Diagnose" on a failure item in the bell: open a chat in the place that
5
+ * knows most about the failure and hand it a first message that asks for
6
+ * root cause and a fix.
7
+ *
8
+ * workflow run failed -> run chat (cwd = run folder, project root granted)
9
+ * routine session died -> a fresh session of the same agent, pointed at the
10
+ * failed session's event log
11
+ * routine could not start -> kraftwerk-aware chat (the agent/harness setup is the problem)
12
+ *
13
+ * The prompt is built here, at click time, so it reflects the run's final
14
+ * state, and the chat shows it as the user's own first message — nothing
15
+ * hidden.
16
+ */
17
+ export interface DiagnosePlan {
18
+ agent: ChatAgentId;
19
+ scope: ChatScope;
20
+ title: string;
21
+ prompt: string;
22
+ }
23
+ /** Where and how the diagnosis chat starts, for a failure item's reference. */
24
+ export declare function diagnosePlan(ref: DiagnoseRef, title: string): Promise<DiagnosePlan | null>;
25
+ /** Create the chat, send the first message, mark the item read. */
26
+ export declare function startDiagnosis(notificationId: string): Promise<{
27
+ chatId: string;
28
+ href: string;
29
+ } | {
30
+ error: string;
31
+ status: number;
32
+ }>;
@@ -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,91 @@
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
+ if (meta.scope.kind === "channel")
42
+ return `/channels/${meta.scope.slug}`;
43
+ return meta.scope.kind === "agent" ? `/agents/${meta.scope.slug}/chat/${meta.id}` : `/agents/chats/${meta.id}`;
44
+ }
45
+ export async function pushNotification(input) {
46
+ const items = await load();
47
+ const item = {
48
+ id: randomUUID(),
49
+ kind: input.kind,
50
+ title: input.title,
51
+ ...(input.body ? { body: clipBody(input.body) } : {}),
52
+ href: input.href,
53
+ at: new Date().toISOString(),
54
+ ...(input.key ? { key: input.key } : {}),
55
+ ...(input.diagnose ? { diagnose: input.diagnose } : {}),
56
+ };
57
+ // Same live state again (e.g. the request re-emitted): replace, don't stack.
58
+ const rest = input.key ? items.filter((n) => n.key !== input.key) : items;
59
+ persist([item, ...rest].slice(0, MAX_ITEMS));
60
+ return item;
61
+ }
62
+ /** Drop the item(s) for a live state that no longer exists (a permission got answered). */
63
+ export async function dismissKey(key) {
64
+ const items = await load();
65
+ const next = items.filter((n) => n.key !== key);
66
+ if (next.length !== items.length)
67
+ persist(next);
68
+ }
69
+ export async function getNotification(id) {
70
+ return (await load()).find((n) => n.id === id) ?? null;
71
+ }
72
+ export async function listNotifications() {
73
+ const items = await load();
74
+ return { items, unread: items.filter((n) => !n.readAt).length };
75
+ }
76
+ /** Mark the given ids (or everything) read. */
77
+ export async function markNotificationsRead(ids) {
78
+ const items = await load();
79
+ const now = new Date().toISOString();
80
+ const pick = ids === "all" ? null : new Set(ids);
81
+ persist(items.map((n) => (!n.readAt && (!pick || pick.has(n.id)) ? { ...n, readAt: now } : n)));
82
+ return listNotifications();
83
+ }
84
+ export async function clearNotifications() {
85
+ await load();
86
+ persist([]);
87
+ }
88
+ /** Tests / project switches: forget the in-memory copy so the next read hits disk. */
89
+ export function resetNotificationsCache() {
90
+ cache = null;
91
+ }
@@ -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,7 +6,10 @@ 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 { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, } from "./chat/sessions.js";
9
+ import { clearNotifications, listNotifications, markNotificationsRead } from "./notifications.js";
10
+ import { startDiagnosis } from "./diagnose.js";
11
+ import { deleteChannel, getChannel, listChannels, saveChannel, channelsRoot } from "./channels.js";
12
+ import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
10
13
  import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
11
14
  import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
12
15
  import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
@@ -23,6 +26,8 @@ import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutin
23
26
  * frontend polls. Realtime is polling — no daemon, no socket, works on a
24
27
  * plain filesystem.
25
28
  */
29
+ /** CSP for raw run files: same shape as the vibeable preview — scripts allowed, origin opaque. */
30
+ const RUN_FILE_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads";
26
31
  const MIME = {
27
32
  ".html": "text/html; charset=utf-8",
28
33
  ".js": "text/javascript; charset=utf-8",
@@ -186,6 +191,108 @@ async function handleApi(req, res, url) {
186
191
  if (method !== "GET" && method !== "HEAD" && !sameOrigin(req)) {
187
192
  return json(res, { error: "cross-origin request refused" }, 403);
188
193
  }
194
+ // Channels — shared transcripts with several agents (channels/<slug>/channel.yml + one chat).
195
+ // GET /api/channels | POST /api/channels {name, purpose?, members, responder?}
196
+ // POST /api/channels/from-chat {chatId, name, members, ...} — an agent session becomes a channel
197
+ // GET/PUT/DELETE /api/channels/:slug
198
+ if (seg.length >= 2 && seg[1] === "channels") {
199
+ const view = async (c) => {
200
+ const meta = await ensureChannelChat(c);
201
+ const live = (await listChats()).find((x) => x.id === meta.id);
202
+ return { ...c, chatId: meta.id, busy: live?.busy ?? false, awaitingApproval: live?.awaitingApproval ?? false, updatedAt: meta.updatedAt };
203
+ };
204
+ let body = {};
205
+ if (method === "POST" || method === "PUT") {
206
+ try {
207
+ const raw = await readBody(req);
208
+ if (raw)
209
+ body = JSON.parse(raw);
210
+ }
211
+ catch {
212
+ return json(res, { error: "invalid JSON body" }, 400);
213
+ }
214
+ }
215
+ try {
216
+ if (seg.length === 2 && method === "GET") {
217
+ const channels = await listChannels();
218
+ return json(res, { root: await channelsRoot(), channels: await Promise.all(channels.map(view)) });
219
+ }
220
+ if (seg.length === 2 && method === "POST") {
221
+ const { chatId: _c, slug: _s, ...input } = body;
222
+ return json(res, await view(await saveChannel(input)));
223
+ }
224
+ if (seg.length === 3 && seg[2] === "from-chat" && method === "POST") {
225
+ const { chatId, slug: _s, ...input } = body;
226
+ if (!chatId)
227
+ return json(res, { error: "chatId is required" }, 400);
228
+ const channel = await saveChannel(input);
229
+ const converted = await convertChatToChannel(chatId, channel);
230
+ if (converted.error) {
231
+ await deleteChannel(channel.slug).catch(() => { });
232
+ return json(res, { error: converted.error }, 409);
233
+ }
234
+ return json(res, await view(channel));
235
+ }
236
+ if (seg.length === 3) {
237
+ const slug = decodeURIComponent(seg[2]);
238
+ const channel = await getChannel(slug);
239
+ if (!channel)
240
+ return json(res, { error: "channel not found" }, 404);
241
+ if (method === "GET")
242
+ return json(res, await view(channel));
243
+ if (method === "PUT") {
244
+ const { chatId: _c, slug: _s, ...input } = body;
245
+ const saved = await saveChannel({ ...input, slug });
246
+ const meta = await ensureChannelChat(saved);
247
+ await dropChannelSeats(meta.id, saved.members);
248
+ return json(res, await view(saved));
249
+ }
250
+ if (method === "DELETE") {
251
+ const meta = (await listChats()).find((c) => c.scope.kind === "channel" && c.scope.slug === slug);
252
+ if (meta)
253
+ await deleteChat(meta.id);
254
+ await deleteChannel(slug);
255
+ return json(res, { ok: true });
256
+ }
257
+ }
258
+ }
259
+ catch (err) {
260
+ return json(res, { error: err.message }, 400);
261
+ }
262
+ return json(res, { error: "not found" }, 404);
263
+ }
264
+ // GET /api/notifications — the bell: attention items, newest first, plus unread count.
265
+ // POST /api/notifications/read {ids?: string[]} — mark some (or all) read.
266
+ // DELETE /api/notifications — clear the list.
267
+ if (seg.length >= 2 && seg[1] === "notifications") {
268
+ if (seg.length === 2 && method === "GET")
269
+ return json(res, await listNotifications());
270
+ if (seg.length === 2 && method === "DELETE") {
271
+ await clearNotifications();
272
+ return json(res, { ok: true });
273
+ }
274
+ // POST /api/notifications/:id/diagnose — open a chat that investigates the failure behind an item.
275
+ if (seg.length === 4 && seg[3] === "diagnose" && method === "POST") {
276
+ const result = await startDiagnosis(seg[2]);
277
+ if ("error" in result)
278
+ return json(res, result, result.status);
279
+ return json(res, result);
280
+ }
281
+ if (seg.length === 3 && seg[2] === "read" && method === "POST") {
282
+ let body = {};
283
+ try {
284
+ const raw = await readBody(req);
285
+ if (raw)
286
+ body = JSON.parse(raw);
287
+ }
288
+ catch {
289
+ return json(res, { error: "invalid JSON body" }, 400);
290
+ }
291
+ const ids = Array.isArray(body.ids) ? body.ids.map(String) : "all";
292
+ return json(res, await markNotificationsRead(ids));
293
+ }
294
+ return json(res, { error: "not found" }, 404);
295
+ }
189
296
  // GET /api/meta — package version + project name ("environment") for the UI.
190
297
  // ?probe=1 marks a discovery probe from another instance: answer identity
191
298
  // only, skip our own discovery (two instances must not probe each other
@@ -486,9 +593,16 @@ async function handleApi(req, res, url) {
486
593
  const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
487
594
  const buf = await fs.readFile(file.absPath);
488
595
  if (raw) {
596
+ // Agent-written files must not run as the inspector's origin: the
597
+ // sandbox keeps scripts working (interactive reports) but makes the
598
+ // origin opaque, so no fetch() into /api and no cookies. PDFs are
599
+ // exempt — Chrome's viewer refuses sandboxed documents, and PDF
600
+ // scripting has no DOM or origin access.
489
601
  res.writeHead(200, {
490
602
  "content-type": MIME[ext] ?? "text/plain; charset=utf-8",
491
603
  "cache-control": "no-store",
604
+ "x-content-type-options": "nosniff",
605
+ ...(ext === ".pdf" ? {} : { "content-security-policy": RUN_FILE_CSP }),
492
606
  });
493
607
  return void res.end(buf);
494
608
  }
@@ -871,7 +985,8 @@ async function handleApi(req, res, url) {
871
985
  const text = String(body.text ?? "").trim();
872
986
  if (!text)
873
987
  return json(res, { error: "text is required" }, 400);
874
- result = await postMessage(seg[2], text);
988
+ // Channels: the poster's display name signs the message.
989
+ result = await postMessage(seg[2], text, { from: typeof body.from === "string" ? body.from : undefined });
875
990
  }
876
991
  else if (seg[3] === "permission") {
877
992
  result = await resolvePermission(seg[2], String(body.requestId ?? ""), body.optionId == null ? null : String(body.optionId));
@@ -1,7 +1,7 @@
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";
4
+ import { ignoreEntryFor, REPOS_DEFAULT_ROOT, resolveProject, VIBEABLES_DEFAULT_ROOT, isSafeGitName } from "../config.js";
5
5
  import { disposeAllDevs, ensureVibeablesRoot } from "./vibeables.js";
6
6
  import { getProjectRoot } from "./context.js";
7
7
  import { ensureReposRoot } from "./repos.js";
@@ -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 !== "") {