antechamber 0.1.1 → 0.2.1

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 (59) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +24 -14
  3. package/bin/opencode-antechamber +14 -1
  4. package/bin/opencode-pi +11 -1
  5. package/dist/approvals.d.ts +4 -1
  6. package/dist/approvals.js +51 -33
  7. package/dist/catalog.d.ts +1 -1
  8. package/dist/catalog.js +1 -1
  9. package/dist/commands/context.d.ts +0 -1
  10. package/dist/commands/context.js +1 -1
  11. package/dist/commands/doctor.d.ts +3 -2
  12. package/dist/commands/serve.d.ts +2 -1
  13. package/dist/commands/setup.d.ts +2 -3
  14. package/dist/commands/setup.js +16 -4
  15. package/dist/http.d.ts +8 -1
  16. package/dist/http.js +42 -19
  17. package/dist/pi-event-translation.d.ts +10 -6
  18. package/dist/pi-event-translation.js +237 -80
  19. package/dist/pi-rpc.d.ts +20 -2
  20. package/dist/pi-rpc.js +96 -29
  21. package/dist/prompt-parts.d.ts +40 -0
  22. package/dist/prompt-parts.js +157 -0
  23. package/dist/route-services.d.ts +45 -2
  24. package/dist/route-services.js +70 -3
  25. package/dist/routes-v1.js +113 -65
  26. package/dist/routes-v2.js +407 -95
  27. package/dist/server.d.ts +9 -1
  28. package/dist/server.js +109 -9
  29. package/dist/session-commands.d.ts +12 -15
  30. package/dist/session-commands.js +54 -13
  31. package/dist/session-diff.d.ts +1 -1
  32. package/dist/session-diff.js +10 -2
  33. package/dist/session-history.d.ts +1 -1
  34. package/dist/session-history.js +110 -44
  35. package/dist/session-hosts.js +2 -2
  36. package/dist/session-store.d.ts +8 -4
  37. package/dist/session-store.js +191 -38
  38. package/dist/session-tree.d.ts +10 -2
  39. package/dist/session-tree.js +55 -26
  40. package/dist/sessions.d.ts +32 -5
  41. package/dist/sessions.js +331 -120
  42. package/dist/sse.d.ts +16 -6
  43. package/dist/sse.js +77 -21
  44. package/dist/subagents.d.ts +5 -0
  45. package/dist/subagents.js +84 -71
  46. package/dist/tool-map.d.ts +3 -2
  47. package/dist/types.d.ts +152 -1
  48. package/dist/v2.d.ts +18 -4
  49. package/dist/v2.js +128 -44
  50. package/dist/vcs.d.ts +5 -0
  51. package/dist/vcs.js +199 -0
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/extensions/omo-dag/README.md +2 -0
  55. package/extensions/omo-dag/docs/DESIGN.md +10 -1
  56. package/extensions/omo-dag/panel/index.html +1 -1
  57. package/extensions/omo-dag/panel/main.js +60 -60
  58. package/extensions/omo-dag/service/main.js +72 -8
  59. package/package.json +1 -1
package/dist/routes-v2.js CHANGED
@@ -1,34 +1,89 @@
1
1
  /** OpenCode v2 (/api/*) routes and their response adapters. */
2
+ import { execFile } from "node:child_process";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { tmpdir } from "node:os";
4
- import { errorBody, json } from "./http.js";
5
- import { debugLog } from "./misc-routes.js";
5
+ import { promisify } from "node:util";
6
+ import { errorBody, json, RequestBodyError } from "./http.js";
7
+ import { nativePromptInput, parsePromptParts, v2PromptAttachments, v2PromptParts } from "./prompt-parts.js";
8
+ import { debugLog, miscRoute } from "./misc-routes.js";
6
9
  import { OPENCODE_API_VERSION } from "./version.js";
7
10
  import { toV2Permission } from "./approvals.js";
8
11
  import { formQuestionAnswer, questionFormID, toQuestionForm } from "./question-forms.js";
9
- import { getAgentName, getExposedAgentNames } from "./agent-name.js";
10
- import { agentDefaults } from "./catalog.js";
11
- import { announceSessionError, compactSession, getCommands, parseShellCommand, parseSlashCommand, runShellCommand, runSlashCommand, unsupportedShare, } from "./session-commands.js";
12
+ import { getAgentName } from "./agent-name.js";
13
+ import { vcsRead, VcsError } from "./vcs.js";
14
+ import { announceSessionError, compactSession, getCommands, unsupportedShare } from "./session-commands.js";
12
15
  import { defaultV2Model, flattenModels, toV2Message, toV2Session } from "./v2.js";
13
- import { modelRefFromBody, numberParam, OPENCODE_COMPAT_VERSION, promptAsync, } from "./route-services.js";
16
+ import { agentDescriptors, modelRefFromBody, numberParam, OPENCODE_COMPAT_VERSION, promptAsync, runSessionShell, runSessionSlash, sessionDirectory, sessionMetadata, } from "./route-services.js";
14
17
  async function v2Agents(services) {
15
- const piName = getAgentName();
16
- const defaults = agentDefaults(await services.catalog());
17
- return getExposedAgentNames().map((name) => ({
18
- id: name,
19
- name,
20
- description: name === piName
21
- ? `${piName} (pi coding agent via antechamber)`
22
- : `${name} — ${piName} (pi coding agent via antechamber)`,
23
- mode: "primary",
24
- hidden: name === "build" && piName !== "build",
25
- options: {},
26
- ...defaults,
18
+ return (await agentDescriptors(services)).map((agent) => ({
19
+ id: agent.name,
20
+ ...agent,
21
+ request: { settings: {}, headers: {}, body: {} },
22
+ permissions: agent.permission.map((rule) => ({
23
+ action: rule.permission,
24
+ resource: rule.pattern,
25
+ effect: rule.action,
26
+ })),
27
+ system: agent.prompt,
27
28
  }));
28
29
  }
29
30
  function v2Session(services, info) {
30
31
  const managed = services.sessions.get(info.id);
31
- return toV2Session(info, { model: managed?.model, agent: getAgentName() });
32
+ return toV2Session(info, { model: managed?.model, agent: info.agent ?? getAgentName() });
33
+ }
34
+ const execFileAsync = promisify(execFile);
35
+ function encodeSessionCursor(session) {
36
+ return Buffer.from(JSON.stringify({ updated: session.time.updated, id: session.id })).toString("base64url");
37
+ }
38
+ function decodeSessionCursor(value) {
39
+ if (value === null || value === "")
40
+ return undefined;
41
+ try {
42
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
43
+ if (typeof parsed === "object" &&
44
+ parsed !== null &&
45
+ "updated" in parsed &&
46
+ "id" in parsed &&
47
+ typeof parsed.updated === "number" &&
48
+ Number.isFinite(parsed.updated) &&
49
+ typeof parsed.id === "string") {
50
+ return { updated: parsed.updated, id: parsed.id };
51
+ }
52
+ }
53
+ catch {
54
+ // A malformed continuation cursor starts a fresh listing, as OpenCode's
55
+ // clients may retain a cursor across a restarted local server.
56
+ }
57
+ return undefined;
58
+ }
59
+ function compareSessions(left, right, order) {
60
+ const comparison = left.time.updated - right.time.updated || left.id.localeCompare(right.id);
61
+ return order === "asc" ? comparison : -comparison;
62
+ }
63
+ function followsCursor(session, cursor, order) {
64
+ const comparison = session.time.updated - cursor.updated || session.id.localeCompare(cursor.id);
65
+ return order === "asc" ? comparison > 0 : comparison < 0;
66
+ }
67
+ async function currentGitBranch(directory) {
68
+ try {
69
+ const { stdout } = await execFileAsync("git", ["symbolic-ref", "--short", "HEAD"], {
70
+ cwd: directory,
71
+ timeout: 3000,
72
+ });
73
+ return stdout.trim() || undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ async function initializeGit(directory) {
80
+ try {
81
+ await execFileAsync("git", ["init", "-q"], { cwd: directory, timeout: 3000 });
82
+ return true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
32
87
  }
33
88
  /** v2 `/api/project` list: the adapter has no project registry, so expose the
34
89
  * directories it knows about (the default plus every restorable session). */
@@ -40,14 +95,13 @@ function v2Projects(services) {
40
95
  return [...dirs].map((dir) => ({
41
96
  id: dir,
42
97
  canonical: dir,
43
- time: { created: now, updated: now },
98
+ time: { created: now, updated: now, active: now },
44
99
  sandboxes: [],
100
+ vcs: "git",
45
101
  }));
46
102
  }
47
- export async function handleV2(services, req, res, url, path, method, directory) {
48
- const location = { directory };
49
- const withLocation = (data) => ({ location, data });
50
- // --- bootstrap ---
103
+ async function handleV2Bootstrap(services, res, path, method, directory) {
104
+ const withLocation = (data) => ({ location: { directory }, data });
51
105
  if (path === "/api/info" && method === "GET") {
52
106
  json(res, 200, {
53
107
  version: OPENCODE_COMPAT_VERSION ?? OPENCODE_API_VERSION,
@@ -55,73 +109,170 @@ export async function handleV2(services, req, res, url, path, method, directory)
55
109
  urls: [services.url],
56
110
  paths: { tmp: tmpdir() },
57
111
  });
58
- return;
112
+ return true;
59
113
  }
60
114
  if (path === "/api/location" && method === "GET") {
61
115
  json(res, 200, { directory, project: { id: directory, directory, canonical: directory } });
62
- return;
116
+ return true;
63
117
  }
64
118
  if (path === "/api/config" && method === "GET") {
65
- json(res, 200, withLocation({ default_agent: getAgentName() }));
66
- return;
119
+ // OpenCode 2.x returns a bare ConfigEntry[]; OpenChamber folds each document's `info` into one config.
120
+ json(res, 200, [{ type: "document", path: "antechamber", info: { default_agent: getAgentName() } }]);
121
+ return true;
67
122
  }
68
123
  if (path === "/api/provider" && method === "GET") {
69
124
  const cat = await services.catalog();
70
125
  json(res, 200, withLocation(cat.providers.map((p) => ({ ...p, activation: "enabled" }))));
71
- return;
126
+ return true;
72
127
  }
73
128
  if (path === "/api/model" && method === "GET") {
74
129
  const cat = await services.catalog();
75
130
  json(res, 200, withLocation(flattenModels(cat)));
76
- return;
131
+ return true;
77
132
  }
78
133
  if (path === "/api/model/default" && method === "GET") {
79
134
  const cat = await services.catalog();
80
135
  json(res, 200, withLocation(defaultV2Model(cat) ?? null));
81
- return;
136
+ return true;
82
137
  }
83
138
  if (path === "/api/agent" && method === "GET") {
84
139
  json(res, 200, withLocation(await v2Agents(services)));
85
- return;
140
+ return true;
86
141
  }
87
142
  const agentMatch = path.match(/^\/api\/agent\/([^/]+)$/);
88
143
  if (agentMatch && method === "GET") {
89
144
  const agentId = decodeURIComponent(agentMatch[1]);
90
145
  const agent = (await v2Agents(services)).find((a) => a.id === agentId);
91
- if (!agent) {
146
+ if (!agent)
92
147
  json(res, 404, errorBody("NotFoundError", `agent not found: ${agentId}`));
93
- return;
94
- }
95
- json(res, 200, withLocation(agent));
96
- return;
148
+ else
149
+ json(res, 200, withLocation(agent));
150
+ return true;
97
151
  }
98
152
  if (path === "/api/command" && method === "GET") {
99
- json(res, 200, withLocation(await getCommands(directory)));
100
- return;
153
+ json(res, 200, withLocation((await getCommands(directory)).map((command) => ({
154
+ ...command,
155
+ source: "command",
156
+ hints: [],
157
+ subtask: false,
158
+ }))));
159
+ return true;
101
160
  }
102
161
  if (path === "/api/skill" && method === "GET") {
103
162
  json(res, 200, withLocation([]));
163
+ return true;
164
+ }
165
+ return false;
166
+ }
167
+ export async function handleV2(services, req, res, url, path, method, directory) {
168
+ const location = { directory };
169
+ const withLocation = (data) => ({ location, data });
170
+ // --- bootstrap ---
171
+ if (await handleV2Bootstrap(services, res, path, method, directory))
172
+ return;
173
+ if (path === "/api/debug/location" && method === "DELETE") {
174
+ // OpenChamber evicts a location after git worktree changes so OpenCode re-reads it. antechamber
175
+ // keeps no per-location instance (project and VCS reads are fresh per request): nothing to evict.
176
+ res.writeHead(204).end();
104
177
  return;
105
178
  }
106
179
  // --- project / vcs / permission / form (2.0.4 bootstrap) ---
180
+ if (path === "/api/credential" && method === "GET") {
181
+ json(res, 200, withLocation([]));
182
+ return;
183
+ }
184
+ const credentialMatch = path.match(/^\/api\/credential\/([^/]+)(\/activate)?$/);
185
+ if ((path === "/api/credential" && method === "POST") ||
186
+ (credentialMatch && method === (credentialMatch[2] ? "POST" : "PATCH"))) {
187
+ await services.body(req);
188
+ json(res, 400, errorBody("UnsupportedError", "Credential management is not available; configure providers in OmO"));
189
+ return;
190
+ }
191
+ if (credentialMatch && !credentialMatch[2] && method === "DELETE") {
192
+ res.writeHead(204).end();
193
+ return;
194
+ }
195
+ if (path === "/api/integration" && method === "GET") {
196
+ json(res, 200, withLocation([]));
197
+ return;
198
+ }
199
+ const integrationMatch = path.match(/^\/api\/integration\/([^/]+)$/);
200
+ if (integrationMatch && method === "GET") {
201
+ json(res, 404, errorBody("NotFoundError", `integration not found: ${decodeURIComponent(integrationMatch[1])}`));
202
+ return;
203
+ }
204
+ if ((method === "POST" && /^\/api\/integration\/[^/]+\/connect\/(key|external|oauth|command)$/.test(path)) ||
205
+ ((method === "GET" || method === "DELETE") &&
206
+ /^\/api\/integration\/[^/]+\/connect\/(oauth|command)\/[^/]+$/.test(path)) ||
207
+ (method === "POST" && /^\/api\/integration\/[^/]+\/connect\/oauth\/[^/]+\/complete$/.test(path))) {
208
+ json(res, 400, errorBody("UnsupportedError", "Integration connections are not available; configure providers in OmO"));
209
+ return;
210
+ }
211
+ if ((path === "/api/plugin" && method === "GET") || (path === "/api/plugin/check" && method === "POST")) {
212
+ if (method === "POST")
213
+ await services.body(req);
214
+ json(res, 200, withLocation([]));
215
+ return;
216
+ }
217
+ if (path === "/api/plugin/update" && method === "POST") {
218
+ json(res, 400, errorBody("UnsupportedError", "Plugin updates are not available with OmO"));
219
+ return;
220
+ }
221
+ if (path === "/api/experimental/generate" && method === "POST") {
222
+ json(res, 503, {
223
+ _tag: "ServiceUnavailableError",
224
+ message: "No generation model is available through antechamber/OmO",
225
+ service: "opencode",
226
+ });
227
+ return;
228
+ }
229
+ // Read-only inventories OpenChamber loads at bootstrap: OmO has no background shells,
230
+ // web-search providers or MCP servers, so each is an honest empty list (mutations stay 400).
231
+ if ((path === "/api/shell" || path === "/api/websearch/provider" || path === "/api/mcp") && method === "GET") {
232
+ json(res, 200, withLocation([]));
233
+ return;
234
+ }
235
+ if (path === "/api/experimental/session/stats" && method === "GET") {
236
+ json(res, 400, errorBody("UnsupportedError", "Session statistics are not available with OmO"));
237
+ return;
238
+ }
239
+ if (path === "/api/mcp" || path.startsWith("/api/mcp/") || path.startsWith("/api/experimental/mcp/")) {
240
+ json(res, 400, errorBody("UnsupportedError", "MCP is not available with OmO"));
241
+ return;
242
+ }
243
+ if (path === "/api/fs/find" && method === "GET") {
244
+ const result = await miscRoute("GET", "/find/file", url, directory);
245
+ if (result === undefined) {
246
+ json(res, 500, errorBody("UnknownError", "file search route is unavailable"));
247
+ return;
248
+ }
249
+ if (result.status !== 200) {
250
+ json(res, result.status, result.body);
251
+ return;
252
+ }
253
+ const files = Array.isArray(result.body)
254
+ ? result.body
255
+ .filter((entry) => typeof entry === "string")
256
+ .map((path) => ({ path, type: "file" }))
257
+ : [];
258
+ json(res, 200, withLocation(files));
259
+ return;
260
+ }
107
261
  if (path === "/api/project" && method === "GET") {
108
262
  json(res, 200, v2Projects(services));
109
263
  return;
110
264
  }
111
265
  const projectMatch = path.match(/^\/api\/project\/([^/]+)$/);
112
266
  if (projectMatch && method === "PATCH") {
113
- const projectId = decodeURIComponent(projectMatch[1]);
114
- const body = (await services.body(req));
115
- json(res, 200, {
116
- id: projectId,
117
- canonical: body.canonical ?? projectId,
118
- time: { created: 0, updated: Date.now() },
119
- sandboxes: [],
120
- });
267
+ await services.body(req);
268
+ json(res, 400, errorBody("UnsupportedError", "Project updates are not available with OmO"));
121
269
  return;
122
270
  }
123
271
  if (path === "/api/permission/request" && method === "GET") {
124
- json(res, 200, withLocation(services.sessions.permissions().map(toV2Permission)));
272
+ json(res, 200, withLocation(services.sessions
273
+ .permissions()
274
+ .filter((permission) => services.sessions.get(permission.sessionID)?.info.directory === directory)
275
+ .map((permission) => ({ ...toV2Permission(permission), ...permission }))));
125
276
  return;
126
277
  }
127
278
  if (path === "/api/question/request" && method === "GET") {
@@ -140,46 +291,76 @@ export async function handleV2(services, req, res, url, path, method, directory)
140
291
  json(res, 200, withLocation(services.sessions.questions().map(toQuestionForm)));
141
292
  return;
142
293
  }
143
- if (path === "/api/vcs" && method === "GET") {
144
- json(res, 200, withLocation({ branch: {} }));
145
- return;
146
- }
147
- if (path === "/api/vcs/base" && method === "GET") {
148
- json(res, 200, withLocation(null));
149
- return;
150
- }
151
- if (path === "/api/vcs/status" && method === "GET") {
152
- json(res, 200, withLocation([]));
294
+ if (path === "/api/vcs/init" && method === "POST") {
295
+ if (!(await initializeGit(directory))) {
296
+ json(res, 400, errorBody("BadRequestError", "Git initialization failed"));
297
+ return;
298
+ }
299
+ res.writeHead(204).end();
153
300
  return;
154
301
  }
155
- if (path === "/api/vcs/branch" && method === "GET") {
156
- json(res, 200, withLocation([]));
302
+ if (path === "/api/vcs" && method === "GET") {
303
+ const current = await currentGitBranch(directory);
304
+ json(res, 200, withLocation({ provider: "git", branch: current === undefined ? {} : { current } }));
157
305
  return;
158
306
  }
159
- if (path === "/api/vcs/diff" && method === "GET") {
160
- json(res, 200, withLocation(""));
307
+ if (method === "GET" && ["/api/vcs/base", "/api/vcs/status", "/api/vcs/branch", "/api/vcs/diff"].includes(path)) {
308
+ try {
309
+ json(res, 200, withLocation(await vcsRead(directory, path.slice("/api/vcs/".length), url)));
310
+ }
311
+ catch (error) {
312
+ if (!(error instanceof VcsError))
313
+ throw error;
314
+ json(res, 400, errorBody(error.name, error.message));
315
+ }
161
316
  return;
162
317
  }
163
318
  // --- sessions ---
164
319
  if (path === "/api/session" && method === "GET") {
165
320
  const dir = url.searchParams.get("directory") ?? url.searchParams.get("location");
166
321
  const search = url.searchParams.get("search");
322
+ const parentID = url.searchParams.get("parentID");
323
+ const order = url.searchParams.get("order") === "asc" ? "asc" : "desc";
167
324
  const limit = numberParam(url.searchParams.get("limit"));
168
325
  let infos = dir !== null && dir !== "" ? services.sessions.list(dir) : services.sessions.listAll();
169
326
  if (search)
170
327
  infos = infos.filter((s) => s.title.toLowerCase().includes(search.toLowerCase()));
171
- const sessions = infos.map((info) => v2Session(services, info));
172
- sessions.sort((a, b) => b.time.updated - a.time.updated);
328
+ if (parentID !== null)
329
+ infos = infos.filter((session) => session.parentID === parentID);
330
+ let sessions = infos.map((info) => v2Session(services, info));
331
+ sessions.sort((left, right) => compareSessions(left, right, order));
332
+ const cursor = decodeSessionCursor(url.searchParams.get("cursor"));
333
+ if (cursor !== undefined)
334
+ sessions = sessions.filter((session) => followsCursor(session, cursor, order));
173
335
  const page = limit !== undefined ? sessions.slice(0, limit) : sessions;
174
336
  // The 2.0.4 UI reads `cursor.previous` unconditionally — a list body
175
337
  // without a cursor throws "Cannot read properties of undefined".
176
- json(res, 200, { data: page, cursor: { previous: null, next: null } });
338
+ const next = limit !== undefined && page.length < sessions.length && page.at(-1) !== undefined
339
+ ? encodeSessionCursor(page.at(-1))
340
+ : null;
341
+ json(res, 200, { data: page, cursor: { previous: null, next } });
177
342
  return;
178
343
  }
179
344
  if (path === "/api/session" && method === "POST") {
180
345
  const body = (await services.body(req));
181
- const dir = body.location?.directory ?? directory;
182
- const info = await services.sessions.create(dir, body.title, body.parentID);
346
+ for (const field of ["id", "agent", "title", "parentID"]) {
347
+ if (body[field] !== undefined && typeof body[field] !== "string")
348
+ throw new RequestBodyError(400, `${field} must be a string`);
349
+ }
350
+ if (body.location !== undefined &&
351
+ (typeof body.location !== "object" || body.location === null || Array.isArray(body.location)))
352
+ throw new RequestBodyError(400, "location must be an object");
353
+ const dir = sessionDirectory(body.location?.directory ?? directory);
354
+ const metadata = sessionMetadata(body.metadata);
355
+ const model = modelRefFromBody(body.model);
356
+ if (body.model !== undefined && model === undefined)
357
+ throw new RequestBodyError(400, "model requires providerID and id or modelID");
358
+ const info = await services.sessions.create(dir, body.title, body.parentID, {
359
+ ...(body.id !== undefined ? { id: body.id } : {}),
360
+ ...(body.agent !== undefined ? { agent: body.agent } : {}),
361
+ ...(model !== undefined ? { model } : {}),
362
+ ...(metadata !== undefined ? { metadata } : {}),
363
+ });
183
364
  json(res, 200, { data: v2Session(services, info) });
184
365
  return;
185
366
  }
@@ -285,7 +466,8 @@ export async function handleV2(services, req, res, url, path, method, directory)
285
466
  const requestId = decodeURIComponent(permissionMatch[1]);
286
467
  if (permissionMatch[2] === "/reply" && method === "POST") {
287
468
  const body = (await services.body(req));
288
- if (!body.reply || !services.sessions.replyPermission(sessionId, requestId, body.reply, body.message)) {
469
+ const decision = body.decision ?? body.reply;
470
+ if (!decision || !services.sessions.replyPermission(sessionId, requestId, decision, body.message)) {
289
471
  json(res, 404, errorBody("PermissionNotFoundError", `permission not found: ${requestId}`));
290
472
  return;
291
473
  }
@@ -302,6 +484,11 @@ export async function handleV2(services, req, res, url, path, method, directory)
302
484
  }
303
485
  }
304
486
  switch (`${method} ${sub}`) {
487
+ case "POST /generate":
488
+ case "POST /background":
489
+ case "POST /move":
490
+ json(res, 400, errorBody("UnsupportedError", `Session ${sub.slice(1)} is not available with OmO`));
491
+ return;
305
492
  case "GET /todo":
306
493
  json(res, 200, { data: services.sessions.todos(sessionId) });
307
494
  return;
@@ -311,7 +498,11 @@ export async function handleV2(services, req, res, url, path, method, directory)
311
498
  }
312
499
  case "PATCH ": {
313
500
  const body = (await services.body(req));
314
- services.sessions.update(sessionId, { title: body.title, archived: body.time?.archived });
501
+ await services.sessions.update(sessionId, {
502
+ title: body.title,
503
+ archived: body.time?.archived,
504
+ metadata: sessionMetadata(body.metadata),
505
+ });
315
506
  res.writeHead(204).end();
316
507
  return;
317
508
  }
@@ -326,11 +517,21 @@ export async function handleV2(services, req, res, url, path, method, directory)
326
517
  let messages = (services.sessions.messages(sessionId) ?? []).map((m) => toV2Message(m));
327
518
  if (order !== "asc")
328
519
  messages = messages.reverse(); // v2 default is newest-first
329
- if (limit !== undefined)
330
- messages = messages.slice(0, limit);
331
- json(res, 200, { data: messages, cursor: { previous: null, next: null } });
520
+ const cursor = url.searchParams.get("cursor");
521
+ if (cursor !== null) {
522
+ const boundary = messages.findIndex((message) => message.id === cursor);
523
+ if (boundary >= 0)
524
+ messages = messages.slice(boundary + 1);
525
+ }
526
+ const page = limit === undefined ? messages : messages.slice(0, limit);
527
+ const last = page.at(-1);
528
+ json(res, 200, {
529
+ data: page,
530
+ cursor: { previous: null, next: last && page.length < messages.length ? last.id : null },
531
+ });
332
532
  return;
333
533
  }
534
+ case "POST /compact":
334
535
  case "POST /summarize": {
335
536
  await services.body(req);
336
537
  const result = await compactSession(services.sessions, sessionId);
@@ -343,38 +544,34 @@ export async function handleV2(services, req, res, url, path, method, directory)
343
544
  return;
344
545
  }
345
546
  case "POST /shell": {
346
- const command = parseShellCommand(await services.body(req));
347
- if (command === undefined) {
547
+ const result = await runSessionShell(services, managed, await services.body(req));
548
+ if (result.kind === "input") {
348
549
  json(res, 400, errorBody("BadRequestError", "command is required"));
349
550
  return;
350
551
  }
351
- if (managed.status.type !== "idle") {
552
+ if (result.kind === "busy") {
352
553
  json(res, 409, errorBody("SessionBusyError", "session is busy"));
353
554
  return;
354
555
  }
355
- const result = await runShellCommand({
356
- sessions: services.sessions,
357
- session: managed,
358
- command,
359
- emit: services.broadcast,
360
- });
361
556
  if (result.kind === "error") {
362
557
  json(res, 400, errorBody("BadRequestError", result.message));
363
558
  return;
364
559
  }
365
- json(res, 200, { data: toV2Message(result.message) });
560
+ res.writeHead(204).end();
366
561
  return;
367
562
  }
368
563
  case "POST /command": {
369
- const input = parseSlashCommand(await services.body(req));
370
- if (input === undefined) {
564
+ const body = (await services.body(req));
565
+ const result = await runSessionSlash(services, sessionId, {
566
+ command: body.name,
567
+ arguments: body.text ?? "",
568
+ messageID: body.id,
569
+ });
570
+ if (result.kind === "input") {
371
571
  json(res, 400, errorBody("BadRequestError", "command is required"));
372
572
  return;
373
573
  }
374
- const message = await runSlashCommand(services.sessions, sessionId, input);
375
- json(res, 200, {
376
- data: message === undefined ? { info: null, parts: [] } : { info: toV2Message(message), parts: message.parts },
377
- });
574
+ res.writeHead(204).end();
378
575
  return;
379
576
  }
380
577
  case "POST /share":
@@ -384,7 +581,38 @@ export async function handleV2(services, req, res, url, path, method, directory)
384
581
  return;
385
582
  case "POST /prompt": {
386
583
  const body = (await services.body(req));
387
- const text = typeof body.text === "string" ? body.text : "";
584
+ if (typeof body.text !== "string") {
585
+ json(res, 400, { _tag: "InvalidRequestError", message: "text must be a string" });
586
+ return;
587
+ }
588
+ const explicit = parsePromptParts(body.parts);
589
+ const text = body.text;
590
+ const parts = explicit.length > 0
591
+ ? [...explicit, ...v2PromptParts("", body.files, body.agents)]
592
+ : v2PromptParts(text, body.files, body.agents);
593
+ if (text.trim() === "" && !parts.some((part) => part.type === "file" || part.type === "agent")) {
594
+ json(res, 400, {
595
+ _tag: "InvalidRequestError",
596
+ message: "prompt requires non-whitespace text, files, or agents",
597
+ });
598
+ return;
599
+ }
600
+ const metadata = sessionMetadata(body.metadata);
601
+ if (body.skills !== undefined) {
602
+ if (!Array.isArray(body.skills))
603
+ throw new RequestBodyError(400, "skills must be an array");
604
+ const skill = body.skills[0];
605
+ if (skill !== undefined) {
606
+ if (typeof skill !== "object" || skill === null || !("id" in skill) || typeof skill.id !== "string")
607
+ throw new RequestBodyError(400, "skills must contain {id} references");
608
+ // GET /api/skill lists none, so any reference is unknown. OpenChamber retries without
609
+ // the attachment on exactly this tagged OpenCode error.
610
+ json(res, 400, { _tag: "InvalidRequestError", message: `Skill not found: ${skill.id}` });
611
+ return;
612
+ }
613
+ }
614
+ // Reject an attachment the native RPC cannot carry before acknowledging the prompt.
615
+ nativePromptInput(text, parts);
388
616
  const messageID = typeof body.id === "string" && body.id !== "" ? body.id : `msg_${randomUUID().replaceAll("-", "")}`;
389
617
  const model = modelRefFromBody(body.model ?? (body.variant !== undefined ? managed.model : undefined), body.variant);
390
618
  if (body.delivery !== undefined && body.delivery !== "steer" && body.delivery !== "queue") {
@@ -392,7 +620,7 @@ export async function handleV2(services, req, res, url, path, method, directory)
392
620
  return;
393
621
  }
394
622
  const delivery = body.delivery ?? "steer";
395
- promptAsync(services, managed, text, model, false, messageID, delivery);
623
+ promptAsync(services, managed, text, model, false, messageID, delivery, parts.length > 0 ? parts : undefined, metadata);
396
624
  json(res, 200, {
397
625
  data: {
398
626
  id: messageID,
@@ -400,19 +628,97 @@ export async function handleV2(services, req, res, url, path, method, directory)
400
628
  time: { created: Date.now() },
401
629
  type: "user",
402
630
  text,
403
- payload: { text },
631
+ payload: { text, ...v2PromptAttachments(parts) },
404
632
  delivery,
633
+ ...(metadata !== undefined ? { metadata } : {}),
634
+ },
635
+ });
636
+ return;
637
+ }
638
+ case "POST /synthetic":
639
+ case "POST /context": {
640
+ const body = (await services.body(req));
641
+ if (typeof body.text !== "string" || body.text.trim() === "") {
642
+ json(res, 400, { _tag: "InvalidRequestError", message: "text must be a non-whitespace string" });
643
+ return;
644
+ }
645
+ if (body.delivery !== undefined && body.delivery !== "steer" && body.delivery !== "queue") {
646
+ json(res, 400, errorBody("BadRequestError", 'delivery must be "steer" or "queue"'));
647
+ return;
648
+ }
649
+ const metadata = sessionMetadata(body.metadata);
650
+ const messageID = typeof body.id === "string" && body.id !== "" ? body.id : `msg_${randomUUID().replaceAll("-", "")}`;
651
+ const text = body.text;
652
+ promptAsync(services, managed, text, undefined, true, messageID, body.delivery ?? "steer", undefined, metadata);
653
+ // OpenChamber 2.3.2 treats anything but 200 + the admitted record as a failed send (captured OpenCode shape).
654
+ json(res, 200, {
655
+ data: {
656
+ id: messageID,
657
+ sessionID: sessionId,
658
+ time: { created: Date.now() },
659
+ type: "synthetic",
660
+ payload: { text },
405
661
  },
406
662
  });
407
663
  return;
408
664
  }
409
665
  case "POST /interrupt": {
410
- await services.sessions.abort(sessionId);
411
- json(res, 200, { data: true });
666
+ const interrupted = await services.sessions.abort(sessionId);
667
+ // Bare body, as captured from OpenCode 2.x: OpenChamber reads `result.interrupted` directly.
668
+ json(res, 200, { interrupted });
669
+ return;
670
+ }
671
+ case "POST /fork":
672
+ case "POST /revert":
673
+ case "POST /revert/stage":
674
+ case "POST /revert/commit":
675
+ case "DELETE /revert": {
676
+ const body = method === "DELETE" ? {} : await services.body(req);
677
+ const target = typeof body === "object" && body !== null
678
+ ? "before" in body
679
+ ? body.before
680
+ : "messageID" in body
681
+ ? body.messageID
682
+ : undefined
683
+ : undefined;
684
+ if ((sub === "/revert" || sub === "/revert/stage") &&
685
+ method === "POST" &&
686
+ (typeof target !== "string" || target === "")) {
687
+ json(res, 400, errorBody("InvalidRequestError", "messageID is required"));
688
+ return;
689
+ }
690
+ try {
691
+ if (sub === "/fork") {
692
+ if (target !== undefined && (typeof target !== "string" || target === ""))
693
+ throw new Error("before must be a nonempty message id");
694
+ const info = await services.sessions.fork(sessionId, typeof target === "string" ? target : undefined);
695
+ json(res, 200, { data: v2Session(services, info) });
696
+ }
697
+ else if (method === "DELETE") {
698
+ await services.sessions.unrevert(sessionId);
699
+ res.writeHead(204).end();
700
+ }
701
+ else if (sub === "/revert/commit") {
702
+ await services.sessions.commitRevert(sessionId);
703
+ res.writeHead(204).end();
704
+ }
705
+ else {
706
+ const info = await services.sessions.revert(sessionId, String(target));
707
+ json(res, 200, { data: info.revert });
708
+ }
709
+ }
710
+ catch (error) {
711
+ json(res, 400, errorBody("SessionTreeError", error instanceof Error ? error.message : String(error)));
712
+ }
412
713
  return;
413
714
  }
414
715
  case "POST /agent": {
415
- await services.body(req); // agent is fixed per connector — acknowledge and ignore
716
+ const body = (await services.body(req));
717
+ if (typeof body.agent !== "string") {
718
+ json(res, 400, errorBody("InvalidRequestError", "agent must be a string"));
719
+ return;
720
+ }
721
+ await services.sessions.update(sessionId, { agent: body.agent });
416
722
  res.writeHead(204).end();
417
723
  return;
418
724
  }
@@ -462,7 +768,13 @@ export async function handleV2(services, req, res, url, path, method, directory)
462
768
  return;
463
769
  }
464
770
  case "GET /diff":
465
- json(res, 200, { data: await services.sessions.diff(sessionId) });
771
+ json(res, 200, {
772
+ data: await services.sessions.diff(sessionId, {
773
+ from: url.searchParams.get("from") ?? undefined,
774
+ to: url.searchParams.get("to") ?? undefined,
775
+ context: numberParam(url.searchParams.get("context")),
776
+ }),
777
+ });
466
778
  return;
467
779
  case "GET /context": {
468
780
  json(res, 501, errorBody("NotImplemented", `${method} ${path} is not implemented by this connector`));