@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.
- package/README.md +72 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +18 -0
- package/dist/inspector/channels.d.ts +48 -0
- package/dist/inspector/channels.js +130 -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 +20 -1
- package/dist/inspector/chat/sessions.js +384 -59
- package/dist/inspector/chat/types.d.ts +18 -0
- 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 +91 -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 +117 -2
- package/dist/inspector/settings.js +5 -1
- package/inspector/dist/assets/{dist-DYqdkWRb.js → dist-BO4gDbWB.js} +1 -1
- package/inspector/dist/assets/{dist-D01HLRsD.js → dist-BiIrAs8U.js} +1 -1
- package/inspector/dist/assets/{dist-Cn3OeZ9x.js → dist-Bj7XitTo.js} +1 -1
- package/inspector/dist/assets/{dist-DQujkoko.js → dist-BvHfHbzl.js} +1 -1
- package/inspector/dist/assets/{dist-BUzvZfC2.js → dist-CASrSR5E.js} +1 -1
- package/inspector/dist/assets/{dist-DfmPNdMI.js → dist-CoT4dTT9.js} +1 -1
- package/inspector/dist/assets/{dist-BAHymHVh.js → dist-CqbReWDG.js} +1 -1
- package/inspector/dist/assets/{dist-D0WPeXAf.js → dist-DKds1beX.js} +1 -1
- package/inspector/dist/assets/dist-Dg2YcNTD.js +1 -0
- package/inspector/dist/assets/{dist-CMK0Dv1Q.js → dist-g0pveAaF.js} +1 -1
- package/inspector/dist/assets/{dist-FnKl1oDp.js → dist-h7tJ3T-Z.js} +1 -1
- package/inspector/dist/assets/{editor-CVAfyi-9.js → editor-BiyVJ7Wz.js} +3 -3
- package/inspector/dist/assets/index-CHI0eOQa.css +1 -0
- package/inspector/dist/assets/index-lwVGUk9H.js +218 -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,13 +385,81 @@ 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
|
|
|
420
|
+
## Channels
|
|
421
|
+
|
|
422
|
+
A channel is one conversation shared by several agents and humans — a Slack
|
|
423
|
+
channel where the coworkers are agents. The definition is git-tracked with
|
|
424
|
+
the workspace, the transcript is a chat like any other:
|
|
425
|
+
|
|
426
|
+
```
|
|
427
|
+
channels/<slug>/channel.yml # name, purpose, members, responder, maxHops
|
|
428
|
+
output/chats/<chat-id>/ # the transcript (scope { kind: channel })
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
```yaml
|
|
432
|
+
name: Website relaunch
|
|
433
|
+
purpose: ship the new site by March
|
|
434
|
+
members: [researcher, writer, dev-ops] # agent slugs
|
|
435
|
+
responder: researcher # answers when nobody is @mentioned (optional)
|
|
436
|
+
maxHops: 3 # agent-to-agent handovers per human message
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Every member agent has its own seat in the channel: its own process, its
|
|
440
|
+
own persona, skills, model and permissions, exactly as in a direct session.
|
|
441
|
+
What it receives is not the whole transcript each time but the messages
|
|
442
|
+
since its last turn, each prefixed with the author (`[Lukas]:` a human,
|
|
443
|
+
`[@writer]:` an agent). Who answers:
|
|
444
|
+
|
|
445
|
+
- `@mention` an agent and it wakes; several mentions run in parallel and
|
|
446
|
+
their replies stream in as separate posts.
|
|
447
|
+
- No mention: the channel's responder answers, or nobody if none is set.
|
|
448
|
+
- Agents hand over by mentioning each other; `maxHops` bounds the chain
|
|
449
|
+
per human message so two agents never talk forever.
|
|
450
|
+
- Humans are never blocked. An agent that is busy when mentioned again runs
|
|
451
|
+
once more when its turn ends, with everything it missed.
|
|
452
|
+
- Permission questions from any agent show as cards in the channel and in
|
|
453
|
+
the bell; anyone present can answer.
|
|
454
|
+
|
|
455
|
+
Create channels on the channels screen, or from an existing agent session
|
|
456
|
+
with **add coworker**: the session becomes the channel's transcript, the
|
|
457
|
+
agent keeps its process and memory, and the agents you pick join. Your
|
|
458
|
+
messages carry the name set in the composer ("posting as"), stored per
|
|
459
|
+
browser. API: `GET/POST /api/channels`, `GET/PUT/DELETE /api/channels/:slug`,
|
|
460
|
+
`POST /api/channels/from-chat {chatId, name, members}`; messages go through
|
|
461
|
+
the chat endpoint with `from` for the poster's name.
|
|
462
|
+
|
|
395
463
|
## Context & Knowledge
|
|
396
464
|
|
|
397
465
|
Alongside runs and chats, a project can keep curated knowledge as
|
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)`);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channels: one transcript shared by several agents and humans — a Slack
|
|
3
|
+
* channel where the coworkers are agents. The definition is git-tracked
|
|
4
|
+
* with the workspace so the whole team sees the same channels:
|
|
5
|
+
*
|
|
6
|
+
* channels/<slug>/channel.yml # name, purpose, members (agent slugs), responder, maxHops
|
|
7
|
+
*
|
|
8
|
+
* The transcript itself is a chat (scope { kind: "channel", slug }) under
|
|
9
|
+
* <output>/chats/, run-state like every other chat. sessions.ts gives each
|
|
10
|
+
* member agent its own seat (process) in that chat and routes messages:
|
|
11
|
+
* an @mention wakes that agent, no mention wakes the channel's responder,
|
|
12
|
+
* an agent mentioning another hands over — bounded by maxHops per human
|
|
13
|
+
* message so two agents never talk forever.
|
|
14
|
+
*/
|
|
15
|
+
export interface Channel {
|
|
16
|
+
slug: string;
|
|
17
|
+
name: string;
|
|
18
|
+
purpose?: string;
|
|
19
|
+
/** Agent slugs that sit in the channel. */
|
|
20
|
+
members: string[];
|
|
21
|
+
/** Who answers a message that mentions nobody; absent = nobody does. */
|
|
22
|
+
responder?: string;
|
|
23
|
+
/** Agent-to-agent handovers allowed per human message. */
|
|
24
|
+
maxHops: number;
|
|
25
|
+
}
|
|
26
|
+
export declare const DEFAULT_MAX_HOPS = 3;
|
|
27
|
+
export declare function channelsRoot(): Promise<string>;
|
|
28
|
+
export declare function safeChannelSlug(slug: string): string;
|
|
29
|
+
export declare function listChannels(): Promise<Channel[]>;
|
|
30
|
+
export declare function getChannel(slug: string): Promise<Channel | null>;
|
|
31
|
+
export interface SaveChannelInput {
|
|
32
|
+
slug?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
purpose?: string;
|
|
35
|
+
members?: string[];
|
|
36
|
+
/** Agent slug, or "" / null to clear. */
|
|
37
|
+
responder?: string | null;
|
|
38
|
+
maxHops?: number;
|
|
39
|
+
}
|
|
40
|
+
/** Create (no slug: derived from the name) or update a channel definition. */
|
|
41
|
+
export declare function saveChannel(input: SaveChannelInput): Promise<Channel>;
|
|
42
|
+
export declare function deleteChannel(slug: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Who a channel message wakes. @mentions of members win (in text order, each
|
|
45
|
+
* once); a message that mentions nobody goes to the responder, or to nobody.
|
|
46
|
+
* `self` (the author, when an agent) never wakes itself.
|
|
47
|
+
*/
|
|
48
|
+
export declare function mentionTargets(text: string, channel: Pick<Channel, "members" | "responder">, self?: string): string[];
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import { getProjectRoot } from "./context.js";
|
|
5
|
+
import { resolveProject } from "../config.js";
|
|
6
|
+
import { listAgents, safeAgentSlug, slugFromName } from "./agents.js";
|
|
7
|
+
export const DEFAULT_MAX_HOPS = 3;
|
|
8
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
|
|
9
|
+
export async function channelsRoot() {
|
|
10
|
+
const project = await resolveProject(getProjectRoot());
|
|
11
|
+
return path.resolve(project.root, "channels");
|
|
12
|
+
}
|
|
13
|
+
export function safeChannelSlug(slug) {
|
|
14
|
+
if (!SLUG_RE.test(slug))
|
|
15
|
+
throw new Error(`invalid channel slug "${slug}"`);
|
|
16
|
+
return slug;
|
|
17
|
+
}
|
|
18
|
+
function normalize(slug, raw) {
|
|
19
|
+
const members = Array.isArray(raw.members) ? [...new Set(raw.members.map(String))] : [];
|
|
20
|
+
const responder = typeof raw.responder === "string" && members.includes(raw.responder) ? raw.responder : undefined;
|
|
21
|
+
const hops = typeof raw.maxHops === "number" && Number.isInteger(raw.maxHops) && raw.maxHops >= 0 ? raw.maxHops : DEFAULT_MAX_HOPS;
|
|
22
|
+
return {
|
|
23
|
+
slug,
|
|
24
|
+
name: String(raw.name ?? slug),
|
|
25
|
+
...(raw.purpose ? { purpose: String(raw.purpose) } : {}),
|
|
26
|
+
members,
|
|
27
|
+
...(responder ? { responder } : {}),
|
|
28
|
+
maxHops: hops,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function listChannels() {
|
|
32
|
+
const root = await channelsRoot();
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = (await fs.readdir(root, { withFileTypes: true }))
|
|
36
|
+
.filter((e) => e.isDirectory() && SLUG_RE.test(e.name))
|
|
37
|
+
.map((e) => e.name);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const channels = await Promise.all(entries.map((slug) => getChannel(slug)));
|
|
43
|
+
return channels.filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
|
|
44
|
+
}
|
|
45
|
+
export async function getChannel(slug) {
|
|
46
|
+
const file = path.join(await channelsRoot(), safeChannelSlug(slug), "channel.yml");
|
|
47
|
+
try {
|
|
48
|
+
const raw = parse(await fs.readFile(file, "utf8"));
|
|
49
|
+
return normalize(slug, (raw ?? {}));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Members must be existing, non-archived agents; returns them in the order given. */
|
|
56
|
+
async function checkMembers(slugs) {
|
|
57
|
+
const agents = await listAgents();
|
|
58
|
+
return slugs.map((slug) => {
|
|
59
|
+
const a = agents.find((m) => m.slug === safeAgentSlug(slug));
|
|
60
|
+
if (!a)
|
|
61
|
+
throw new Error(`agent "${slug}" not found`);
|
|
62
|
+
if (a.archived)
|
|
63
|
+
throw new Error(`agent "${slug}" is archived`);
|
|
64
|
+
return a;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Create (no slug: derived from the name) or update a channel definition. */
|
|
68
|
+
export async function saveChannel(input) {
|
|
69
|
+
const existing = input.slug ? await getChannel(safeChannelSlug(input.slug)) : null;
|
|
70
|
+
const name = (input.name ?? existing?.name ?? "").trim();
|
|
71
|
+
if (!name)
|
|
72
|
+
throw new Error("name is required");
|
|
73
|
+
const slug = input.slug ? safeChannelSlug(input.slug) : slugFromName(name);
|
|
74
|
+
if (!input.slug && (await getChannel(slug)))
|
|
75
|
+
throw new Error(`channel "${slug}" exists already`);
|
|
76
|
+
const members = input.members !== undefined ? [...new Set(input.members.map(String))] : existing?.members ?? [];
|
|
77
|
+
if (members.length === 0)
|
|
78
|
+
throw new Error("a channel needs at least one agent");
|
|
79
|
+
await checkMembers(members);
|
|
80
|
+
let responder = existing?.responder;
|
|
81
|
+
if (input.responder !== undefined)
|
|
82
|
+
responder = input.responder ? input.responder : undefined;
|
|
83
|
+
if (responder && !members.includes(responder))
|
|
84
|
+
responder = undefined;
|
|
85
|
+
const maxHops = input.maxHops !== undefined
|
|
86
|
+
? Number.isInteger(input.maxHops) && input.maxHops >= 0
|
|
87
|
+
? input.maxHops
|
|
88
|
+
: (() => {
|
|
89
|
+
throw new Error("maxHops must be a whole number");
|
|
90
|
+
})()
|
|
91
|
+
: existing?.maxHops ?? DEFAULT_MAX_HOPS;
|
|
92
|
+
const channel = {
|
|
93
|
+
slug,
|
|
94
|
+
name,
|
|
95
|
+
...(input.purpose !== undefined
|
|
96
|
+
? input.purpose.trim()
|
|
97
|
+
? { purpose: input.purpose.trim() }
|
|
98
|
+
: {}
|
|
99
|
+
: existing?.purpose
|
|
100
|
+
? { purpose: existing.purpose }
|
|
101
|
+
: {}),
|
|
102
|
+
members,
|
|
103
|
+
...(responder ? { responder } : {}),
|
|
104
|
+
maxHops,
|
|
105
|
+
};
|
|
106
|
+
const dir = path.join(await channelsRoot(), slug);
|
|
107
|
+
await fs.mkdir(dir, { recursive: true });
|
|
108
|
+
const { slug: _s, ...body } = channel;
|
|
109
|
+
await fs.writeFile(path.join(dir, "channel.yml"), stringify(body));
|
|
110
|
+
return channel;
|
|
111
|
+
}
|
|
112
|
+
export async function deleteChannel(slug) {
|
|
113
|
+
await fs.rm(path.join(await channelsRoot(), safeChannelSlug(slug)), { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Who a channel message wakes. @mentions of members win (in text order, each
|
|
117
|
+
* once); a message that mentions nobody goes to the responder, or to nobody.
|
|
118
|
+
* `self` (the author, when an agent) never wakes itself.
|
|
119
|
+
*/
|
|
120
|
+
export function mentionTargets(text, channel, self) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const m of text.matchAll(/(^|[^\w@])@([a-z0-9][a-z0-9-]*)/g)) {
|
|
123
|
+
const slug = m[2];
|
|
124
|
+
if (channel.members.includes(slug) && slug !== self && !out.includes(slug))
|
|
125
|
+
out.push(slug);
|
|
126
|
+
}
|
|
127
|
+
if (out.length === 0 && channel.responder && channel.responder !== self && !self)
|
|
128
|
+
return [channel.responder];
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatAgentId, ChatMeta, ChatScope, StoredChatEvent } from "./types.js";
|
|
2
|
+
import { type Channel } from "../channels.js";
|
|
2
3
|
/** Server shutdown: no agent subprocess may outlive the inspector. */
|
|
3
4
|
export declare function disposeAllBackends(): void;
|
|
4
5
|
/**
|
|
@@ -21,17 +22,35 @@ export declare function createChat(opts: {
|
|
|
21
22
|
/** Preset title (e.g. routine runs); otherwise the first message names the chat. */
|
|
22
23
|
title?: string;
|
|
23
24
|
}): Promise<ChatMeta>;
|
|
25
|
+
/** A permission request is waiting for a human in this chat (live state only — a restart drops it with the backend). */
|
|
26
|
+
export declare function chatAwaitingApproval(id: string): boolean;
|
|
24
27
|
export declare function listChats(): Promise<Array<ChatMeta & {
|
|
25
28
|
busy: boolean;
|
|
29
|
+
awaitingApproval: boolean;
|
|
26
30
|
}>>;
|
|
27
31
|
export declare function getChat(id: string): Promise<{
|
|
28
32
|
meta: ChatMeta;
|
|
29
33
|
events: StoredChatEvent[];
|
|
30
34
|
busy: boolean;
|
|
31
35
|
} | null>;
|
|
32
|
-
export declare function postMessage(id: string, text: string
|
|
36
|
+
export declare function postMessage(id: string, text: string, opts?: {
|
|
37
|
+
from?: string;
|
|
38
|
+
}): Promise<{
|
|
33
39
|
error?: string;
|
|
34
40
|
}>;
|
|
41
|
+
/** The chat that holds a channel's transcript; created on first use. */
|
|
42
|
+
export declare function ensureChannelChat(channel: Channel): Promise<ChatMeta>;
|
|
43
|
+
/**
|
|
44
|
+
* "Add a coworker" to an agent session: the chat becomes the channel's
|
|
45
|
+
* transcript. The agent keeps its process and memory — it has seen every
|
|
46
|
+
* message so far — and learns the channel rules with its next prompt.
|
|
47
|
+
*/
|
|
48
|
+
export declare function convertChatToChannel(chatId: string, channel: Channel): Promise<{
|
|
49
|
+
error?: string;
|
|
50
|
+
meta?: ChatMeta;
|
|
51
|
+
}>;
|
|
52
|
+
/** Members left the channel: their processes go, the transcript stays. */
|
|
53
|
+
export declare function dropChannelSeats(chatId: string, keep: string[]): Promise<void>;
|
|
35
54
|
/**
|
|
36
55
|
* Open a vibeable in the chat (or close it with null). The agent's cwd
|
|
37
56
|
* becomes the app folder, so the running subprocess is dropped and the next
|