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