@netnodeag/kraftwerk 0.43.0 → 0.45.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 (38) hide show
  1. package/README.md +68 -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/runner.js +29 -4
  15. package/dist/inspector/runs.d.ts +5 -0
  16. package/dist/inspector/runs.js +56 -16
  17. package/dist/inspector/server.js +133 -7
  18. package/inspector/dist/assets/{dist-LuoAT8wo.js → dist-3XKl7K9N.js} +1 -1
  19. package/inspector/dist/assets/{dist-NNs7Vtl3.js → dist-BPzx-u5t.js} +1 -1
  20. package/inspector/dist/assets/dist-BnF1Rm2M.js +1 -0
  21. package/inspector/dist/assets/{dist-Rc-i9obk.js → dist-CGccQQRD.js} +1 -1
  22. package/inspector/dist/assets/{dist-C-Yu0i8M.js → dist-CIjxdHNW.js} +1 -1
  23. package/inspector/dist/assets/{dist-FHTVbzBi.js → dist-CdchVWU8.js} +1 -1
  24. package/inspector/dist/assets/{dist-2tkXRwU5.js → dist-DCZKo3cg.js} +1 -1
  25. package/inspector/dist/assets/{dist-CypKz49n.js → dist-DJnrih_T.js} +1 -1
  26. package/inspector/dist/assets/{dist-Ck7zPi0Z.js → dist-DWGd5Kj_.js} +1 -1
  27. package/inspector/dist/assets/{dist-BX5KQfrx.js → dist-DgaFP-8o.js} +1 -1
  28. package/inspector/dist/assets/{dist-BoDJE1PN.js → dist-bVw1IlxS.js} +1 -1
  29. package/inspector/dist/assets/{editor-BI5xRg8C.js → editor-DITzQiO1.js} +3 -3
  30. package/inspector/dist/assets/index-CS1G-A97.css +1 -0
  31. package/inspector/dist/assets/index-CiJ79dQy.js +219 -0
  32. package/inspector/dist/assets/roboto-latin-600-normal-CzqH9ZEY.woff2 +0 -0
  33. package/inspector/dist/assets/roboto-latin-600-normal-X4S5XP0J.woff +0 -0
  34. package/inspector/dist/index.html +2 -2
  35. package/package.json +2 -2
  36. package/inspector/dist/assets/dist-Q3GFwcs6.js +0 -1
  37. package/inspector/dist/assets/index-BUp72TiG.js +0 -218
  38. package/inspector/dist/assets/index-h5Vh1ZaF.css +0 -1
@@ -42,30 +42,213 @@ export interface ChatMeta {
42
42
  scope: ChatScope;
43
43
  /** Slug of the vibeable open in this chat's preview pane (cwd is its folder while set). */
44
44
  vibeable?: string;
45
+ /**
46
+ * The agent's own session id per seat ("main", or the agent slug in a
47
+ * channel): after a restart or the idle reaper, the next backend resumes
48
+ * it (session/resume) so the agent keeps its context instead of starting
49
+ * over with the transcript as a summary.
50
+ */
51
+ sessions?: Record<string, string>;
45
52
  createdAt: string;
46
53
  updatedAt: string;
47
54
  }
48
- /** One thread event; `seq` orders them and drives SSE resume (?after=seq). */
49
- export type ChatEvent = {
55
+ /** A context compaction the agent ran, as reported on its synthetic tool call (`_meta.contextCompaction`). */
56
+ export interface Compaction {
57
+ trigger?: "manual" | "automatic";
58
+ preTokens?: number;
59
+ postTokens?: number;
60
+ durationMs?: number;
61
+ error?: string;
62
+ }
63
+ /**
64
+ * A failure the agent's session ran into, as the harness reports it
65
+ * (AIR `sessionFailure`): what broke, how bad, and what a human can do.
66
+ * The same `id` with a higher `revision` replaces an earlier report.
67
+ */
68
+ export interface SessionFailure {
69
+ id: string;
70
+ revision: number;
71
+ category: "connection" | "access" | "limit" | "request" | "service" | "unknown";
72
+ severity: "warning" | "error";
73
+ title: string;
74
+ details?: string;
75
+ /** Machine-readable refinement of the failure (e.g. why a login was refused). */
76
+ reason?: string;
77
+ actions: Array<"retry" | "login" | "new_session">;
78
+ }
79
+ /** One step of the agent's plan (Claude's todo list), as ACP reports it. */
80
+ export interface PlanEntry {
81
+ content: string;
82
+ priority: "high" | "medium" | "low";
83
+ status: "pending" | "in_progress" | "completed";
84
+ }
85
+ /** A slash command the agent offers (`available_commands_update`). */
86
+ export interface AgentCommand {
87
+ name: string;
88
+ description: string;
89
+ /** What the command takes after its name, if anything. */
90
+ hint?: string;
91
+ }
92
+ /** One session setting the agent lets the client change (`configOptions`). */
93
+ export type ConfigOption = {
94
+ id: string;
95
+ name: string;
96
+ description?: string;
97
+ /** model | thought_level | model_config | mode | ... (agent-defined) */
98
+ category?: string;
99
+ } & ({
100
+ type: "select";
101
+ value: string;
102
+ choices: Array<{
103
+ value: string;
104
+ name: string;
105
+ description?: string;
106
+ group?: string;
107
+ }>;
108
+ } | {
109
+ type: "boolean";
110
+ value: boolean;
111
+ });
112
+ /** Who the agent is signed in as (`_auth/status_update`). */
113
+ export interface AuthStatus {
114
+ kind: "account" | "api_key" | "gateway" | "external" | "none";
115
+ label: string;
116
+ detail?: string;
117
+ email?: string;
118
+ organization?: string;
119
+ plan?: string;
120
+ }
121
+ /** One field of a question the agent asks (an ACP form elicitation, e.g. Claude's AskUserQuestion). */
122
+ export interface ElicitationField {
123
+ key: string;
124
+ title?: string;
125
+ description?: string;
126
+ kind: "select" | "multiselect" | "text" | "number" | "boolean";
127
+ options?: Array<{
128
+ value: string;
129
+ label: string;
130
+ description?: string;
131
+ }>;
132
+ required?: boolean;
133
+ /** A free-text alternative to the select above it ("Other"). */
134
+ custom?: boolean;
135
+ }
136
+ /** A file that travels with a message (dropped or pasted into the composer); stored under the chat's folder. */
137
+ export interface Attachment {
138
+ /** File name inside the chat's attachments/ folder (unique, safe). */
139
+ name: string;
140
+ mimeType: string;
141
+ size: number;
142
+ }
143
+ export type ElicitationAnswer = {
144
+ action: "accept";
145
+ content: Record<string, string | number | boolean | string[]>;
146
+ } | {
147
+ action: "decline";
148
+ } | {
149
+ action: "cancel";
150
+ };
151
+ /**
152
+ * One thread event; `seq` orders them and drives SSE resume (?after=seq).
153
+ * `subagent` on text/thought/tool events names the child session that
154
+ * produced them (see the `subagent` event); absent = the main agent.
155
+ */
156
+ export type ChatEvent =
157
+ /** `steered`: handed into the running turn instead of starting the next one. */
158
+ {
50
159
  type: "user_message";
51
160
  text: string;
161
+ steered?: boolean;
162
+ attachments?: Attachment[];
52
163
  } | {
53
164
  type: "text";
54
165
  text: string;
166
+ subagent?: string;
55
167
  } | {
56
168
  type: "thought";
57
169
  text: string;
170
+ subagent?: string;
58
171
  } | {
59
172
  type: "tool_call";
60
173
  callId: string;
61
174
  title: string;
62
175
  kind?: string;
63
176
  status?: string;
177
+ subagent?: string;
178
+ compaction?: Compaction;
64
179
  } | {
65
180
  type: "tool_update";
66
181
  callId: string;
67
182
  title?: string;
68
183
  status?: string;
184
+ subagent?: string;
185
+ compaction?: Compaction;
186
+ }
187
+ /** The agent delegated to a subagent running as its own session; its events follow tagged with `subagent: sessionId`. */
188
+ | {
189
+ type: "subagent";
190
+ sessionId: string;
191
+ name: string;
192
+ task: string;
193
+ } | {
194
+ type: "subagent_state";
195
+ sessionId: string;
196
+ state: "completed" | "failed" | "cancelled" | "disconnected";
197
+ }
198
+ /** Background work the agent started (a backgrounded shell, a monitor, a workflow) — outlives the tool call that started it. */
199
+ | {
200
+ type: "task";
201
+ taskId: string;
202
+ name: string;
203
+ taskType: string;
204
+ description: string;
205
+ toolCallId?: string;
206
+ canStop?: boolean;
207
+ } | ({
208
+ type: "failure";
209
+ } & SessionFailure)
210
+ /** The agent's current plan; null when it dropped it. Replaces the previous one. */
211
+ | {
212
+ type: "plan";
213
+ entries: PlanEntry[] | null;
214
+ }
215
+ /** Context window use (tokens used of size) and the session's cost so far. */
216
+ | {
217
+ type: "usage";
218
+ used: number;
219
+ size: number;
220
+ costUsd?: number;
221
+ } | {
222
+ type: "commands";
223
+ commands: AgentCommand[];
224
+ } | {
225
+ type: "config";
226
+ options: ConfigOption[];
227
+ } | ({
228
+ type: "auth";
229
+ } & AuthStatus)
230
+ /** What the agent says it changed this turn (AIR file change report); `unavailable` names why there is no list. */
231
+ | {
232
+ type: "files_changed";
233
+ paths: string[];
234
+ complete: boolean;
235
+ uncertainty?: string;
236
+ unavailable?: string;
237
+ } | {
238
+ type: "elicitation_request";
239
+ requestId: string;
240
+ message: string;
241
+ fields: ElicitationField[];
242
+ } | ({
243
+ type: "elicitation_resolved";
244
+ requestId: string;
245
+ } & ElicitationAnswer) | {
246
+ type: "task_update";
247
+ taskId: string;
248
+ state?: "running" | "paused" | "completed" | "failed" | "stopped";
249
+ description?: string;
250
+ summary?: string;
251
+ outputFilePath?: string;
69
252
  } | {
70
253
  type: "permission_request";
71
254
  requestId: string;
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { mkdirSync, openSync, closeSync } from "node:fs";
2
+ import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { getProjectRoot } from "./context.js";
5
5
  import { RUN_ID_RE, getRun, safeRunDir } from "./runs.js";
@@ -28,6 +28,8 @@ export function dockerStatus() {
28
28
  });
29
29
  return { available: true, image: image.status === 0 };
30
30
  }
31
+ /** Launchers this inspector started and that have not exited yet, by run id. */
32
+ const live = new Map();
31
33
  export function triggerRun(opts) {
32
34
  if (opts.sandbox) {
33
35
  const docker = dockerStatus();
@@ -55,10 +57,21 @@ export function triggerRun(opts) {
55
57
  });
56
58
  child.unref();
57
59
  closeSync(log);
60
+ live.set(runId, child);
58
61
  // Detached, but as long as the inspector lives it still hears the exit:
59
62
  // that is when the run's outcome goes to the bell (read from the trace,
60
- // the exit code alone does not say whether a gate blocked).
61
- child.on("exit", () => {
63
+ // the exit code alone does not say whether a gate blocked). The exit
64
+ // code also lands in trigger.json, so a launcher that died before the
65
+ // framework wrote a trace (missing env var, npx failure) shows as failed
66
+ // instead of running until the stale timeout.
67
+ child.on("exit", (code, signal) => {
68
+ live.delete(runId);
69
+ try {
70
+ writeFileSync(path.join(runDir, "trigger.json"), JSON.stringify({ exitCode: code ?? 1, signal, finishedAt: new Date().toISOString() }, null, 2) + "\n");
71
+ }
72
+ catch {
73
+ /* run dir removed meanwhile */
74
+ }
62
75
  void getRun(runId)
63
76
  .then((run) => {
64
77
  const ok = run?.status === "ok";
@@ -76,8 +89,20 @@ export function triggerRun(opts) {
76
89
  return { runId };
77
90
  }
78
91
  export function stopRun(runId) {
79
- // Sandboxed runs run in container kw-<runId>; docker stop ends them.
80
92
  if (!RUN_ID_RE.test(runId))
81
93
  return false;
94
+ // A local run this inspector launched: the launcher is detached into its
95
+ // own process group, so the negative pid ends npx and the framework under it.
96
+ const child = live.get(runId);
97
+ if (child?.pid && child.exitCode == null) {
98
+ try {
99
+ process.kill(-child.pid, "SIGTERM");
100
+ return true;
101
+ }
102
+ catch {
103
+ /* group already gone — fall through to docker */
104
+ }
105
+ }
106
+ // Sandboxed runs run in container kw-<runId>; docker stop ends them.
82
107
  return (spawnSync("docker", ["stop", `kw-${runId}`], { stdio: "ignore", timeout: 30_000 }).status === 0);
83
108
  }
@@ -58,3 +58,8 @@ export declare function readRunFile(id: string, name: string): Promise<{
58
58
  absPath: string;
59
59
  size: number;
60
60
  } | null>;
61
+ /**
62
+ * Remove a run folder. A run that still looks live is refused: its process
63
+ * would keep writing into a folder that no longer exists — stop it first.
64
+ */
65
+ export declare function deleteRun(id: string): Promise<"deleted" | "running" | "missing">;
@@ -29,7 +29,7 @@ async function readTrace(runDir) {
29
29
  }
30
30
  return events;
31
31
  }
32
- function analyse(events) {
32
+ function analyse(events, fallbackTs) {
33
33
  const runStart = events.find((e) => e.event === "run_start");
34
34
  const summary = events.find((e) => e.event === "run_summary");
35
35
  const phases = [];
@@ -118,42 +118,69 @@ function analyse(events) {
118
118
  }
119
119
  const failed = phases.some((p) => p.status === "failed" || p.status === "blocked");
120
120
  const lastTs = events.length ? events[events.length - 1].ts : undefined;
121
+ // A run folder without a trace (the launcher died before the framework
122
+ // wrote anything — missing env var, npx failure) has no event to age; the
123
+ // newest file in the folder stands in, so it cannot stay "running" forever.
124
+ const activityTs = lastTs ?? fallbackTs;
121
125
  let status;
122
126
  if (summary)
123
127
  status = failed ? "failed" : "ok";
124
128
  else if (failed)
125
129
  status = "failed";
126
- else if (lastTs && Date.now() - Date.parse(lastTs) > STALE_MS)
127
- status = "aborted";
130
+ else if (activityTs && Date.now() - Date.parse(activityTs) > STALE_MS)
131
+ status = events.length ? "aborted" : "failed";
128
132
  else
129
133
  status = "running";
130
134
  return { runStart, summary, phases, steps, status, lastTs };
131
135
  }
132
136
  /**
133
137
  * Sandboxed runs write runner.json (exit code recorded when the container
134
- * ends). If the trace looks "running" but the container already exited —
135
- * e.g. it crashed before any phase_end — trust the exit code instead of
136
- * waiting for the 15-minute stale timeout.
138
+ * ends) and UI-triggered runs write trigger.json (exit code of the launcher,
139
+ * see runner.ts). If the trace looks "running" but the process already
140
+ * exited — e.g. it crashed before any phase_end, or never wrote a trace —
141
+ * trust the exit code instead of waiting for the 15-minute stale timeout.
137
142
  */
138
143
  async function applyRunnerVerdict(runDir, a) {
139
144
  if (a.status !== "running")
140
145
  return a;
141
- try {
142
- const meta = JSON.parse(await fs.readFile(path.join(runDir, "runner.json"), "utf8"));
143
- if (meta.exitCode != null) {
146
+ for (const name of ["runner.json", "trigger.json"]) {
147
+ try {
148
+ const meta = JSON.parse(await fs.readFile(path.join(runDir, name), "utf8"));
149
+ if (meta.exitCode == null)
150
+ continue;
144
151
  a.status = meta.exitCode === 0 ? "ok" : "failed";
145
152
  if (meta.exitCode !== 0) {
146
153
  for (const p of a.phases)
147
154
  if (p.status === "running")
148
155
  p.status = "failed";
149
156
  }
157
+ return a;
158
+ }
159
+ catch {
160
+ /* file absent (local CLI run) or unreadable — try the next one */
150
161
  }
151
- }
152
- catch {
153
- /* no runner.json (local run) or unreadable — keep trace-based status */
154
162
  }
155
163
  return a;
156
164
  }
165
+ /** Newest mtime among the run folder's files — the age of a run that never wrote a trace. */
166
+ async function newestFileTs(runDir) {
167
+ const names = await fs.readdir(runDir).catch(() => []);
168
+ let newest = 0;
169
+ for (const name of names) {
170
+ const st = await fs.stat(path.join(runDir, name)).catch(() => null);
171
+ if (st && st.mtimeMs > newest)
172
+ newest = st.mtimeMs;
173
+ }
174
+ return newest ? new Date(newest).toISOString() : undefined;
175
+ }
176
+ /**
177
+ * The workflow slug baked into the run id ("2026-08-25-1432-07-<slug>", see
178
+ * newRunId) — the only name a run has when the trace was never written.
179
+ */
180
+ function workflowFromId(id) {
181
+ const m = /^\d{4}-\d{2}-\d{2}-\d{4}-\d{2}-(.+)$/.exec(id);
182
+ return m?.[1];
183
+ }
157
184
  function shortenPath(p) {
158
185
  const parts = p.split("/");
159
186
  return parts.length > 2 ? parts.slice(-2).join("/") : p;
@@ -171,12 +198,12 @@ export async function listRuns() {
171
198
  if (!st?.isDirectory())
172
199
  return null;
173
200
  const events = await readTrace(runDir);
174
- const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events));
201
+ const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events, events.length ? undefined : await newestFileTs(runDir)));
175
202
  const done = phases.filter((p) => p.status === "ok").length;
176
203
  const current = phases.find((p) => p.status === "running")?.phase;
177
204
  return {
178
205
  id,
179
- workflow: runStart?.workflow,
206
+ workflow: runStart?.workflow ?? workflowFromId(id),
180
207
  request: runStart?.request,
181
208
  status,
182
209
  startedAt: events[0]?.ts,
@@ -207,7 +234,7 @@ export async function getRun(id) {
207
234
  if (!st?.isDirectory())
208
235
  return null;
209
236
  const events = await readTrace(runDir);
210
- const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events));
237
+ const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events, events.length ? undefined : await newestFileTs(runDir)));
211
238
  const names = await fs.readdir(runDir);
212
239
  const files = [];
213
240
  for (const name of names) {
@@ -218,7 +245,7 @@ export async function getRun(id) {
218
245
  files.sort((a, b) => a.name.localeCompare(b.name));
219
246
  return {
220
247
  id,
221
- workflow: runStart?.workflow,
248
+ workflow: runStart?.workflow ?? workflowFromId(id),
222
249
  description: runStart?.description,
223
250
  request: runStart?.request,
224
251
  status,
@@ -244,3 +271,16 @@ export async function readRunFile(id, name) {
244
271
  return null;
245
272
  return { absPath, size: st.size };
246
273
  }
274
+ /**
275
+ * Remove a run folder. A run that still looks live is refused: its process
276
+ * would keep writing into a folder that no longer exists — stop it first.
277
+ */
278
+ export async function deleteRun(id) {
279
+ const run = await getRun(id);
280
+ if (!run)
281
+ return "missing";
282
+ if (run.status === "running")
283
+ return "running";
284
+ await fs.rm(safeRunDir(id), { recursive: true, force: true });
285
+ return "deleted";
286
+ }
@@ -1,15 +1,16 @@
1
1
  import { existsSync, promises as fs } from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
+ import { attachmentPath, saveAttachment } from "./chat/store.js";
4
5
  import { setOutputDir, setProjectRoot, getOutputDir, getProjectRoot } from "./context.js";
5
6
  import { resolveProject } from "../config.js";
6
- import { listRuns, getRun, readRunFile } from "./runs.js";
7
+ import { listRuns, getRun, readRunFile, deleteRun } from "./runs.js";
7
8
  import { listWorkflows, getWorkflow } from "./workflows.js";
8
9
  import { dockerStatus, triggerRun, stopRun } from "./runner.js";
9
10
  import { clearNotifications, listNotifications, markNotificationsRead } from "./notifications.js";
10
11
  import { startDiagnosis } from "./diagnose.js";
11
12
  import { deleteChannel, getChannel, listChannels, saveChannel, channelsRoot } from "./channels.js";
12
- import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
13
+ import { cancelChat, createChat, forkChat, resetSession, answerElicitation, steerChat, stopChatTask, setChatConfig, listAgentSessionsFor, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
13
14
  import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
14
15
  import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
15
16
  import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
@@ -54,6 +55,24 @@ function json(res, body, status = 200) {
54
55
  res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
55
56
  res.end(JSON.stringify(body));
56
57
  }
58
+ /** A raw upload body (attachments), capped at `max` bytes. */
59
+ function readRawBody(req, max) {
60
+ return new Promise((resolve, reject) => {
61
+ const chunks = [];
62
+ let size = 0;
63
+ req.on("data", (c) => {
64
+ size += c.length;
65
+ if (size > max) {
66
+ reject(new Error(`file too large (max ${Math.round(max / 1_000_000)} MB)`));
67
+ req.destroy();
68
+ return;
69
+ }
70
+ chunks.push(c);
71
+ });
72
+ req.on("end", () => resolve(Buffer.concat(chunks)));
73
+ req.on("error", reject);
74
+ });
75
+ }
57
76
  function readBody(req) {
58
77
  return new Promise((resolve, reject) => {
59
78
  let data = "";
@@ -618,7 +637,22 @@ async function handleApi(req, res, url) {
618
637
  if (seg.length === 4 && seg[1] === "runs" && seg[3] === "stop" && method === "POST") {
619
638
  return stopRun(seg[2])
620
639
  ? json(res, { stopped: true })
621
- : json(res, { error: "no running sandbox container for this run (local runs cannot be stopped here)" }, 404);
640
+ : json(res, { error: "nothing to stop: no launcher of this inspector and no sandbox container for this run" }, 404);
641
+ }
642
+ // DELETE /api/runs/:id — refused while the run is live (stop it first)
643
+ if (seg.length === 3 && seg[1] === "runs" && method === "DELETE") {
644
+ let outcome;
645
+ try {
646
+ outcome = await deleteRun(seg[2]);
647
+ }
648
+ catch {
649
+ return json(res, { error: "invalid run id" }, 400);
650
+ }
651
+ if (outcome === "missing")
652
+ return json(res, { error: "not found" }, 404);
653
+ if (outcome === "running")
654
+ return json(res, { error: "run is still running — stop it first" }, 409);
655
+ return json(res, { deleted: true });
622
656
  }
623
657
  // GET /api/workflows
624
658
  if (seg.length === 2 && seg[1] === "workflows" && method === "GET") {
@@ -902,13 +936,25 @@ async function handleApi(req, res, url) {
902
936
  return json(res, { error: "agent must be claude, codex, or pi" }, 400);
903
937
  }
904
938
  try {
905
- return json(res, await createChat({ agent, scope }));
939
+ return json(res, await createChat({ agent, scope, ...(typeof body.resume === "string" && body.resume ? { resume: body.resume } : {}) }));
906
940
  }
907
941
  catch (err) {
908
942
  return json(res, { error: err.message }, 400);
909
943
  }
910
944
  }
911
945
  }
946
+ // GET /api/agent-sessions?agent=claude|codex — the agent's own sessions in the project root
947
+ if (seg.length === 2 && seg[1] === "agent-sessions" && method === "GET") {
948
+ const agent = url.searchParams.get("agent") ?? "claude";
949
+ if (!["claude", "codex", "pi"].includes(agent))
950
+ return json(res, { error: "agent must be claude, codex, or pi" }, 400);
951
+ try {
952
+ return json(res, { sessions: await listAgentSessionsFor(agent) });
953
+ }
954
+ catch (err) {
955
+ return json(res, { error: err.message }, 502);
956
+ }
957
+ }
912
958
  // DELETE /api/chats/:id
913
959
  if (seg.length === 3 && seg[1] === "chats" && method === "DELETE") {
914
960
  try {
@@ -968,6 +1014,60 @@ async function handleApi(req, res, url) {
968
1014
  return json(res, { error: err.message }, 400);
969
1015
  }
970
1016
  }
1017
+ // POST /api/chats/:id/fork — a new chat continuing a copy of this one's session
1018
+ if (seg.length === 4 && seg[1] === "chats" && seg[3] === "fork" && method === "POST") {
1019
+ try {
1020
+ const result = await forkChat(seg[2]);
1021
+ return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
1022
+ }
1023
+ catch (err) {
1024
+ return json(res, { error: err.message }, 400);
1025
+ }
1026
+ }
1027
+ // POST /api/chats/:id/attachments — raw file body; x-file-name names it. Returns the stored attachment.
1028
+ if (seg.length === 4 && seg[1] === "chats" && seg[3] === "attachments" && method === "POST") {
1029
+ try {
1030
+ if (!(await getChat(seg[2])))
1031
+ return json(res, { error: "not found" }, 404);
1032
+ const data = await readRawBody(req, 25_000_000);
1033
+ if (data.length === 0)
1034
+ return json(res, { error: "empty file" }, 400);
1035
+ const mimeType = (req.headers["content-type"] ?? "application/octet-stream").split(";")[0].trim();
1036
+ const name = await saveAttachment(seg[2], decodeURIComponent(String(req.headers["x-file-name"] ?? "file")), data);
1037
+ return json(res, { name, mimeType, size: data.length });
1038
+ }
1039
+ catch (err) {
1040
+ return json(res, { error: err.message }, 400);
1041
+ }
1042
+ }
1043
+ // GET /api/chats/:id/attachments/:name — the stored file
1044
+ if (seg.length === 5 && seg[1] === "chats" && seg[3] === "attachments" && method === "GET") {
1045
+ try {
1046
+ const file = attachmentPath(seg[2], seg[4]);
1047
+ const ext = path.extname(file).toLowerCase();
1048
+ const data = await fs.readFile(file);
1049
+ res.writeHead(200, {
1050
+ "content-type": MIME[ext] ?? "application/octet-stream",
1051
+ "content-length": data.length,
1052
+ "cache-control": "private, max-age=86400",
1053
+ "x-content-type-options": "nosniff",
1054
+ });
1055
+ return void res.end(data);
1056
+ }
1057
+ catch {
1058
+ return json(res, { error: "not found" }, 404);
1059
+ }
1060
+ }
1061
+ // POST /api/chats/:id/reset-session — forget the agent's session; the next message starts fresh
1062
+ if (seg.length === 4 && seg[1] === "chats" && seg[3] === "reset-session" && method === "POST") {
1063
+ try {
1064
+ const result = await resetSession(seg[2]);
1065
+ return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
1066
+ }
1067
+ catch (err) {
1068
+ return json(res, { error: err.message }, 400);
1069
+ }
1070
+ }
971
1071
  // POST /api/chats/:id/{message,permission,cancel}
972
1072
  if (seg.length === 4 && seg[1] === "chats" && method === "POST") {
973
1073
  let body = {};
@@ -981,18 +1081,44 @@ async function handleApi(req, res, url) {
981
1081
  }
982
1082
  try {
983
1083
  let result;
1084
+ const attachments = Array.isArray(body.attachments)
1085
+ ? body.attachments
1086
+ .filter((a) => a && typeof a.name === "string" && typeof a.mimeType === "string")
1087
+ .map((a) => ({ name: String(a.name), mimeType: String(a.mimeType), size: Number(a.size) || 0 }))
1088
+ : [];
984
1089
  if (seg[3] === "message") {
985
1090
  const text = String(body.text ?? "").trim();
986
- if (!text)
1091
+ if (!text && attachments.length === 0)
987
1092
  return json(res, { error: "text is required" }, 400);
988
1093
  // Channels: the poster's display name signs the message.
989
- result = await postMessage(seg[2], text, { from: typeof body.from === "string" ? body.from : undefined });
1094
+ result = await postMessage(seg[2], text || "(see attached)", { from: typeof body.from === "string" ? body.from : undefined, attachments });
990
1095
  }
991
1096
  else if (seg[3] === "permission") {
992
1097
  result = await resolvePermission(seg[2], String(body.requestId ?? ""), body.optionId == null ? null : String(body.optionId));
993
1098
  }
994
1099
  else if (seg[3] === "cancel") {
995
- result = await cancelChat(seg[2]);
1100
+ // Channels: `agent` stops one member; without it every agent in the chat.
1101
+ result = await cancelChat(seg[2], typeof body.agent === "string" && body.agent ? body.agent : undefined);
1102
+ }
1103
+ else if (seg[3] === "steer") {
1104
+ const text = String(body.text ?? "").trim();
1105
+ if (!text && attachments.length === 0)
1106
+ return json(res, { error: "text is required" }, 400);
1107
+ result = await steerChat(seg[2], text || "(see attached)", attachments);
1108
+ }
1109
+ else if (seg[3] === "elicitation") {
1110
+ const action = body.action === "accept" || body.action === "decline" || body.action === "cancel" ? body.action : null;
1111
+ if (!action)
1112
+ return json(res, { error: "action must be accept, decline, or cancel" }, 400);
1113
+ const content = body.content && typeof body.content === "object" ? body.content : {};
1114
+ result = await answerElicitation(seg[2], String(body.requestId ?? ""), action === "accept" ? { action, content } : { action });
1115
+ }
1116
+ else if (seg[3] === "task-stop") {
1117
+ result = await stopChatTask(seg[2], String(body.taskId ?? ""));
1118
+ }
1119
+ else if (seg[3] === "config") {
1120
+ const value = typeof body.value === "boolean" ? body.value : String(body.value ?? "");
1121
+ result = await setChatConfig(seg[2], String(body.configId ?? ""), value);
996
1122
  }
997
1123
  else {
998
1124
  return json(res, { error: "not found" }, 404);