@netnodeag/kraftwerk 0.39.1 → 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.
- package/README.md +29 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +18 -0
- package/dist/inspector/chat/acp.js +17 -0
- package/dist/inspector/chat/backend.d.ts +6 -0
- package/dist/inspector/chat/permissions.d.ts +36 -0
- package/dist/inspector/chat/permissions.js +42 -0
- package/dist/inspector/chat/sessions.d.ts +3 -0
- package/dist/inspector/chat/sessions.js +105 -16
- package/dist/inspector/diagnose.d.ts +32 -0
- package/dist/inspector/diagnose.js +80 -0
- package/dist/inspector/git.js +17 -4
- package/dist/inspector/notifications.d.ts +69 -0
- package/dist/inspector/notifications.js +89 -0
- package/dist/inspector/routines.d.ts +2 -0
- package/dist/inspector/routines.js +15 -1
- package/dist/inspector/runner.js +20 -1
- package/dist/inspector/server.js +43 -0
- package/dist/inspector/settings.js +5 -1
- package/inspector/dist/assets/{dist-CMK0Dv1Q.js → dist-AXonJXol.js} +1 -1
- package/inspector/dist/assets/{dist-FnKl1oDp.js → dist-BJLTdJRF.js} +1 -1
- package/inspector/dist/assets/{dist-D01HLRsD.js → dist-Bv3fOisq.js} +1 -1
- package/inspector/dist/assets/{dist-BAHymHVh.js → dist-Cex4_N6v.js} +1 -1
- package/inspector/dist/assets/{dist-DQujkoko.js → dist-D9-MFS0x.js} +1 -1
- package/inspector/dist/assets/{dist-D0WPeXAf.js → dist-DdbyO9w9.js} +1 -1
- package/inspector/dist/assets/{dist-BUzvZfC2.js → dist-Dyv2EX0S.js} +1 -1
- package/inspector/dist/assets/{dist-DYqdkWRb.js → dist-HuQTcyVK.js} +1 -1
- package/inspector/dist/assets/dist-UTSke4yL.js +1 -0
- package/inspector/dist/assets/{dist-DfmPNdMI.js → dist-XL4xXfDo.js} +1 -1
- package/inspector/dist/assets/{dist-Cn3OeZ9x.js → dist-uQIhtGKj.js} +1 -1
- package/inspector/dist/assets/{editor-CVAfyi-9.js → editor--4qv8VP5.js} +3 -3
- package/inspector/dist/assets/index-BSqc9eg2.js +218 -0
- package/inspector/dist/assets/index-E1E7-_NW.css +1 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/dist-CWxCXHGC.js +0 -1
- package/inspector/dist/assets/index-Ba-uGp4N.js +0 -218
- package/inspector/dist/assets/index-CL7d8WY1.css +0 -1
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
@@ -143,6 +144,22 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
|
|
|
143
144
|
: {}),
|
|
144
145
|
});
|
|
145
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
|
+
}
|
|
146
163
|
return {
|
|
147
164
|
async prompt(text) {
|
|
148
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
|
+
}
|
|
@@ -21,8 +21,11 @@ export declare function createChat(opts: {
|
|
|
21
21
|
/** Preset title (e.g. routine runs); otherwise the first message names the chat. */
|
|
22
22
|
title?: string;
|
|
23
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;
|
|
24
26
|
export declare function listChats(): Promise<Array<ChatMeta & {
|
|
25
27
|
busy: boolean;
|
|
28
|
+
awaitingApproval: boolean;
|
|
26
29
|
}>>;
|
|
27
30
|
export declare function getChat(id: string): Promise<{
|
|
28
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();
|
|
@@ -254,8 +291,12 @@ async function baseScopeContext(scope, agent) {
|
|
|
254
291
|
`say so and suggest which agent or tool fits better.` +
|
|
255
292
|
(scope.routine
|
|
256
293
|
? `\n\nThis session was started automatically by your scheduled routine "${scope.routine}" — ` +
|
|
257
|
-
`nobody may be watching live. Complete the task autonomously
|
|
258
|
-
`
|
|
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.`
|
|
259
300
|
: ""));
|
|
260
301
|
}
|
|
261
302
|
if (scope.kind === "knowledge") {
|
|
@@ -384,6 +425,9 @@ async function backendTuning(meta) {
|
|
|
384
425
|
// A vibeable session works inside the app folder for the same reason.
|
|
385
426
|
if ((scope.kind === "run" || meta.vibeable) && agent === "claude")
|
|
386
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;
|
|
387
431
|
// pi loads skills only when told: hand it every visible skill folder.
|
|
388
432
|
if (agent === "pi") {
|
|
389
433
|
const skills = await availableSkills(scope);
|
|
@@ -398,24 +442,46 @@ async function ensureBackend(state) {
|
|
|
398
442
|
const hooks = {
|
|
399
443
|
emit: (ev) => void emit(state, ev),
|
|
400
444
|
askPermission: (title, options) => {
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
emit(state, { type: "permission_request", requestId, title, options });
|
|
408
|
-
emit(state, { type: "permission_resolved", requestId, optionId: allow?.optionId ?? null });
|
|
409
|
-
return Promise.resolve(allow?.optionId ?? null);
|
|
410
|
-
}
|
|
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.
|
|
411
451
|
return new Promise((resolve) => {
|
|
412
452
|
const requestId = randomUUID();
|
|
453
|
+
const notifyKey = `approval:${state.meta.id}:${requestId}`;
|
|
454
|
+
let deadline;
|
|
413
455
|
state.pendingPermissions.set(requestId, (optionId) => {
|
|
456
|
+
if (deadline)
|
|
457
|
+
clearTimeout(deadline);
|
|
414
458
|
state.pendingPermissions.delete(requestId);
|
|
415
459
|
emit(state, { type: "permission_resolved", requestId, optionId });
|
|
460
|
+
void dismissKey(notifyKey);
|
|
416
461
|
resolve(optionId);
|
|
417
462
|
});
|
|
418
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
|
+
}
|
|
419
485
|
});
|
|
420
486
|
},
|
|
421
487
|
};
|
|
@@ -471,11 +537,16 @@ export async function createChat(opts) {
|
|
|
471
537
|
});
|
|
472
538
|
return meta;
|
|
473
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
|
+
}
|
|
474
544
|
export async function listChats() {
|
|
475
545
|
const metas = await listChatMetas();
|
|
476
546
|
return metas.map((m) => ({
|
|
477
547
|
...(states.get(m.id)?.meta ?? m),
|
|
478
548
|
busy: states.get(m.id)?.busy ?? false,
|
|
549
|
+
awaitingApproval: chatAwaitingApproval(m.id),
|
|
479
550
|
}));
|
|
480
551
|
}
|
|
481
552
|
export async function getChat(id) {
|
|
@@ -496,7 +567,7 @@ export async function postMessage(id, text) {
|
|
|
496
567
|
state.meta.title = text.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
497
568
|
void writeMeta(state.meta).catch(() => { });
|
|
498
569
|
}
|
|
499
|
-
emit(state, { type: "user_message", text });
|
|
570
|
+
const turnStart = emit(state, { type: "user_message", text }).seq;
|
|
500
571
|
// The turn runs in the background; the HTTP request returns immediately
|
|
501
572
|
// and the browser follows along on the SSE stream.
|
|
502
573
|
void (async () => {
|
|
@@ -511,13 +582,31 @@ export async function postMessage(id, text) {
|
|
|
511
582
|
const stopReason = await backend.prompt(promptText);
|
|
512
583
|
emit(state, { type: "turn_end", stopReason });
|
|
513
584
|
// Routine sessions are one-shot and unattended: release the agent
|
|
514
|
-
// process as soon as the turn ends instead of waiting for the reaper
|
|
515
|
-
|
|
516
|
-
if (
|
|
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)) {
|
|
517
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
|
+
}
|
|
518
598
|
}
|
|
519
599
|
catch (err) {
|
|
520
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
|
+
}
|
|
521
610
|
// A failed turn may mean a dead subprocess; drop it so the next
|
|
522
611
|
// message spawns a fresh agent (thread history stays on disk).
|
|
523
612
|
dropBackend(state);
|
|
@@ -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
|
+
}
|
package/dist/inspector/git.js
CHANGED
|
@@ -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
|
|
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
|
|
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}`
|
|
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";
|