@netnodeag/kraftwerk 0.42.1 → 0.44.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 +55 -6
- package/dist/acp.d.ts +131 -1
- package/dist/acp.js +163 -2
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/harnesses/acp.js +9 -5
- package/dist/inspector/channels.d.ts +3 -0
- package/dist/inspector/channels.js +12 -1
- package/dist/inspector/chat/acp.d.ts +48 -2
- package/dist/inspector/chat/acp.js +471 -47
- package/dist/inspector/chat/backend.d.ts +30 -3
- package/dist/inspector/chat/pi.js +4 -1
- package/dist/inspector/chat/sessions.d.ts +60 -2
- package/dist/inspector/chat/sessions.js +326 -16
- package/dist/inspector/chat/store.d.ts +12 -0
- package/dist/inspector/chat/store.js +46 -0
- package/dist/inspector/chat/types.d.ts +185 -2
- package/dist/inspector/git.js +8 -5
- package/dist/inspector/instances.d.ts +5 -7
- package/dist/inspector/instances.js +14 -10
- package/dist/inspector/search.d.ts +7 -5
- package/dist/inspector/search.js +5 -2
- package/dist/inspector/server.js +117 -6
- package/inspector/dist/assets/{dist-Cn-NK0yx.js → dist-BBzscIhY.js} +1 -1
- package/inspector/dist/assets/{dist-BnxCrUQZ.js → dist-BDk9auN5.js} +1 -1
- package/inspector/dist/assets/dist-BktrTMXT.js +1 -0
- package/inspector/dist/assets/{dist-DkSZQh-S.js → dist-BprKOWfC.js} +1 -1
- package/inspector/dist/assets/{dist-BpgTFX4N.js → dist-CLig8JSu.js} +1 -1
- package/inspector/dist/assets/{dist-OsHgcj_x.js → dist-Cq-0-mqe.js} +1 -1
- package/inspector/dist/assets/{dist-z049FsVf.js → dist-DVe7MUnQ.js} +1 -1
- package/inspector/dist/assets/{dist-DQbdG8Cf.js → dist-DqfkVJaW.js} +1 -1
- package/inspector/dist/assets/{dist-C8aIUi2c.js → dist-S-LgiFkD.js} +1 -1
- package/inspector/dist/assets/{dist-cZmo-9Se.js → dist-SBBqiARl.js} +1 -1
- package/inspector/dist/assets/{dist-BJxiusVh.js → dist-wCqvkQEl.js} +1 -1
- package/inspector/dist/assets/{editor-BQkS4m10.js → editor-D__mCVTd.js} +3 -3
- package/inspector/dist/assets/index-DPdYC76U.css +1 -0
- package/inspector/dist/assets/index-De7hHAxt.js +219 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +2 -2
- package/inspector/dist/assets/dist-C6Q5FWM0.js +0 -1
- package/inspector/dist/assets/index-DGlIITXq.js +0 -218
- package/inspector/dist/assets/index-DuZ9U4BZ.css +0 -1
|
@@ -1,53 +1,370 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { RequestError, } from "@agentclientprotocol/sdk";
|
|
5
|
+
import { adapterEnv, airMeta, connectAcp, EXT_METHODS, EXT_NOTIFICATIONS, fileChangeReportMeta, openSession, withAdapter, } from "../../acp.js";
|
|
3
6
|
import { unattendedMode } from "./permissions.js";
|
|
4
7
|
/**
|
|
5
8
|
* ACP-backed chat: spawn an adapter (claude-agent-acp / codex-acp) as a
|
|
6
9
|
* subprocess, speak Agent Client Protocol over its stdio, and translate
|
|
7
10
|
* session/update notifications into chat events. One subprocess lives for
|
|
8
|
-
* the whole chat; the ACP session id carries the conversation
|
|
9
|
-
*
|
|
11
|
+
* the whole chat; the ACP session id carries the conversation and is
|
|
12
|
+
* handed back so a later backend (after a restart, or the idle reaper)
|
|
13
|
+
* resumes it. The adapter plumbing is shared with the workflow ACP
|
|
14
|
+
* harness (src/acp.ts).
|
|
15
|
+
*
|
|
16
|
+
* Beyond text and tool calls the chat takes everything the adapters
|
|
17
|
+
* offer that a thread can show or a human can act on: subagents as their
|
|
18
|
+
* own sessions and background work as async tasks (AIR extension),
|
|
19
|
+
* session failures with a recommended action, the agent's plan, context
|
|
20
|
+
* and cost usage, its slash commands, its session settings, who it is
|
|
21
|
+
* signed in as, the files it says it changed, and its questions (form
|
|
22
|
+
* elicitations) — plus steering a message into the running turn.
|
|
10
23
|
*/
|
|
11
24
|
function contentText(content) {
|
|
12
25
|
return content.type === "text" ? content.text : "";
|
|
13
26
|
}
|
|
27
|
+
/** The compaction facts on a synthetic "Compact conversation" tool call, if it is one. */
|
|
28
|
+
function compactionOf(meta) {
|
|
29
|
+
const m = meta?.contextCompaction;
|
|
30
|
+
if (!m || typeof m !== "object")
|
|
31
|
+
return undefined;
|
|
32
|
+
const c = {};
|
|
33
|
+
if (m.trigger === "manual" || m.trigger === "automatic")
|
|
34
|
+
c.trigger = m.trigger;
|
|
35
|
+
if (typeof m.preTokens === "number")
|
|
36
|
+
c.preTokens = m.preTokens;
|
|
37
|
+
if (typeof m.postTokens === "number")
|
|
38
|
+
c.postTokens = m.postTokens;
|
|
39
|
+
if (typeof m.durationMs === "number")
|
|
40
|
+
c.durationMs = m.durationMs;
|
|
41
|
+
if (typeof m.error === "string")
|
|
42
|
+
c.error = m.error;
|
|
43
|
+
return c;
|
|
44
|
+
}
|
|
45
|
+
const FAILURE_CATEGORIES = new Set(["connection", "access", "limit", "request", "service", "unknown"]);
|
|
46
|
+
const FAILURE_ACTIONS = new Set(["retry", "login", "new_session"]);
|
|
47
|
+
/** The session failure a `session_info_update` carries, if it is one (AIR `sessionFailure` in `_meta`). */
|
|
48
|
+
export function failureOf(meta) {
|
|
49
|
+
const f = airMeta(meta, "sessionFailure");
|
|
50
|
+
if (!f || typeof f.id !== "string" || typeof f.title !== "string")
|
|
51
|
+
return undefined;
|
|
52
|
+
const actions = Array.isArray(f.actions) ? f.actions.filter((a) => FAILURE_ACTIONS.has(String(a))) : [];
|
|
53
|
+
return {
|
|
54
|
+
id: f.id,
|
|
55
|
+
revision: typeof f.revision === "number" ? f.revision : 1,
|
|
56
|
+
category: FAILURE_CATEGORIES.has(String(f.category)) ? f.category : "unknown",
|
|
57
|
+
severity: f.severity === "warning" ? "warning" : "error",
|
|
58
|
+
title: f.title,
|
|
59
|
+
...(typeof f.details === "string" && f.details ? { details: f.details } : {}),
|
|
60
|
+
...(typeof f.reason === "string" && f.reason ? { reason: f.reason } : {}),
|
|
61
|
+
actions,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The file change report a `session_info_update` carries, if it is one (AIR `agentFileChangeReport`). */
|
|
65
|
+
export function filesChangedOf(meta) {
|
|
66
|
+
const r = airMeta(meta, "agentFileChangeReport");
|
|
67
|
+
if (!r || typeof r.status !== "string")
|
|
68
|
+
return undefined;
|
|
69
|
+
if (r.status === "reported") {
|
|
70
|
+
const paths = Array.isArray(r.paths) ? r.paths.filter((p) => typeof p === "string") : [];
|
|
71
|
+
return {
|
|
72
|
+
type: "files_changed",
|
|
73
|
+
paths,
|
|
74
|
+
complete: r.declaredComplete === true && r.truncated !== true,
|
|
75
|
+
...(typeof r.uncertainty === "string" && r.uncertainty ? { uncertainty: r.uncertainty } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return { type: "files_changed", paths: [], complete: false, unavailable: typeof r.reason === "string" ? r.reason : r.status };
|
|
79
|
+
}
|
|
80
|
+
const PLAN_PRIORITIES = new Set(["high", "medium", "low"]);
|
|
81
|
+
const PLAN_STATUSES = new Set(["pending", "in_progress", "completed"]);
|
|
82
|
+
function planEntries(entries) {
|
|
83
|
+
return entries.map((e) => ({
|
|
84
|
+
content: e.content,
|
|
85
|
+
priority: PLAN_PRIORITIES.has(e.priority) ? e.priority : "medium",
|
|
86
|
+
status: PLAN_STATUSES.has(e.status) ? e.status : "pending",
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
/** The protocol's config options -> the chat's (select choices flattened, groups kept as a label). */
|
|
90
|
+
export function configOptions(options) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const o of options ?? []) {
|
|
93
|
+
const base = {
|
|
94
|
+
id: o.id,
|
|
95
|
+
name: o.name,
|
|
96
|
+
...(o.description ? { description: o.description } : {}),
|
|
97
|
+
...(o.category ? { category: String(o.category) } : {}),
|
|
98
|
+
};
|
|
99
|
+
if (o.type === "boolean")
|
|
100
|
+
out.push({ ...base, type: "boolean", value: o.currentValue });
|
|
101
|
+
else if (o.type === "select") {
|
|
102
|
+
const choices = [];
|
|
103
|
+
for (const item of o.options) {
|
|
104
|
+
if (Array.isArray(item.options)) {
|
|
105
|
+
for (const c of item.options) {
|
|
106
|
+
choices.push({ value: c.value, name: c.name, ...(c.description ? { description: c.description } : {}), group: String(item.name ?? item.group) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const c = item;
|
|
111
|
+
choices.push({ value: c.value, name: c.name, ...(c.description ? { description: c.description } : {}) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
out.push({ ...base, type: "select", value: o.currentValue, choices });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** The adapter's auth status notification -> the chat's. */
|
|
120
|
+
export function authStatusOf(params) {
|
|
121
|
+
const a = params?.authStatus;
|
|
122
|
+
if (!a || typeof a.label !== "string")
|
|
123
|
+
return undefined;
|
|
124
|
+
const kinds = new Set(["account", "api_key", "gateway", "external", "none"]);
|
|
125
|
+
const account = (a.account ?? {});
|
|
126
|
+
return {
|
|
127
|
+
kind: kinds.has(String(a.kind)) ? a.kind : "external",
|
|
128
|
+
label: a.label,
|
|
129
|
+
...(typeof a.detail === "string" && a.detail ? { detail: a.detail } : {}),
|
|
130
|
+
...(typeof account.email === "string" ? { email: account.email } : {}),
|
|
131
|
+
...(typeof account.organization === "string" ? { organization: account.organization } : {}),
|
|
132
|
+
...(typeof account.plan === "string" ? { plan: account.plan } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A form elicitation's schema -> fields the UI can render. Claude's
|
|
137
|
+
* AskUserQuestion arrives as one select (oneOf) per question plus an
|
|
138
|
+
* "Other" text field flagged as its custom answer; MCP elicitations bring
|
|
139
|
+
* plain string/number/boolean/enum properties. Unknown property types
|
|
140
|
+
* degrade to text.
|
|
141
|
+
*/
|
|
142
|
+
export function elicitationFields(schema) {
|
|
143
|
+
const s = schema;
|
|
144
|
+
const required = new Set(s?.required ?? []);
|
|
145
|
+
const fields = [];
|
|
146
|
+
const option = (o) => {
|
|
147
|
+
if (typeof o === "string")
|
|
148
|
+
return { value: o, label: o };
|
|
149
|
+
const e = o;
|
|
150
|
+
if (typeof e?.const !== "string")
|
|
151
|
+
return null;
|
|
152
|
+
return { value: e.const, label: typeof e.title === "string" ? e.title : e.const, ...(typeof e.description === "string" ? { description: e.description } : {}) };
|
|
153
|
+
};
|
|
154
|
+
for (const [key, p] of Object.entries(s?.properties ?? {})) {
|
|
155
|
+
const common = {
|
|
156
|
+
key,
|
|
157
|
+
...(typeof p.title === "string" ? { title: p.title } : {}),
|
|
158
|
+
...(typeof p.description === "string" ? { description: p.description } : {}),
|
|
159
|
+
...(required.has(key) ? { required: true } : {}),
|
|
160
|
+
};
|
|
161
|
+
const meta = (p._meta ?? {});
|
|
162
|
+
const custom = Object.values(meta).some((m) => m && typeof m === "object" && m.isCustomAnswer === true);
|
|
163
|
+
if (p.type === "boolean")
|
|
164
|
+
fields.push({ ...common, kind: "boolean" });
|
|
165
|
+
else if (p.type === "number" || p.type === "integer")
|
|
166
|
+
fields.push({ ...common, kind: "number" });
|
|
167
|
+
else if (p.type === "array") {
|
|
168
|
+
const items = (p.items ?? {});
|
|
169
|
+
const options = (items.anyOf ?? items.enum ?? []).map(option).filter((o) => !!o);
|
|
170
|
+
fields.push({ ...common, kind: options.length ? "multiselect" : "text", ...(options.length ? { options } : {}) });
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const raw = (p.oneOf ?? p.enum);
|
|
174
|
+
const options = Array.isArray(raw) ? raw.map(option).filter((o) => !!o) : [];
|
|
175
|
+
fields.push({ ...common, kind: options.length ? "select" : "text", ...(options.length ? { options } : {}), ...(custom ? { custom: true } : {}) });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return fields;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* One standard session update -> the chat event it means, or null for
|
|
182
|
+
* updates that carry no thread content (mode updates, ...).
|
|
183
|
+
* Updates from a session other than `root` come from a subagent and are
|
|
184
|
+
* tagged with that child's session id.
|
|
185
|
+
*/
|
|
186
|
+
export function translateUpdate(root, params) {
|
|
187
|
+
const u = params.update;
|
|
188
|
+
const sub = params.sessionId !== root ? { subagent: params.sessionId } : {};
|
|
189
|
+
switch (u.sessionUpdate) {
|
|
190
|
+
case "agent_message_chunk": {
|
|
191
|
+
const text = contentText(u.content);
|
|
192
|
+
return text ? { type: "text", text, ...sub } : null;
|
|
193
|
+
}
|
|
194
|
+
case "agent_thought_chunk": {
|
|
195
|
+
const text = contentText(u.content);
|
|
196
|
+
return text ? { type: "thought", text, ...sub } : null;
|
|
197
|
+
}
|
|
198
|
+
case "tool_call": {
|
|
199
|
+
const compaction = compactionOf(u._meta);
|
|
200
|
+
return {
|
|
201
|
+
type: "tool_call",
|
|
202
|
+
callId: u.toolCallId,
|
|
203
|
+
title: u.title,
|
|
204
|
+
kind: u.kind ?? undefined,
|
|
205
|
+
status: u.status ?? undefined,
|
|
206
|
+
...sub,
|
|
207
|
+
...(compaction ? { compaction } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
case "tool_call_update": {
|
|
211
|
+
const compaction = compactionOf(u._meta);
|
|
212
|
+
return {
|
|
213
|
+
type: "tool_update",
|
|
214
|
+
callId: u.toolCallId,
|
|
215
|
+
title: u.title ?? undefined,
|
|
216
|
+
status: u.status ?? undefined,
|
|
217
|
+
...sub,
|
|
218
|
+
...(compaction ? { compaction } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// A subagent's plan is its own business; the thread shows the main agent's.
|
|
222
|
+
case "plan":
|
|
223
|
+
return params.sessionId !== root ? null : { type: "plan", entries: planEntries(u.entries) };
|
|
224
|
+
case "plan_update":
|
|
225
|
+
// Only the item form is a checklist; a plan as file or markdown has no thread rendering yet.
|
|
226
|
+
return params.sessionId !== root || u.plan.type !== "items" ? null : { type: "plan", entries: planEntries(u.plan.entries) };
|
|
227
|
+
case "plan_removed":
|
|
228
|
+
return params.sessionId !== root ? null : { type: "plan", entries: null };
|
|
229
|
+
case "usage_update":
|
|
230
|
+
return params.sessionId !== root
|
|
231
|
+
? null
|
|
232
|
+
: { type: "usage", used: u.used, size: u.size, ...(typeof u.cost?.amount === "number" ? { costUsd: u.cost.amount } : {}) };
|
|
233
|
+
case "available_commands_update": {
|
|
234
|
+
const commands = u.availableCommands.map((c) => ({
|
|
235
|
+
name: c.name,
|
|
236
|
+
description: c.description,
|
|
237
|
+
...(c.input && "hint" in c.input && typeof c.input.hint === "string" ? { hint: c.input.hint } : {}),
|
|
238
|
+
}));
|
|
239
|
+
return params.sessionId !== root ? null : { type: "commands", commands };
|
|
240
|
+
}
|
|
241
|
+
case "config_option_update":
|
|
242
|
+
return params.sessionId !== root ? null : { type: "config", options: configOptions(u.configOptions) };
|
|
243
|
+
case "session_info_update": {
|
|
244
|
+
// Titles and timestamps carry nothing for the thread; failures and file reports do.
|
|
245
|
+
const failure = failureOf(u._meta);
|
|
246
|
+
if (failure)
|
|
247
|
+
return { type: "failure", ...failure };
|
|
248
|
+
return filesChangedOf(u._meta) ?? null;
|
|
249
|
+
}
|
|
250
|
+
default:
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** One extension update (subagent / async task lifecycle) -> its chat event. */
|
|
255
|
+
export function translateExtension(n) {
|
|
256
|
+
const u = n.update;
|
|
257
|
+
switch (u.sessionUpdate) {
|
|
258
|
+
case "subagent_spawned":
|
|
259
|
+
return { type: "subagent", sessionId: u.subagentSessionId, name: u.name, task: u.task };
|
|
260
|
+
case "subagent_state_update":
|
|
261
|
+
return { type: "subagent_state", sessionId: u.subagentSessionId, state: u.state };
|
|
262
|
+
case "async_task_spawned":
|
|
263
|
+
return {
|
|
264
|
+
type: "task",
|
|
265
|
+
taskId: u.asyncTaskId,
|
|
266
|
+
name: u.name,
|
|
267
|
+
taskType: u.taskType,
|
|
268
|
+
description: u.description,
|
|
269
|
+
...(u.toolCallId ? { toolCallId: u.toolCallId } : {}),
|
|
270
|
+
...(u.canStop ? { canStop: true } : {}),
|
|
271
|
+
};
|
|
272
|
+
case "async_task_progress":
|
|
273
|
+
return {
|
|
274
|
+
type: "task_update",
|
|
275
|
+
taskId: u.asyncTaskId,
|
|
276
|
+
...(u.description ? { description: u.description } : {}),
|
|
277
|
+
...(u.summary ? { summary: u.summary } : {}),
|
|
278
|
+
...(u.outputFilePath ? { outputFilePath: u.outputFilePath } : {}),
|
|
279
|
+
};
|
|
280
|
+
case "async_task_state_update":
|
|
281
|
+
return {
|
|
282
|
+
type: "task_update",
|
|
283
|
+
taskId: u.asyncTaskId,
|
|
284
|
+
state: u.state,
|
|
285
|
+
...(u.summary ? { summary: u.summary } : {}),
|
|
286
|
+
...(u.outputFilePath ? { outputFilePath: u.outputFilePath } : {}),
|
|
287
|
+
};
|
|
288
|
+
default:
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Text files this large and smaller go into the prompt whole; bigger ones are referenced by path. */
|
|
293
|
+
const INLINE_TEXT_LIMIT = 200_000;
|
|
294
|
+
const TEXT_MIME = /^(text\/|application\/(json|xml|yaml|x-yaml|javascript|typescript|toml|sql)\b)/;
|
|
295
|
+
/**
|
|
296
|
+
* A message with its files as ACP prompt blocks: images as image blocks
|
|
297
|
+
* (the agent sees the screenshot), text files as embedded resources, and
|
|
298
|
+
* anything else as a line naming the path — every file is on disk under
|
|
299
|
+
* the chat, so the agent can open it itself.
|
|
300
|
+
*/
|
|
301
|
+
export async function promptBlocks(text, files = []) {
|
|
302
|
+
const blocks = [];
|
|
303
|
+
const mentions = [];
|
|
304
|
+
for (const f of files) {
|
|
305
|
+
try {
|
|
306
|
+
if (f.mimeType.startsWith("image/")) {
|
|
307
|
+
const data = await fs.readFile(f.path);
|
|
308
|
+
blocks.push({ type: "image", mimeType: f.mimeType, data: data.toString("base64") });
|
|
309
|
+
mentions.push(`${f.name} (image, ${f.path})`);
|
|
310
|
+
}
|
|
311
|
+
else if (TEXT_MIME.test(f.mimeType) && f.size <= INLINE_TEXT_LIMIT) {
|
|
312
|
+
const content = await fs.readFile(f.path, "utf8");
|
|
313
|
+
blocks.push({ type: "resource", resource: { uri: pathToFileURL(f.path).href, mimeType: f.mimeType, text: content } });
|
|
314
|
+
mentions.push(`${f.name} (${f.path})`);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
mentions.push(`${f.name} (${f.mimeType}, ${f.path})`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
mentions.push(`${f.name} (could not be read: ${f.path})`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const body = mentions.length ? `${text}\n\nAttached files:\n${mentions.map((m) => `- ${m}`).join("\n")}` : text;
|
|
325
|
+
return [{ type: "text", text: body }, ...blocks];
|
|
326
|
+
}
|
|
327
|
+
/** The agent's own sessions in `cwd` (newest first), from a short-lived adapter. */
|
|
328
|
+
export async function listAgentSessions(agent, cwd) {
|
|
329
|
+
return withAdapter(agent, cwd, async (conn) => {
|
|
330
|
+
const res = await conn.listSessions({ cwd });
|
|
331
|
+
return res.sessions
|
|
332
|
+
.map((s) => ({
|
|
333
|
+
sessionId: s.sessionId,
|
|
334
|
+
cwd: s.cwd,
|
|
335
|
+
...(s.title ? { title: s.title } : {}),
|
|
336
|
+
...(s.updatedAt ? { updatedAt: s.updatedAt } : {}),
|
|
337
|
+
}))
|
|
338
|
+
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/** Remove one of the agent's sessions for good (its transcript on disk), from a short-lived adapter. */
|
|
342
|
+
export async function deleteAgentSession(agent, cwd, sessionId) {
|
|
343
|
+
await withAdapter(agent, cwd, async (conn) => {
|
|
344
|
+
await conn.deleteSession({ sessionId });
|
|
345
|
+
});
|
|
346
|
+
}
|
|
14
347
|
export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
|
|
15
348
|
let dead = false;
|
|
349
|
+
// Set once the session is open; updates before that belong to nobody.
|
|
350
|
+
let sessionId = "";
|
|
351
|
+
// Usage arrives after nearly every chunk; the thread keeps a step per
|
|
352
|
+
// percent of the window and every cost total (turn ends), not each tick.
|
|
353
|
+
let usagePercent = -1;
|
|
16
354
|
const client = {
|
|
17
355
|
sessionUpdate(params) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (text)
|
|
29
|
-
hooks.emit({ type: "thought", text });
|
|
30
|
-
break;
|
|
31
|
-
}
|
|
32
|
-
case "tool_call":
|
|
33
|
-
hooks.emit({
|
|
34
|
-
type: "tool_call",
|
|
35
|
-
callId: u.toolCallId,
|
|
36
|
-
title: u.title,
|
|
37
|
-
kind: u.kind ?? undefined,
|
|
38
|
-
status: u.status ?? undefined,
|
|
39
|
-
});
|
|
40
|
-
break;
|
|
41
|
-
case "tool_call_update":
|
|
42
|
-
hooks.emit({
|
|
43
|
-
type: "tool_update",
|
|
44
|
-
callId: u.toolCallId,
|
|
45
|
-
title: u.title ?? undefined,
|
|
46
|
-
status: u.status ?? undefined,
|
|
47
|
-
});
|
|
48
|
-
break;
|
|
49
|
-
// plans, mode/config updates etc. carry no thread content — skip.
|
|
356
|
+
const ev = translateUpdate(sessionId, params);
|
|
357
|
+
if (!ev)
|
|
358
|
+
return;
|
|
359
|
+
if (ev.type === "commands")
|
|
360
|
+
backend.commands = ev.commands;
|
|
361
|
+
if (ev.type === "usage") {
|
|
362
|
+
const pct = ev.size > 0 ? Math.floor((100 * ev.used) / ev.size) : 0;
|
|
363
|
+
if (ev.costUsd == null && pct === usagePercent)
|
|
364
|
+
return;
|
|
365
|
+
usagePercent = pct;
|
|
50
366
|
}
|
|
367
|
+
hooks.emit(ev);
|
|
51
368
|
},
|
|
52
369
|
async requestPermission(params) {
|
|
53
370
|
const optionId = await hooks.askPermission(params.toolCall.title ?? params.toolCall.toolCallId, params.options.map((o) => ({ optionId: o.optionId, name: o.name, kind: o.kind })));
|
|
@@ -55,12 +372,32 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
|
|
|
55
372
|
? { outcome: { outcome: "selected", optionId } }
|
|
56
373
|
: { outcome: { outcome: "cancelled" } };
|
|
57
374
|
},
|
|
375
|
+
async createElicitation(params) {
|
|
376
|
+
// Only forms: a url elicitation would need a browser on the user's side.
|
|
377
|
+
if (params.mode !== "form")
|
|
378
|
+
return { action: "decline" };
|
|
379
|
+
const answer = await hooks.askElicitation(params.message, elicitationFields(params.requestedSchema));
|
|
380
|
+
return answer.action === "accept" ? { action: "accept", content: answer.content } : { action: answer.action };
|
|
381
|
+
},
|
|
382
|
+
async extNotification(method, params) {
|
|
383
|
+
if (method === EXT_NOTIFICATIONS.authStatus) {
|
|
384
|
+
const auth = authStatusOf(params);
|
|
385
|
+
if (auth)
|
|
386
|
+
hooks.emit({ type: "auth", ...auth });
|
|
387
|
+
}
|
|
388
|
+
},
|
|
58
389
|
};
|
|
59
390
|
const { child, conn } = await connectAcp(agent, {
|
|
60
391
|
cwd,
|
|
61
392
|
env: adapterEnv(agent, tuning),
|
|
62
393
|
client,
|
|
63
394
|
clientName: "kraftwerk-inspector",
|
|
395
|
+
elicitation: true,
|
|
396
|
+
onExtension: (n) => {
|
|
397
|
+
const ev = translateExtension(n);
|
|
398
|
+
if (ev)
|
|
399
|
+
hooks.emit(ev);
|
|
400
|
+
},
|
|
64
401
|
onError: (err) => {
|
|
65
402
|
dead = true;
|
|
66
403
|
hooks.emit({ type: "error", message: `could not start the ${agent} agent: ${err.message}` });
|
|
@@ -86,14 +423,23 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
|
|
|
86
423
|
// no-op on API-key auth, where Claude Code keeps the integration off).
|
|
87
424
|
extraArgs: { chrome: null },
|
|
88
425
|
};
|
|
89
|
-
const
|
|
426
|
+
const sessionReq = {
|
|
90
427
|
cwd,
|
|
91
428
|
mcpServers: [],
|
|
92
429
|
...(agent === "claude" && Object.keys(claudeOptions).length
|
|
93
430
|
? { _meta: { claudeCode: { options: claudeOptions } } }
|
|
94
431
|
: {}),
|
|
95
|
-
}
|
|
96
|
-
const
|
|
432
|
+
};
|
|
433
|
+
const session = await openSession(conn, sessionReq, tuning.resume);
|
|
434
|
+
sessionId = session.sessionId;
|
|
435
|
+
if (session.resumeError) {
|
|
436
|
+
hooks.emit({
|
|
437
|
+
type: "error",
|
|
438
|
+
message: `could not resume the previous ${agent} session (${session.resumeError}) — starting a fresh one; the agent gets the conversation so far as context, not as memory`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
if (session.configOptions?.length)
|
|
442
|
+
hooks.emit({ type: "config", options: configOptions(session.configOptions) });
|
|
97
443
|
if (tuning.unattended) {
|
|
98
444
|
// Nobody is watching: keep the harness's configured mode unless it is
|
|
99
445
|
// one that never asks (see unattendedMode). The harness keeps deciding
|
|
@@ -110,21 +456,84 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
|
|
|
110
456
|
});
|
|
111
457
|
}
|
|
112
458
|
}
|
|
113
|
-
|
|
114
|
-
|
|
459
|
+
const fail = (err, what) => {
|
|
460
|
+
if (err instanceof RequestError)
|
|
461
|
+
throw new Error(`${agent} agent ${what}: ${err.message}`);
|
|
462
|
+
throw err;
|
|
463
|
+
};
|
|
464
|
+
let turns = 0;
|
|
465
|
+
const backend = {
|
|
466
|
+
sessionId,
|
|
467
|
+
resumed: session.resumed,
|
|
468
|
+
async prompt(text, files) {
|
|
115
469
|
if (dead)
|
|
116
470
|
throw new Error(`${agent} agent process is gone — start a new chat`);
|
|
117
471
|
try {
|
|
118
472
|
const res = await conn.prompt({
|
|
119
473
|
sessionId,
|
|
120
|
-
prompt:
|
|
474
|
+
prompt: await promptBlocks(text, files),
|
|
475
|
+
// Ask for the list of files this turn changed (arrives as a files_changed event).
|
|
476
|
+
_meta: fileChangeReportMeta(randomUUID()),
|
|
121
477
|
});
|
|
478
|
+
turns++;
|
|
122
479
|
return res.stopReason;
|
|
123
480
|
}
|
|
124
481
|
catch (err) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
482
|
+
// A resumed session that cannot even take its first prompt (the
|
|
483
|
+
// agent's transcript is gone) must not be resumed again.
|
|
484
|
+
if (session.resumed && turns === 0)
|
|
485
|
+
backend.resumeFailed = true;
|
|
486
|
+
return fail(err, "");
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
async fork() {
|
|
490
|
+
if (dead)
|
|
491
|
+
throw new Error(`${agent} agent process is gone`);
|
|
492
|
+
try {
|
|
493
|
+
const res = await conn.unstable_forkSession({ sessionId, ...sessionReq });
|
|
494
|
+
return res.sessionId;
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
return fail(err, "cannot fork this session");
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
async steer(text, files) {
|
|
501
|
+
if (dead)
|
|
502
|
+
throw new Error(`${agent} agent process is gone`);
|
|
503
|
+
try {
|
|
504
|
+
const res = await conn.extMethod(EXT_METHODS.steer, {
|
|
505
|
+
sessionId,
|
|
506
|
+
prompt: await promptBlocks(text, files),
|
|
507
|
+
// Never start a turn behind kraftwerk's back: it owns the turn bookkeeping.
|
|
508
|
+
_meta: { steering: { idleBehavior: "promptRequired" } },
|
|
509
|
+
});
|
|
510
|
+
return res.outcome === "promptRequired" ? "promptRequired" : "injected";
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
return fail(err, "cannot steer");
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
async stopTask(taskId) {
|
|
517
|
+
if (dead)
|
|
518
|
+
throw new Error(`${agent} agent process is gone`);
|
|
519
|
+
try {
|
|
520
|
+
await conn.extMethod(EXT_METHODS.stopAsyncTask, { sessionId, asyncTaskId: taskId });
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
fail(err, "cannot stop the task");
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
async setConfig(configId, value) {
|
|
527
|
+
if (dead)
|
|
528
|
+
throw new Error(`${agent} agent process is gone`);
|
|
529
|
+
try {
|
|
530
|
+
const res = await conn.setSessionConfigOption(typeof value === "boolean" ? { sessionId, configId, type: "boolean", value } : { sessionId, configId, value });
|
|
531
|
+
const options = configOptions(res.configOptions);
|
|
532
|
+
hooks.emit({ type: "config", options });
|
|
533
|
+
return options;
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
return fail(err, "refused the setting");
|
|
128
537
|
}
|
|
129
538
|
},
|
|
130
539
|
cancel() {
|
|
@@ -132,8 +541,23 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
|
|
|
132
541
|
void conn.cancel({ sessionId }).catch(() => { });
|
|
133
542
|
},
|
|
134
543
|
dispose() {
|
|
135
|
-
if (
|
|
136
|
-
|
|
544
|
+
if (dead)
|
|
545
|
+
return;
|
|
546
|
+
// Let the adapter close the session cleanly; the process goes either way.
|
|
547
|
+
const kill = () => {
|
|
548
|
+
if (!dead)
|
|
549
|
+
child.kill("SIGTERM");
|
|
550
|
+
};
|
|
551
|
+
const timer = setTimeout(kill, 1500);
|
|
552
|
+
timer.unref?.();
|
|
553
|
+
void conn
|
|
554
|
+
.closeSession({ sessionId })
|
|
555
|
+
.catch(() => { })
|
|
556
|
+
.finally(() => {
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
kill();
|
|
559
|
+
});
|
|
137
560
|
},
|
|
138
561
|
};
|
|
562
|
+
return backend;
|
|
139
563
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type { ChatEvent } from "./types.js";
|
|
1
|
+
import type { AgentCommand, Attachment, ChatEvent, ConfigOption, ElicitationAnswer, ElicitationField } from "./types.js";
|
|
2
|
+
/** An attachment as the backend gets it: where the file is, and what it is. */
|
|
3
|
+
export interface PromptFile extends Attachment {
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
2
6
|
/**
|
|
3
7
|
* A chat backend is one live agent conversation: prompt() runs a full turn
|
|
4
8
|
* (resolves when the agent finishes), streaming intermediate activity
|
|
@@ -17,6 +21,8 @@ export interface BackendHooks {
|
|
|
17
21
|
name: string;
|
|
18
22
|
kind?: string;
|
|
19
23
|
}>): Promise<string | null>;
|
|
24
|
+
/** The agent asks the user a question (form); resolves with the answer, or a decline/cancel. */
|
|
25
|
+
askElicitation(message: string, fields: ElicitationField[]): Promise<ElicitationAnswer>;
|
|
20
26
|
}
|
|
21
27
|
/**
|
|
22
28
|
* Optional per-chat model/effort overrides (from an agent definition).
|
|
@@ -37,10 +43,31 @@ export interface BackendTuning {
|
|
|
37
43
|
* what still needs a human; kraftwerk never answers for one.
|
|
38
44
|
*/
|
|
39
45
|
unattended?: boolean;
|
|
46
|
+
/** ACP agents: the session id to continue (from ChatMeta.sessions) instead of opening a new one. */
|
|
47
|
+
resume?: string;
|
|
40
48
|
}
|
|
41
49
|
export interface ChatBackend {
|
|
42
|
-
/**
|
|
43
|
-
|
|
50
|
+
/** The agent's own session id, when it has one to resume later (ACP agents). */
|
|
51
|
+
sessionId?: string;
|
|
52
|
+
/** This backend continues an earlier session: the agent still has the conversation in context. */
|
|
53
|
+
resumed?: boolean;
|
|
54
|
+
/** The resumed session turned out unusable at its first prompt — its id must not be resumed again. */
|
|
55
|
+
resumeFailed?: boolean;
|
|
56
|
+
/** Send one user message (plus files dropped into it); resolves with the stop reason at turn end. */
|
|
57
|
+
prompt(text: string, files?: PromptFile[]): Promise<string>;
|
|
58
|
+
/** Branch the conversation: a new session id with this session's history (ACP agents that offer session/fork). */
|
|
59
|
+
fork?(): Promise<string>;
|
|
60
|
+
/**
|
|
61
|
+
* Hand a message into the running turn instead of queueing it as the
|
|
62
|
+
* next prompt. "promptRequired": no turn is running — send it as a prompt.
|
|
63
|
+
*/
|
|
64
|
+
steer?(text: string, files?: PromptFile[]): Promise<"injected" | "promptRequired">;
|
|
65
|
+
/** Stop one background task without cancelling the turn. */
|
|
66
|
+
stopTask?(taskId: string): Promise<void>;
|
|
67
|
+
/** Change a session setting; resolves with the options as they now stand. */
|
|
68
|
+
setConfig?(configId: string, value: string | boolean): Promise<ConfigOption[]>;
|
|
69
|
+
/** Slash commands the agent offers, as last announced. */
|
|
70
|
+
commands?: AgentCommand[];
|
|
44
71
|
/** Interrupt the current turn (the running prompt() still resolves). */
|
|
45
72
|
cancel(): void;
|
|
46
73
|
/** Kill the agent subprocess, if any. */
|
|
@@ -14,7 +14,10 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
|
|
|
14
14
|
...(tuning.skillDirs ?? []).flatMap((dir) => ["--skill", dir]),
|
|
15
15
|
];
|
|
16
16
|
return {
|
|
17
|
-
prompt(text) {
|
|
17
|
+
prompt(text, files) {
|
|
18
|
+
// pi takes text only: name the files, they are on disk for it to open.
|
|
19
|
+
if (files?.length)
|
|
20
|
+
text += `\n\nAttached files:\n${files.map((f) => `- ${f.name} (${f.mimeType}, ${f.path})`).join("\n")}`;
|
|
18
21
|
cancelled = false;
|
|
19
22
|
return new Promise((resolve, reject) => {
|
|
20
23
|
const proc = spawn("pi", ["-p", "--mode", "json", "--session-id", sessionId, ...tuningArgs], {
|