@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.
Files changed (42) hide show
  1. package/README.md +55 -6
  2. package/dist/acp.d.ts +131 -1
  3. package/dist/acp.js +163 -2
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +1 -1
  6. package/dist/harnesses/acp.js +9 -5
  7. package/dist/inspector/channels.d.ts +3 -0
  8. package/dist/inspector/channels.js +12 -1
  9. package/dist/inspector/chat/acp.d.ts +48 -2
  10. package/dist/inspector/chat/acp.js +471 -47
  11. package/dist/inspector/chat/backend.d.ts +30 -3
  12. package/dist/inspector/chat/pi.js +4 -1
  13. package/dist/inspector/chat/sessions.d.ts +60 -2
  14. package/dist/inspector/chat/sessions.js +326 -16
  15. package/dist/inspector/chat/store.d.ts +12 -0
  16. package/dist/inspector/chat/store.js +46 -0
  17. package/dist/inspector/chat/types.d.ts +185 -2
  18. package/dist/inspector/git.js +8 -5
  19. package/dist/inspector/instances.d.ts +5 -7
  20. package/dist/inspector/instances.js +14 -10
  21. package/dist/inspector/search.d.ts +7 -5
  22. package/dist/inspector/search.js +5 -2
  23. package/dist/inspector/server.js +117 -6
  24. package/inspector/dist/assets/{dist-Cn-NK0yx.js → dist-BBzscIhY.js} +1 -1
  25. package/inspector/dist/assets/{dist-BnxCrUQZ.js → dist-BDk9auN5.js} +1 -1
  26. package/inspector/dist/assets/dist-BktrTMXT.js +1 -0
  27. package/inspector/dist/assets/{dist-DkSZQh-S.js → dist-BprKOWfC.js} +1 -1
  28. package/inspector/dist/assets/{dist-BpgTFX4N.js → dist-CLig8JSu.js} +1 -1
  29. package/inspector/dist/assets/{dist-OsHgcj_x.js → dist-Cq-0-mqe.js} +1 -1
  30. package/inspector/dist/assets/{dist-z049FsVf.js → dist-DVe7MUnQ.js} +1 -1
  31. package/inspector/dist/assets/{dist-DQbdG8Cf.js → dist-DqfkVJaW.js} +1 -1
  32. package/inspector/dist/assets/{dist-C8aIUi2c.js → dist-S-LgiFkD.js} +1 -1
  33. package/inspector/dist/assets/{dist-cZmo-9Se.js → dist-SBBqiARl.js} +1 -1
  34. package/inspector/dist/assets/{dist-BJxiusVh.js → dist-wCqvkQEl.js} +1 -1
  35. package/inspector/dist/assets/{editor-BQkS4m10.js → editor-D__mCVTd.js} +3 -3
  36. package/inspector/dist/assets/index-DPdYC76U.css +1 -0
  37. package/inspector/dist/assets/index-De7hHAxt.js +219 -0
  38. package/inspector/dist/index.html +2 -2
  39. package/package.json +2 -2
  40. package/inspector/dist/assets/dist-C6Q5FWM0.js +0 -1
  41. package/inspector/dist/assets/index-DGlIITXq.js +0 -218
  42. package/inspector/dist/assets/index-DuZ9U4BZ.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;
@@ -6,8 +6,8 @@ import { ENV_FILE } from "../runner/docker.js";
6
6
  import { getOutputDir, getProjectRoot } from "./context.js";
7
7
  /**
8
8
  * Workspace git sync. Several people run kraftwerk against one git repo, so
9
- * knowledge, agents, skills and workflows travel between them like any other
10
- * tracked file. The inspector shows what changed, lets a human pick files and
9
+ * knowledge, agents, skills, channels and workflows travel between them like
10
+ * any other tracked file. The inspector shows what changed, lets a human pick files and
11
11
  * commit them, and pushes on request.
12
12
  *
13
13
  * Deliberate limits, because this runs git on someone's repo from a web UI
@@ -203,9 +203,9 @@ async function checkedOut(repo) {
203
203
  }
204
204
  /**
205
205
  * The roots that may be committed, as repo-relative paths: the workflows,
206
- * knowledge, agents, skills and vibeables roots this project declares, plus
207
- * kraftwerk.yml. The output directory is excluded even when it lives inside
208
- * one of them.
206
+ * knowledge, agents, skills, channels and vibeables roots this project
207
+ * declares, plus kraftwerk.yml and the project README. The output directory
208
+ * is excluded even when it lives inside one of them.
209
209
  */
210
210
  function scopeFor({ project, repoRoot }) {
211
211
  // rev-parse reports the repo root with symlinks resolved; the project root
@@ -230,9 +230,12 @@ function scopeFor({ project, repoRoot }) {
230
230
  path.resolve(project.root, project.config.knowledge ?? "knowledge"),
231
231
  path.resolve(project.root, project.config.agents ?? "agents"),
232
232
  path.resolve(project.root, project.config.skills ?? "skills"),
233
+ // Channels have no configurable root: channels/ under the project.
234
+ path.resolve(project.root, "channels"),
233
235
  // Vibeables are the workspace's own apps: versioned with it, unlike clones.
234
236
  vibeablesRootFor(project),
235
237
  project.configPath,
238
+ path.resolve(project.root, "README.md"),
236
239
  ];
237
240
  const mapped = roots.filter((r) => !!r).map(rel).filter((r) => !!r);
238
241
  // Clones under the repos root are other people's history, never this
@@ -1,4 +1,5 @@
1
1
  import type { AgentSummary } from "./agents.js";
2
+ import type { ChannelSummary } from "./channels.js";
2
3
  /** Durable per-project record. The root is the key; everything else derives from it. */
3
4
  export interface ProjectRecord {
4
5
  root: string;
@@ -9,6 +10,8 @@ export interface ProjectRecord {
9
10
  startCount: number;
10
11
  /** Active agents, as last seen by the instance serving this root. */
11
12
  agents?: AgentSummary[];
13
+ /** Channels, as last seen by the instance serving this root. */
14
+ channels?: ChannelSummary[];
12
15
  }
13
16
  /** A verified running instance, shaped like a switcher entry. */
14
17
  export interface DiscoveredInstance {
@@ -58,13 +61,8 @@ export declare function unregisterInstance(): void;
58
61
  export declare function discoverInstances(): Promise<DiscoveredInstance[]>;
59
62
  /** Upsert the project record for a root (call on every inspector start). */
60
63
  export declare function registerProject(root: string): Promise<void>;
61
- /**
62
- * Write the roster into the project's record. Called on every roster read
63
- * (the UI polls it, routines tick it), so a record only changes when the
64
- * roster did. Roots with no record (a CLI run in a project that never
65
- * started the inspector) are left alone.
66
- */
67
- export declare function syncProjectAgents(root: string, agents: AgentSummary[]): Promise<void>;
64
+ export declare const syncProjectAgents: (root: string, agents: AgentSummary[]) => Promise<void>;
65
+ export declare const syncProjectChannels: (root: string, channels: ChannelSummary[]) => Promise<void>;
68
66
  /** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
69
67
  export declare function markProjectStopped(): void;
70
68
  /** All known projects, most recently started first. */
@@ -145,6 +145,7 @@ async function readProject(file) {
145
145
  lastStopped: rec.lastStopped,
146
146
  startCount: rec.startCount ?? 1,
147
147
  ...(Array.isArray(rec.agents) ? { agents: rec.agents } : {}),
148
+ ...(Array.isArray(rec.channels) ? { channels: rec.channels } : {}),
148
149
  };
149
150
  }
150
151
  catch {
@@ -164,32 +165,35 @@ export async function registerProject(root) {
164
165
  lastStarted: now,
165
166
  startCount: (prev?.startCount ?? 0) + 1,
166
167
  ...(prev?.agents ? { agents: prev.agents } : {}),
168
+ ...(prev?.channels ? { channels: prev.channels } : {}),
167
169
  };
168
170
  await fs.writeFile(projectFile(abs), JSON.stringify(rec, null, 2));
169
171
  }
170
172
  catch { } // best-effort, like the instance file
171
173
  }
172
- let syncedAgents = "";
174
+ const synced = new Map();
173
175
  /**
174
- * Write the roster into the project's record. Called on every roster read
175
- * (the UI polls it, routines tick it), so a record only changes when the
176
- * roster did. Roots with no record (a CLI run in a project that never
177
- * started the inspector) are left alone.
176
+ * Write the roster (or the channel list) into the project's record. Called
177
+ * on every read (the UI polls it, routines tick it), so a record only
178
+ * changes when the list did. Roots with no record (a CLI run in a project
179
+ * that never started the inspector) are left alone.
178
180
  */
179
- export async function syncProjectAgents(root, agents) {
180
- const key = `${root}\n${JSON.stringify(agents)}`;
181
- if (key === syncedAgents)
181
+ async function syncProjectList(root, key, list) {
182
+ const stamp = `${root}\n${JSON.stringify(list)}`;
183
+ if (synced.get(key) === stamp)
182
184
  return;
183
185
  try {
184
186
  const file = projectFile(root);
185
187
  const rec = await readProject(file);
186
188
  if (!rec)
187
189
  return;
188
- await fs.writeFile(file, JSON.stringify({ ...rec, agents }, null, 2));
189
- syncedAgents = key;
190
+ await fs.writeFile(file, JSON.stringify({ ...rec, [key]: list }, null, 2));
191
+ synced.set(key, stamp);
190
192
  }
191
193
  catch { }
192
194
  }
195
+ export const syncProjectAgents = (root, agents) => syncProjectList(root, "agents", agents);
196
+ export const syncProjectChannels = (root, channels) => syncProjectList(root, "channels", channels);
193
197
  /** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
194
198
  export function markProjectStopped() {
195
199
  if (!selfRoot)
@@ -1,10 +1,11 @@
1
1
  import { type AgentSummary } from "./agents.js";
2
+ import { type ChannelSummary } from "./channels.js";
2
3
  /**
3
- * The ⌘K palette: every active agent in every workspace this machine
4
- * knows, so a user can jump to one without switching workspaces first.
5
- * This instance reads its own roster from disk; the others come from the
6
- * project registry, where each instance records its roster whenever it
7
- * reads it (see instances.ts). Stopped workspaces are listed too — the
4
+ * The ⌘K palette: every active agent and every channel in every workspace
5
+ * this machine knows, so a user can jump to one without switching
6
+ * workspaces first. This instance reads its own lists from disk; the others
7
+ * come from the project registry, where each instance records them
8
+ * whenever it reads them (see instances.ts). Stopped workspaces are listed too — the
8
9
  * palette starts them on the way to the agent.
9
10
  */
10
11
  export interface WorkspaceAgents {
@@ -18,6 +19,7 @@ export interface WorkspaceAgents {
18
19
  current: boolean;
19
20
  live: boolean;
20
21
  agents: AgentSummary[];
22
+ channels: ChannelSummary[];
21
23
  }
22
24
  export interface AgentSearch {
23
25
  workspaces: WorkspaceAgents[];
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { resolveProject } from "../config.js";
3
3
  import { getProjectRoot } from "./context.js";
4
4
  import { listAgents, toSummary } from "./agents.js";
5
+ import { listChannels, toChannelSummary } from "./channels.js";
5
6
  import { currentInstanceUrl, discoverWorkspaces, listProjects } from "./instances.js";
6
7
  export async function searchAgents() {
7
8
  const project = await resolveProject(getProjectRoot()).catch(() => null);
@@ -13,9 +14,10 @@ export async function searchAgents() {
13
14
  current: true,
14
15
  live: true,
15
16
  agents: (await listAgents().catch(() => [])).filter((a) => !a.archived).map(toSummary),
17
+ channels: (await listChannels().catch(() => [])).map(toChannelSummary),
16
18
  };
17
19
  const [workspaces, projects] = await Promise.all([discoverWorkspaces(), listProjects()]);
18
- const rosters = new Map(projects.map((p) => [p.root, p.agents ?? []]));
20
+ const records = new Map(projects.map((p) => [p.root, p]));
19
21
  const others = workspaces
20
22
  .filter((w) => w.root && w.exists !== false)
21
23
  .map((w) => ({
@@ -25,7 +27,8 @@ export async function searchAgents() {
25
27
  root: w.root,
26
28
  current: false,
27
29
  live: w.live,
28
- agents: rosters.get(w.root) ?? [],
30
+ agents: records.get(w.root)?.agents ?? [],
31
+ channels: records.get(w.root)?.channels ?? [],
29
32
  }));
30
33
  return { workspaces: [self, ...others] };
31
34
  }
@@ -1,6 +1,7 @@
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
7
  import { listRuns, getRun, readRunFile } from "./runs.js";
@@ -9,7 +10,7 @@ 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 = "";
@@ -472,7 +491,7 @@ async function handleApi(req, res, url) {
472
491
  return json(res, { error: err.message }, 400);
473
492
  }
474
493
  }
475
- // GET /api/search/agents — active agents of every reachable workspace (the ⌘K palette)
494
+ // GET /api/search/agents — active agents and channels of every reachable workspace (the ⌘K palette)
476
495
  if (seg.length === 3 && seg[1] === "search" && seg[2] === "agents" && method === "GET") {
477
496
  return json(res, await searchAgents());
478
497
  }
@@ -902,13 +921,25 @@ async function handleApi(req, res, url) {
902
921
  return json(res, { error: "agent must be claude, codex, or pi" }, 400);
903
922
  }
904
923
  try {
905
- return json(res, await createChat({ agent, scope }));
924
+ return json(res, await createChat({ agent, scope, ...(typeof body.resume === "string" && body.resume ? { resume: body.resume } : {}) }));
906
925
  }
907
926
  catch (err) {
908
927
  return json(res, { error: err.message }, 400);
909
928
  }
910
929
  }
911
930
  }
931
+ // GET /api/agent-sessions?agent=claude|codex — the agent's own sessions in the project root
932
+ if (seg.length === 2 && seg[1] === "agent-sessions" && method === "GET") {
933
+ const agent = url.searchParams.get("agent") ?? "claude";
934
+ if (!["claude", "codex", "pi"].includes(agent))
935
+ return json(res, { error: "agent must be claude, codex, or pi" }, 400);
936
+ try {
937
+ return json(res, { sessions: await listAgentSessionsFor(agent) });
938
+ }
939
+ catch (err) {
940
+ return json(res, { error: err.message }, 502);
941
+ }
942
+ }
912
943
  // DELETE /api/chats/:id
913
944
  if (seg.length === 3 && seg[1] === "chats" && method === "DELETE") {
914
945
  try {
@@ -968,6 +999,60 @@ async function handleApi(req, res, url) {
968
999
  return json(res, { error: err.message }, 400);
969
1000
  }
970
1001
  }
1002
+ // POST /api/chats/:id/fork — a new chat continuing a copy of this one's session
1003
+ if (seg.length === 4 && seg[1] === "chats" && seg[3] === "fork" && method === "POST") {
1004
+ try {
1005
+ const result = await forkChat(seg[2]);
1006
+ return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
1007
+ }
1008
+ catch (err) {
1009
+ return json(res, { error: err.message }, 400);
1010
+ }
1011
+ }
1012
+ // POST /api/chats/:id/attachments — raw file body; x-file-name names it. Returns the stored attachment.
1013
+ if (seg.length === 4 && seg[1] === "chats" && seg[3] === "attachments" && method === "POST") {
1014
+ try {
1015
+ if (!(await getChat(seg[2])))
1016
+ return json(res, { error: "not found" }, 404);
1017
+ const data = await readRawBody(req, 25_000_000);
1018
+ if (data.length === 0)
1019
+ return json(res, { error: "empty file" }, 400);
1020
+ const mimeType = (req.headers["content-type"] ?? "application/octet-stream").split(";")[0].trim();
1021
+ const name = await saveAttachment(seg[2], decodeURIComponent(String(req.headers["x-file-name"] ?? "file")), data);
1022
+ return json(res, { name, mimeType, size: data.length });
1023
+ }
1024
+ catch (err) {
1025
+ return json(res, { error: err.message }, 400);
1026
+ }
1027
+ }
1028
+ // GET /api/chats/:id/attachments/:name — the stored file
1029
+ if (seg.length === 5 && seg[1] === "chats" && seg[3] === "attachments" && method === "GET") {
1030
+ try {
1031
+ const file = attachmentPath(seg[2], seg[4]);
1032
+ const ext = path.extname(file).toLowerCase();
1033
+ const data = await fs.readFile(file);
1034
+ res.writeHead(200, {
1035
+ "content-type": MIME[ext] ?? "application/octet-stream",
1036
+ "content-length": data.length,
1037
+ "cache-control": "private, max-age=86400",
1038
+ "x-content-type-options": "nosniff",
1039
+ });
1040
+ return void res.end(data);
1041
+ }
1042
+ catch {
1043
+ return json(res, { error: "not found" }, 404);
1044
+ }
1045
+ }
1046
+ // POST /api/chats/:id/reset-session — forget the agent's session; the next message starts fresh
1047
+ if (seg.length === 4 && seg[1] === "chats" && seg[3] === "reset-session" && method === "POST") {
1048
+ try {
1049
+ const result = await resetSession(seg[2]);
1050
+ return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
1051
+ }
1052
+ catch (err) {
1053
+ return json(res, { error: err.message }, 400);
1054
+ }
1055
+ }
971
1056
  // POST /api/chats/:id/{message,permission,cancel}
972
1057
  if (seg.length === 4 && seg[1] === "chats" && method === "POST") {
973
1058
  let body = {};
@@ -981,18 +1066,44 @@ async function handleApi(req, res, url) {
981
1066
  }
982
1067
  try {
983
1068
  let result;
1069
+ const attachments = Array.isArray(body.attachments)
1070
+ ? body.attachments
1071
+ .filter((a) => a && typeof a.name === "string" && typeof a.mimeType === "string")
1072
+ .map((a) => ({ name: String(a.name), mimeType: String(a.mimeType), size: Number(a.size) || 0 }))
1073
+ : [];
984
1074
  if (seg[3] === "message") {
985
1075
  const text = String(body.text ?? "").trim();
986
- if (!text)
1076
+ if (!text && attachments.length === 0)
987
1077
  return json(res, { error: "text is required" }, 400);
988
1078
  // Channels: the poster's display name signs the message.
989
- result = await postMessage(seg[2], text, { from: typeof body.from === "string" ? body.from : undefined });
1079
+ result = await postMessage(seg[2], text || "(see attached)", { from: typeof body.from === "string" ? body.from : undefined, attachments });
990
1080
  }
991
1081
  else if (seg[3] === "permission") {
992
1082
  result = await resolvePermission(seg[2], String(body.requestId ?? ""), body.optionId == null ? null : String(body.optionId));
993
1083
  }
994
1084
  else if (seg[3] === "cancel") {
995
- result = await cancelChat(seg[2]);
1085
+ // Channels: `agent` stops one member; without it every agent in the chat.
1086
+ result = await cancelChat(seg[2], typeof body.agent === "string" && body.agent ? body.agent : undefined);
1087
+ }
1088
+ else if (seg[3] === "steer") {
1089
+ const text = String(body.text ?? "").trim();
1090
+ if (!text && attachments.length === 0)
1091
+ return json(res, { error: "text is required" }, 400);
1092
+ result = await steerChat(seg[2], text || "(see attached)", attachments);
1093
+ }
1094
+ else if (seg[3] === "elicitation") {
1095
+ const action = body.action === "accept" || body.action === "decline" || body.action === "cancel" ? body.action : null;
1096
+ if (!action)
1097
+ return json(res, { error: "action must be accept, decline, or cancel" }, 400);
1098
+ const content = body.content && typeof body.content === "object" ? body.content : {};
1099
+ result = await answerElicitation(seg[2], String(body.requestId ?? ""), action === "accept" ? { action, content } : { action });
1100
+ }
1101
+ else if (seg[3] === "task-stop") {
1102
+ result = await stopChatTask(seg[2], String(body.taskId ?? ""));
1103
+ }
1104
+ else if (seg[3] === "config") {
1105
+ const value = typeof body.value === "boolean" ? body.value : String(body.value ?? "");
1106
+ result = await setChatConfig(seg[2], String(body.configId ?? ""), value);
996
1107
  }
997
1108
  else {
998
1109
  return json(res, { error: "not found" }, 404);