@netnodeag/kraftwerk 0.43.0 → 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.
Files changed (33) hide show
  1. package/README.md +48 -0
  2. package/dist/acp.d.ts +131 -1
  3. package/dist/acp.js +163 -2
  4. package/dist/harnesses/acp.js +9 -5
  5. package/dist/inspector/chat/acp.d.ts +48 -2
  6. package/dist/inspector/chat/acp.js +471 -47
  7. package/dist/inspector/chat/backend.d.ts +30 -3
  8. package/dist/inspector/chat/pi.js +4 -1
  9. package/dist/inspector/chat/sessions.d.ts +60 -2
  10. package/dist/inspector/chat/sessions.js +274 -16
  11. package/dist/inspector/chat/store.d.ts +12 -0
  12. package/dist/inspector/chat/store.js +46 -0
  13. package/dist/inspector/chat/types.d.ts +185 -2
  14. package/dist/inspector/server.js +116 -5
  15. package/inspector/dist/assets/{dist-2tkXRwU5.js → dist-BBzscIhY.js} +1 -1
  16. package/inspector/dist/assets/{dist-Ck7zPi0Z.js → dist-BDk9auN5.js} +1 -1
  17. package/inspector/dist/assets/dist-BktrTMXT.js +1 -0
  18. package/inspector/dist/assets/{dist-LuoAT8wo.js → dist-BprKOWfC.js} +1 -1
  19. package/inspector/dist/assets/{dist-BoDJE1PN.js → dist-CLig8JSu.js} +1 -1
  20. package/inspector/dist/assets/{dist-CypKz49n.js → dist-Cq-0-mqe.js} +1 -1
  21. package/inspector/dist/assets/{dist-Rc-i9obk.js → dist-DVe7MUnQ.js} +1 -1
  22. package/inspector/dist/assets/{dist-C-Yu0i8M.js → dist-DqfkVJaW.js} +1 -1
  23. package/inspector/dist/assets/{dist-NNs7Vtl3.js → dist-S-LgiFkD.js} +1 -1
  24. package/inspector/dist/assets/{dist-FHTVbzBi.js → dist-SBBqiARl.js} +1 -1
  25. package/inspector/dist/assets/{dist-BX5KQfrx.js → dist-wCqvkQEl.js} +1 -1
  26. package/inspector/dist/assets/{editor-BI5xRg8C.js → editor-D__mCVTd.js} +3 -3
  27. package/inspector/dist/assets/index-DPdYC76U.css +1 -0
  28. package/inspector/dist/assets/index-De7hHAxt.js +219 -0
  29. package/inspector/dist/index.html +2 -2
  30. package/package.json +2 -2
  31. package/inspector/dist/assets/dist-Q3GFwcs6.js +0 -1
  32. package/inspector/dist/assets/index-BUp72TiG.js +0 -218
  33. package/inspector/dist/assets/index-h5Vh1ZaF.css +0 -1
@@ -1,53 +1,370 @@
1
- import { RequestError } from "@agentclientprotocol/sdk";
2
- import { adapterEnv, connectAcp } from "../../acp.js";
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. The
9
- * adapter plumbing is shared with the workflow ACP harness (src/acp.ts).
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 u = params.update;
19
- switch (u.sessionUpdate) {
20
- case "agent_message_chunk": {
21
- const text = contentText(u.content);
22
- if (text)
23
- hooks.emit({ type: "text", text });
24
- break;
25
- }
26
- case "agent_thought_chunk": {
27
- const text = contentText(u.content);
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 session = await conn.newSession({
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 sessionId = session.sessionId;
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
- return {
114
- async prompt(text) {
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: [{ type: "text", text }],
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
- if (err instanceof RequestError)
126
- throw new Error(`${agent} agent: ${err.message}`);
127
- throw err;
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 (!dead)
136
- child.kill("SIGTERM");
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
- /** Send one user message; resolves with the stop reason at turn end. */
43
- prompt(text: string): Promise<string>;
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], {