@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
package/README.md CHANGED
@@ -385,10 +385,35 @@ session, errors) lives in `<output>/routines-state.json`.
385
385
  The inspector server runs the scheduler in-process, so there is no external
386
386
  cron to set up. Every due routine opens a fresh session for the agent, posts
387
387
  the prompt, and shows up in the sessions sidebar titled "⏰ <name>". Routine
388
- sessions run unattended, so tool-permission requests are auto-approved, with
389
- the request and approval pair left in the thread as an audit trail. Give
390
- routine prompts the same trust you would give a `KRAFTWERK_YES=1` workflow
391
- run. Manage routines on the agent page, where you can create, edit and
388
+ sessions run unattended in the harness's own permission mode — your
389
+ configured claude default (`auto` or `acceptEdits`; plain `default` is lifted
390
+ to `acceptEdits`, `bypassPermissions` is never used) or codex's sandboxed
391
+ `agent` preset (never full access). Edits inside the project need no
392
+ approval, and the harness keeps deciding what still needs a human — shell
393
+ commands, network, files outside the project. Kraftwerk never answers those for you.
394
+ The question waits in the thread, the routine row and the agent card show
395
+ "needs approval", and you allow or deny when you look; "allow always" is
396
+ remembered by the harness so the same routine stops asking. A request nobody
397
+ answers within 30 minutes is declined and the routine ends with a summary of
398
+ what it could not do.
399
+
400
+ The bell in the top bar collects what happened while you were away: a
401
+ session waiting for approval, a routine that finished (with the first lines
402
+ of its summary) or failed, a workflow run started from the inspector that
403
+ ended. The unread count sits on the bell and in front of the tab title, so a
404
+ background tab reads "(2) …"; click an item to jump there. Failure items
405
+ carry a "diagnose" button: it opens a chat where the failure is best
406
+ understood — the run folder for a failed workflow run, a fresh session of the
407
+ same agent for a routine that died mid-run, the kraftwerk-aware chat for a
408
+ routine that could not start — and sends a first message that names the
409
+ failure, points at the evidence (trace, logs, the failed session's events)
410
+ and asks for root cause, fix and how to re-run. Allow browser
411
+ notifications once (the bell offers it) and each new item also shows as a
412
+ system notification while any inspector tab is open. Items live in
413
+ `output/notifications.json`; `GET /api/notifications` lists them,
414
+ `POST /api/notifications/read` marks them, `DELETE /api/notifications` clears.
415
+
416
+ Manage routines on the agent page, where you can create, edit and
392
417
  delete them, toggle enabled, hit "run now", and jump to the last run's
393
418
  session. Schedules missed while the server is down are skipped, not replayed.
394
419
 
package/dist/config.d.ts CHANGED
@@ -42,6 +42,13 @@ export declare const CONFIG_FILENAMES: string[];
42
42
  * skills) plus kraftwerk.yml itself. Commit and push stay manual; the
43
43
  * interval only fetches, and pulls when `autosync` allows it.
44
44
  */
45
+ /**
46
+ * A remote or branch name kraftwerk will hand to git as a positional
47
+ * argument. Git reads a leading dash as an option ("--upload-pack=<cmd>"
48
+ * runs a command), so such values are refused wherever they enter — config
49
+ * load, settings API — and the git calls add --end-of-options as well.
50
+ */
51
+ export declare function isSafeGitName(value: string): boolean;
45
52
  export interface GitConfig {
46
53
  /** false keeps the block but turns the feature off. Default: true. */
47
54
  enabled?: boolean;
package/dist/config.js CHANGED
@@ -41,6 +41,21 @@ export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk
41
41
  export const CONFIG_SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/kraftwerk.schema.json";
42
42
  export const CONFIG_FILENAMES = ["kraftwerk.yml", "kraftwerk.yaml"];
43
43
  const WORKFLOW_ROOT_CANDIDATES = ["src/workflows", "workflows"];
44
+ /**
45
+ * Git sync for the workspace. The synced paths are not configurable: they
46
+ * are the roots this file already declares (workflows, knowledge, agents,
47
+ * skills) plus kraftwerk.yml itself. Commit and push stay manual; the
48
+ * interval only fetches, and pulls when `autosync` allows it.
49
+ */
50
+ /**
51
+ * A remote or branch name kraftwerk will hand to git as a positional
52
+ * argument. Git reads a leading dash as an option ("--upload-pack=<cmd>"
53
+ * runs a command), so such values are refused wherever they enter — config
54
+ * load, settings API — and the git calls add --end-of-options as well.
55
+ */
56
+ export function isSafeGitName(value) {
57
+ return value.length > 0 && !value.startsWith("-") && !/[\s\x00-\x1f]/.test(value);
58
+ }
44
59
  /** Where clones land when `repos.root` is not set. */
45
60
  export const REPOS_DEFAULT_ROOT = "repos";
46
61
  /**
@@ -240,6 +255,9 @@ function validateGit(configPath, value) {
240
255
  if (g[key] !== undefined && (typeof g[key] !== "string" || !g[key].trim())) {
241
256
  throw new Error(`${file}: git.${key} must be a non-empty string`);
242
257
  }
258
+ if (typeof g[key] === "string" && !isSafeGitName(g[key].trim())) {
259
+ throw new Error(`${file}: git.${key} must be a plain name (no leading "-", no whitespace)`);
260
+ }
243
261
  }
244
262
  if (g.interval !== undefined && (typeof g.interval !== "number" || !Number.isInteger(g.interval) || g.interval < 0)) {
245
263
  throw new Error(`${file}: git.interval must be a whole number of seconds (0 disables the timer)`);
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { Readable, Writable } from "node:stream";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, RequestError, } from "@agentclientprotocol/sdk";
5
+ import { unattendedMode } from "./permissions.js";
5
6
  /**
6
7
  * ACP-backed chat: spawn an adapter (claude-agent-acp / codex-acp) as a
7
8
  * subprocess, speak Agent Client Protocol over its stdio, and translate
@@ -59,6 +60,12 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
59
60
  stderr = stderr.slice(-20_000);
60
61
  });
61
62
  let dead = false;
63
+ // Without a listener a failed spawn (cwd gone, node missing) is an
64
+ // unhandled 'error' event that takes the whole inspector down.
65
+ child.on("error", (err) => {
66
+ dead = true;
67
+ hooks.emit({ type: "error", message: `could not start the ${agent} agent: ${err.message}` });
68
+ });
62
69
  child.on("close", (code) => {
63
70
  dead = true;
64
71
  if (code !== 0 && code !== null) {
@@ -137,6 +144,22 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
137
144
  : {}),
138
145
  });
139
146
  const sessionId = session.sessionId;
147
+ if (tuning.unattended) {
148
+ // Nobody is watching: keep the harness's configured mode unless it is
149
+ // one that never asks (see unattendedMode). The harness keeps deciding
150
+ // which calls reach a human; kraftwerk only rules out "never ask".
151
+ const modeId = unattendedMode(agent, session.modes?.currentModeId);
152
+ if (modeId) {
153
+ // A mode the adapter does not offer must not kill the session: the
154
+ // harness default applies, and the thread shows why.
155
+ await conn.setSessionMode({ sessionId, modeId }).catch((err) => {
156
+ hooks.emit({
157
+ type: "error",
158
+ message: `could not select the ${modeId} mode for this unattended session (${err.message}) — the ${agent} default applies`,
159
+ });
160
+ });
161
+ }
162
+ }
140
163
  return {
141
164
  async prompt(text) {
142
165
  if (dead)
@@ -31,6 +31,12 @@ export interface BackendTuning {
31
31
  skillDirs?: string[];
32
32
  /** claude: extra directories the session may work in (e.g. project root for run chats). */
33
33
  addDirs?: string[];
34
+ /**
35
+ * Nobody is watching this session live (routine runs). The backend selects
36
+ * the harness's own hands-off permission mode — the harness keeps deciding
37
+ * what still needs a human; kraftwerk never answers for one.
38
+ */
39
+ unattended?: boolean;
34
40
  }
35
41
  export interface ChatBackend {
36
42
  /** Send one user message; resolves with the stop reason at turn end. */
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Permission requests for sessions nobody is watching live (routine runs).
3
+ *
4
+ * The harness (claude / codex) decides which tool calls need a human; a
5
+ * request reaching kraftwerk means the harness already judged the action
6
+ * worth asking about. Kraftwerk never answers on the human's behalf — it
7
+ * holds the question in the thread, flags the session as waiting, and gives
8
+ * the human this long to look. When nobody does, the request is declined
9
+ * so the routine can end with a clear summary instead of hanging forever.
10
+ */
11
+ export declare const UNATTENDED_PERMISSION_TIMEOUT_MS: number;
12
+ export interface PermissionOption {
13
+ optionId: string;
14
+ name: string;
15
+ kind?: string;
16
+ }
17
+ /**
18
+ * The option that declines a request without ending the turn: the adapter's
19
+ * `reject_once` (ACP kinds are allow_once / allow_always / reject_once /
20
+ * reject_always). null cancels the tool call outright when no reject option
21
+ * is offered — never an "allow".
22
+ */
23
+ export declare function declineOption(options: PermissionOption[]): string | null;
24
+ /** Human-readable timeout for prompts and thread notes. */
25
+ export declare function unattendedTimeoutLabel(ms?: number): string;
26
+ /**
27
+ * Session mode for an unattended (routine) session, given the mode the
28
+ * harness opened with (its configured default). Returns the mode to switch
29
+ * to, or null to keep the current one. The harness stays the judge of what
30
+ * needs a human; this only rules out the two presets that never ask —
31
+ * claude bypassPermissions and codex agent-full-access — and lifts claude's
32
+ * plain "default" (ask for every edit) to acceptEdits so a routine can write
33
+ * inside the project without stalling. claude's "auto" mode (its own
34
+ * classifier decides) and acceptEdits are kept as they are.
35
+ */
36
+ export declare function unattendedMode(agent: "claude" | "codex", current: string | undefined): string | null;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Permission requests for sessions nobody is watching live (routine runs).
3
+ *
4
+ * The harness (claude / codex) decides which tool calls need a human; a
5
+ * request reaching kraftwerk means the harness already judged the action
6
+ * worth asking about. Kraftwerk never answers on the human's behalf — it
7
+ * holds the question in the thread, flags the session as waiting, and gives
8
+ * the human this long to look. When nobody does, the request is declined
9
+ * so the routine can end with a clear summary instead of hanging forever.
10
+ */
11
+ export const UNATTENDED_PERMISSION_TIMEOUT_MS = 30 * 60_000;
12
+ /**
13
+ * The option that declines a request without ending the turn: the adapter's
14
+ * `reject_once` (ACP kinds are allow_once / allow_always / reject_once /
15
+ * reject_always). null cancels the tool call outright when no reject option
16
+ * is offered — never an "allow".
17
+ */
18
+ export function declineOption(options) {
19
+ const reject = options.find((o) => o.kind === "reject_once") ??
20
+ options.find((o) => o.kind?.startsWith("reject"));
21
+ return reject?.optionId ?? null;
22
+ }
23
+ /** Human-readable timeout for prompts and thread notes. */
24
+ export function unattendedTimeoutLabel(ms = UNATTENDED_PERMISSION_TIMEOUT_MS) {
25
+ const min = Math.round(ms / 60_000);
26
+ return min === 1 ? "1 minute" : `${min} minutes`;
27
+ }
28
+ /**
29
+ * Session mode for an unattended (routine) session, given the mode the
30
+ * harness opened with (its configured default). Returns the mode to switch
31
+ * to, or null to keep the current one. The harness stays the judge of what
32
+ * needs a human; this only rules out the two presets that never ask —
33
+ * claude bypassPermissions and codex agent-full-access — and lifts claude's
34
+ * plain "default" (ask for every edit) to acceptEdits so a routine can write
35
+ * inside the project without stalling. claude's "auto" mode (its own
36
+ * classifier decides) and acceptEdits are kept as they are.
37
+ */
38
+ export function unattendedMode(agent, current) {
39
+ if (agent === "codex")
40
+ return current === "agent-full-access" || !current ? "agent" : null;
41
+ return current === "auto" || current === "acceptEdits" ? null : "acceptEdits";
42
+ }
@@ -1,14 +1,31 @@
1
1
  import type { ChatAgentId, ChatMeta, ChatScope, StoredChatEvent } from "./types.js";
2
2
  /** Server shutdown: no agent subprocess may outlive the inspector. */
3
3
  export declare function disposeAllBackends(): void;
4
+ /**
5
+ * "## Vibeable" block: the chat has an app folder open in the preview pane.
6
+ * The agent works inside that folder, and everything it saves is what the
7
+ * user sees next — so the block spells out how the preview runs and what
8
+ * the sandbox rules out.
9
+ */
10
+ export declare function vibeableContext(slug: string, cwd: string): Promise<string>;
11
+ /**
12
+ * The folder the agent is actually spawned in. A vibeable that was removed
13
+ * leaves meta.cwd pointing nowhere; spawning there fails, so fall back to
14
+ * the scope's default while keeping meta.vibeable — the context then tells
15
+ * the agent the folder is gone instead of pretending it is there.
16
+ */
17
+ export declare function effectiveCwd(meta: ChatMeta): Promise<string>;
4
18
  export declare function createChat(opts: {
5
19
  agent: ChatAgentId;
6
20
  scope: ChatScope;
7
21
  /** Preset title (e.g. routine runs); otherwise the first message names the chat. */
8
22
  title?: string;
9
23
  }): Promise<ChatMeta>;
24
+ /** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
25
+ export declare function chatAwaitingApproval(id: string): boolean;
10
26
  export declare function listChats(): Promise<Array<ChatMeta & {
11
27
  busy: boolean;
28
+ awaitingApproval: boolean;
12
29
  }>>;
13
30
  export declare function getChat(id: string): Promise<{
14
31
  meta: ChatMeta;
@@ -10,8 +10,45 @@ import { listRepos } from "../repos.js";
10
10
  import { resolveVibeable, vibeableStatus, VIBEABLE_CONFIG_FILE } from "../vibeables.js";
11
11
  import { startAcpBackend } from "./acp.js";
12
12
  import { startPiBackend } from "./pi.js";
13
+ import { UNATTENDED_PERMISSION_TIMEOUT_MS, declineOption, unattendedTimeoutLabel } from "./permissions.js";
14
+ import { chatHref, dismissKey, pushNotification } from "../notifications.js";
13
15
  import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
14
16
  const states = new Map();
17
+ /** Routine-fired sessions: nobody is expected to be watching live. */
18
+ const isUnattended = (scope) => scope.kind === "agent" && !!scope.routine;
19
+ /** Diagnose handle for a failed routine session (only called for unattended agent scopes). */
20
+ function routineRef(meta) {
21
+ const scope = meta.scope;
22
+ if (scope.kind !== "agent" || !scope.routine)
23
+ return undefined;
24
+ return { kind: "routine", agent: scope.slug, routine: scope.routine, chatId: meta.id };
25
+ }
26
+ /** How the bell names a chat: its title ("⏰ Morning digest"), else the agent, else "chat". */
27
+ function chatLabel(meta) {
28
+ if (meta.title)
29
+ return meta.title;
30
+ return meta.scope.kind === "agent" ? meta.scope.slug : "chat";
31
+ }
32
+ /** Text of the agent's final message (or last error) after `afterSeq`, for a notification body. */
33
+ function lastEventText(state, afterSeq, type) {
34
+ const parts = [];
35
+ for (const e of state.events) {
36
+ if (e.seq <= afterSeq)
37
+ continue;
38
+ if (e.type === "user_message")
39
+ parts.length = 0;
40
+ if (type === "text" && e.type === "text")
41
+ parts.push(e.text);
42
+ if (type === "error" && e.type === "error")
43
+ parts.push(e.message);
44
+ }
45
+ const text = parts.join("").trim();
46
+ if (!text)
47
+ return undefined;
48
+ // Agents end with the summary: keep the last paragraph(s), the bell clips the rest.
49
+ const paras = text.split(/\n{2,}/).filter((p) => p.trim());
50
+ return paras.slice(-2).join("\n\n");
51
+ }
15
52
  /** Kill a chat's agent subprocess; the next message spawns a fresh one. */
16
53
  function dropBackend(state) {
17
54
  state.backend?.dispose();
@@ -175,9 +212,21 @@ async function reposContext() {
175
212
  * user sees next — so the block spells out how the preview runs and what
176
213
  * the sandbox rules out.
177
214
  */
178
- async function vibeableContext(slug) {
179
- const st = await vibeableStatus(slug).catch(() => null);
215
+ export async function vibeableContext(slug, cwd) {
216
+ let st = null;
217
+ let failure = "";
218
+ try {
219
+ st = await vibeableStatus(slug);
220
+ }
221
+ catch (err) {
222
+ failure = err.message;
223
+ }
180
224
  if (!st) {
225
+ if (/are off/.test(failure)) {
226
+ return (`## Vibeable: ${slug}\nThis chat had the vibeable "${slug}" open, but vibeables have since been switched off in this ` +
227
+ `workspace's settings. Its folder still exists at ${cwd} and is your working directory; there is no preview pane any ` +
228
+ `more. Work in the folder if the user asks, and mention that the feature is off.`);
229
+ }
181
230
  return `## Vibeable\nThis chat had the vibeable "${slug}" open in its preview pane, but its folder is gone. Tell the user; do not recreate it unasked.`;
182
231
  }
183
232
  const dev = st.dev;
@@ -195,11 +244,12 @@ async function vibeableContext(slug) {
195
244
  `How the preview runs:\n` +
196
245
  `- Static mode (default): the inspector serves the folder directly at /vibeables/${slug}/ — index.html is the entry; plain HTML, CSS and ` +
197
246
  `JavaScript with no build step. Use relative URLs (./app.js, not /app.js). ES modules and CDN imports (https://esm.sh/<pkg>) work. ` +
198
- `The static preview is a sandboxed document: no localStorage, no cookies, no calls into the inspector's own /api. Keep state in memory, ` +
199
- `or move to dev mode when it must persist.\n` +
247
+ `The preview is a sandboxed document in both modes: no localStorage, no cookies, no calls into the inspector's own /api. Keep state in ` +
248
+ `memory, or move to dev mode and persist through your own server.\n` +
200
249
  `- Dev mode: when the app needs a build tool, a backend or a database, write ${VIBEABLE_CONFIG_FILE} with \`dev: <command>\` ` +
201
250
  `(e.g. \`npm run dev\`, \`node server.js\`, \`python3 -m http.server $PORT\`). kraftwerk starts it inside the folder with PORT in the ` +
202
- `environment and the pane embeds that server; the command must listen on $PORT (or declare \`port:\` when it cannot). Optional ` +
251
+ `environment and the pane reaches it through /vibeables/${slug}/ on the inspector (BASE_PATH holds that prefix — a dev server that ` +
252
+ `emits absolute URLs, like Vite, must use it as its base); the command must listen on $PORT (or declare \`port:\` when it cannot). Optional ` +
203
253
  `\`dir:\` picks the folder for static mode (e.g. dist). The app brings its own backend and storage — a small node server writing JSON ` +
204
254
  `files, SQLite, an external API — nothing of that is provided by kraftwerk.\n\n` +
205
255
  `Rules: the folder is part of this workspace and versioned with it — do not run git yourself, the user reviews and commits from the ` +
@@ -241,8 +291,12 @@ async function baseScopeContext(scope, agent) {
241
291
  `say so and suggest which agent or tool fits better.` +
242
292
  (scope.routine
243
293
  ? `\n\nThis session was started automatically by your scheduled routine "${scope.routine}" — ` +
244
- `nobody may be watching live. Complete the task autonomously (tool permissions are ` +
245
- `auto-approved) and end with a clear, self-contained summary of what you did and found.`
294
+ `nobody may be watching live. Complete the task autonomously. Edits inside the project ` +
295
+ `need no approval; anything your harness asks about (shell commands, network, files ` +
296
+ `outside the project) is shown to a human who may look later — say in one line why you ` +
297
+ `need it, then continue with what you can. A request nobody answers within ` +
298
+ `${unattendedTimeoutLabel()} is declined: report what you could not do and why, do ` +
299
+ `not retry it another way. End with a clear, self-contained summary of what you did and found.`
246
300
  : ""));
247
301
  }
248
302
  if (scope.kind === "knowledge") {
@@ -346,7 +400,7 @@ async function scopeContext(meta) {
346
400
  const [base, repos, vibe, skills] = await Promise.all([
347
401
  baseScopeContext(scope, agent),
348
402
  scope.kind === "agent" || scope.kind === "kraftwerk" ? reposContext() : Promise.resolve(""),
349
- meta.vibeable ? vibeableContext(meta.vibeable) : Promise.resolve(""),
403
+ meta.vibeable ? vibeableContext(meta.vibeable, meta.cwd) : Promise.resolve(""),
350
404
  availableSkills(scope),
351
405
  ]);
352
406
  return [base, repos, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
@@ -371,6 +425,9 @@ async function backendTuning(meta) {
371
425
  // A vibeable session works inside the app folder for the same reason.
372
426
  if ((scope.kind === "run" || meta.vibeable) && agent === "claude")
373
427
  tuning.addDirs = [getProjectRoot()];
428
+ // Routine runs: never a harness mode that skips asking (see unattendedMode in permissions.ts).
429
+ if (isUnattended(scope))
430
+ tuning.unattended = true;
374
431
  // pi loads skills only when told: hand it every visible skill folder.
375
432
  if (agent === "pi") {
376
433
  const skills = await availableSkills(scope);
@@ -385,28 +442,51 @@ async function ensureBackend(state) {
385
442
  const hooks = {
386
443
  emit: (ev) => void emit(state, ev),
387
444
  askPermission: (title, options) => {
388
- // Routine-triggered sessions run unattended: auto-approve, but keep
389
- // the request/resolution pair in the thread as an audit trail.
390
- const scope = state.meta.scope;
391
- if (scope.kind === "agent" && scope.routine) {
392
- const allow = options.find((o) => o.kind?.startsWith("allow")) ?? options[0];
393
- const requestId = randomUUID();
394
- emit(state, { type: "permission_request", requestId, title, options });
395
- emit(state, { type: "permission_resolved", requestId, optionId: allow?.optionId ?? null });
396
- return Promise.resolve(allow?.optionId ?? null);
397
- }
445
+ // The harness already judged this call worth asking about, so the
446
+ // question always goes to a human — routine sessions included. The
447
+ // request stays pending in the thread (and the chat/routine show as
448
+ // waiting) until someone answers. Unattended sessions get a deadline:
449
+ // an unanswered request is declined, never approved, so the routine
450
+ // can wrap up with a summary instead of hanging.
398
451
  return new Promise((resolve) => {
399
452
  const requestId = randomUUID();
453
+ const notifyKey = `approval:${state.meta.id}:${requestId}`;
454
+ let deadline;
400
455
  state.pendingPermissions.set(requestId, (optionId) => {
456
+ if (deadline)
457
+ clearTimeout(deadline);
401
458
  state.pendingPermissions.delete(requestId);
402
459
  emit(state, { type: "permission_resolved", requestId, optionId });
460
+ void dismissKey(notifyKey);
403
461
  resolve(optionId);
404
462
  });
405
463
  emit(state, { type: "permission_request", requestId, title, options });
464
+ // The bell: a question is waiting. Answered -> the item goes away again.
465
+ void pushNotification({
466
+ kind: "approval",
467
+ key: notifyKey,
468
+ title: `${chatLabel(state.meta)} needs approval`,
469
+ body: title,
470
+ href: chatHref(state.meta),
471
+ });
472
+ if (isUnattended(state.meta.scope)) {
473
+ deadline = setTimeout(() => {
474
+ const pending = state.pendingPermissions.get(requestId);
475
+ if (!pending)
476
+ return;
477
+ emit(state, {
478
+ type: "error",
479
+ message: `nobody answered this permission request within ${unattendedTimeoutLabel()} — declined (unattended routine session)`,
480
+ });
481
+ pending(declineOption(options));
482
+ }, UNATTENDED_PERMISSION_TIMEOUT_MS);
483
+ deadline.unref?.();
484
+ }
406
485
  });
407
486
  },
408
487
  };
409
- const { agent, cwd } = state.meta;
488
+ const { agent } = state.meta;
489
+ const cwd = await effectiveCwd(state.meta);
410
490
  const tuning = await backendTuning(state.meta);
411
491
  state.backend =
412
492
  agent === "pi"
@@ -420,6 +500,18 @@ async function ensureBackend(state) {
420
500
  /* ---------- public API (used by server.ts) ---------- */
421
501
  /** Where a chat's agent runs when no vibeable is open: the run folder for run chats, else the project root. */
422
502
  const defaultCwd = (scope) => (scope.kind === "run" ? safeRunDir(scope.runId) : getProjectRoot());
503
+ /**
504
+ * The folder the agent is actually spawned in. A vibeable that was removed
505
+ * leaves meta.cwd pointing nowhere; spawning there fails, so fall back to
506
+ * the scope's default while keeping meta.vibeable — the context then tells
507
+ * the agent the folder is gone instead of pretending it is there.
508
+ */
509
+ export async function effectiveCwd(meta) {
510
+ const st = await fs.stat(meta.cwd).catch(() => null);
511
+ if (st?.isDirectory())
512
+ return meta.cwd;
513
+ return defaultCwd(meta.scope);
514
+ }
423
515
  export async function createChat(opts) {
424
516
  const cwd = defaultCwd(opts.scope);
425
517
  const now = new Date().toISOString();
@@ -445,11 +537,16 @@ export async function createChat(opts) {
445
537
  });
446
538
  return meta;
447
539
  }
540
+ /** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
541
+ export function chatAwaitingApproval(id) {
542
+ return (states.get(id)?.pendingPermissions.size ?? 0) > 0;
543
+ }
448
544
  export async function listChats() {
449
545
  const metas = await listChatMetas();
450
546
  return metas.map((m) => ({
451
547
  ...(states.get(m.id)?.meta ?? m),
452
548
  busy: states.get(m.id)?.busy ?? false,
549
+ awaitingApproval: chatAwaitingApproval(m.id),
453
550
  }));
454
551
  }
455
552
  export async function getChat(id) {
@@ -470,7 +567,7 @@ export async function postMessage(id, text) {
470
567
  state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
471
568
  void writeMeta(state.meta).catch(() => { });
472
569
  }
473
- emit(state, { type: "user_message", text });
570
+ const turnStart = emit(state, { type: "user_message", text }).seq;
474
571
  // The turn runs in the background; the HTTP request returns immediately
475
572
  // and the browser follows along on the SSE stream.
476
573
  void (async () => {
@@ -485,13 +582,31 @@ export async function postMessage(id, text) {
485
582
  const stopReason = await backend.prompt(promptText);
486
583
  emit(state, { type: "turn_end", stopReason });
487
584
  // Routine sessions are one-shot and unattended: release the agent
488
- // process as soon as the turn ends instead of waiting for the reaper.
489
- const scope = state.meta.scope;
490
- if (scope.kind === "agent" && scope.routine)
585
+ // process as soon as the turn ends instead of waiting for the reaper,
586
+ // and tell the bell how it went.
587
+ if (isUnattended(state.meta.scope)) {
491
588
  dropBackend(state);
589
+ const failed = state.events.some((e) => e.seq > turnStart && e.type === "error");
590
+ void pushNotification({
591
+ kind: failed ? "routine_failed" : "routine_done",
592
+ title: `${chatLabel(state.meta)} ${failed ? "ended with an error" : "finished"}`,
593
+ body: failed ? lastEventText(state, turnStart, "error") : lastEventText(state, turnStart, "text"),
594
+ href: chatHref(state.meta),
595
+ ...(failed ? { diagnose: routineRef(state.meta) } : {}),
596
+ });
597
+ }
492
598
  }
493
599
  catch (err) {
494
600
  emit(state, { type: "error", message: err.message });
601
+ if (isUnattended(state.meta.scope)) {
602
+ void pushNotification({
603
+ kind: "routine_failed",
604
+ title: `${chatLabel(state.meta)} failed`,
605
+ body: err.message,
606
+ href: chatHref(state.meta),
607
+ diagnose: routineRef(state.meta),
608
+ });
609
+ }
495
610
  // A failed turn may mean a dead subprocess; drop it so the next
496
611
  // message spawns a fresh agent (thread history stays on disk).
497
612
  dropBackend(state);
@@ -513,10 +628,11 @@ export async function setChatVibeable(id, slug) {
513
628
  const state = await loadState(id);
514
629
  if (!state)
515
630
  return { error: "not found", status: 404 };
631
+ const busyError = { error: "agent is still working — wait for the turn to finish", status: 409 };
516
632
  if (state.busy)
517
- return { error: "agent is still working — wait for the turn to finish", status: 409 };
633
+ return busyError;
634
+ let dir = null;
518
635
  if (slug) {
519
- let dir;
520
636
  try {
521
637
  dir = (await resolveVibeable(slug)).dir;
522
638
  }
@@ -524,6 +640,13 @@ export async function setChatVibeable(id, slug) {
524
640
  const msg = err.message;
525
641
  return { error: msg, status: /^no vibeable/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400 };
526
642
  }
643
+ }
644
+ // A message may have started a turn during the await above; its backend
645
+ // was spawned in the old cwd, so refuse rather than relabel it. From here
646
+ // on nothing yields until cwd, vibeable and backend agree.
647
+ if (state.busy)
648
+ return busyError;
649
+ if (slug && dir) {
527
650
  state.meta.vibeable = slug;
528
651
  state.meta.cwd = dir;
529
652
  }
@@ -532,9 +655,9 @@ export async function setChatVibeable(id, slug) {
532
655
  state.meta.cwd = defaultCwd(state.meta.scope);
533
656
  }
534
657
  state.meta.updatedAt = new Date().toISOString();
535
- await writeMeta(state.meta);
536
658
  dropBackend(state);
537
659
  state.needsContext = true;
660
+ await writeMeta(state.meta);
538
661
  return { meta: state.meta };
539
662
  }
540
663
  export async function resolvePermission(id, requestId, optionId) {
@@ -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
+ }>;