antechamber 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +143 -0
- package/LICENSE +22 -0
- package/README.md +316 -2
- package/bin/opencode-antechamber +18 -0
- package/bin/opencode-pi +3 -0
- package/dist/agent-dir.d.ts +6 -0
- package/dist/agent-dir.js +21 -0
- package/dist/agent-name.d.ts +24 -0
- package/dist/agent-name.js +110 -0
- package/dist/approvals.d.ts +86 -0
- package/dist/approvals.js +520 -0
- package/dist/catalog.d.ts +88 -0
- package/dist/catalog.js +118 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +86 -0
- package/dist/commands/context.d.ts +26 -0
- package/dist/commands/context.js +89 -0
- package/dist/commands/doctor.d.ts +13 -0
- package/dist/commands/doctor.js +92 -0
- package/dist/commands/help.d.ts +1 -0
- package/dist/commands/help.js +15 -0
- package/dist/commands/serve.d.ts +8 -0
- package/dist/commands/serve.js +29 -0
- package/dist/commands/setup.d.ts +14 -0
- package/dist/commands/setup.js +137 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +40 -0
- package/dist/http.d.ts +18 -0
- package/dist/http.js +168 -0
- package/dist/lifecycle.d.ts +43 -0
- package/dist/lifecycle.js +138 -0
- package/dist/misc-routes.d.ts +8 -0
- package/dist/misc-routes.js +119 -0
- package/dist/pi-event-translation.d.ts +65 -0
- package/dist/pi-event-translation.js +545 -0
- package/dist/pi-rpc.d.ts +68 -0
- package/dist/pi-rpc.js +285 -0
- package/dist/pidfd.d.ts +2 -0
- package/dist/pidfd.js +28 -0
- package/dist/question-forms.d.ts +30 -0
- package/dist/question-forms.js +46 -0
- package/dist/route-services.d.ts +26 -0
- package/dist/route-services.js +33 -0
- package/dist/routes-v1.d.ts +4 -0
- package/dist/routes-v1.js +346 -0
- package/dist/routes-v2.d.ts +4 -0
- package/dist/routes-v2.js +477 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +113 -0
- package/dist/session-commands.d.ts +75 -0
- package/dist/session-commands.js +323 -0
- package/dist/session-diff.d.ts +14 -0
- package/dist/session-diff.js +152 -0
- package/dist/session-history.d.ts +35 -0
- package/dist/session-history.js +416 -0
- package/dist/session-hosts.d.ts +3 -0
- package/dist/session-hosts.js +188 -0
- package/dist/session-store.d.ts +103 -0
- package/dist/session-store.js +339 -0
- package/dist/session-tree.d.ts +59 -0
- package/dist/session-tree.js +324 -0
- package/dist/sessions.d.ts +174 -0
- package/dist/sessions.js +1141 -0
- package/dist/sse.d.ts +19 -0
- package/dist/sse.js +92 -0
- package/dist/subagents.d.ts +64 -0
- package/dist/subagents.js +621 -0
- package/dist/thinking-levels.d.ts +17 -0
- package/dist/thinking-levels.js +24 -0
- package/dist/todo-bridge.d.ts +26 -0
- package/dist/todo-bridge.js +100 -0
- package/dist/tool-map.d.ts +32 -0
- package/dist/tool-map.js +349 -0
- package/dist/types.d.ts +338 -0
- package/dist/types.js +6 -0
- package/dist/v2.d.ts +190 -0
- package/dist/v2.js +553 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +13 -0
- package/extensions/omo-dag/README.md +159 -0
- package/extensions/omo-dag/docs/DESIGN.md +105 -0
- package/extensions/omo-dag/icon.svg +1 -0
- package/extensions/omo-dag/package.json +33 -0
- package/extensions/omo-dag/panel/index.html +179 -0
- package/extensions/omo-dag/panel/main.js +176 -0
- package/extensions/omo-dag/service/main.js +378 -0
- package/package.json +77 -4
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/** OpenCode v2 (/api/*) routes and their response adapters. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { errorBody, json } from "./http.js";
|
|
5
|
+
import { debugLog } from "./misc-routes.js";
|
|
6
|
+
import { OPENCODE_API_VERSION } from "./version.js";
|
|
7
|
+
import { toV2Permission } from "./approvals.js";
|
|
8
|
+
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 { defaultV2Model, flattenModels, toV2Message, toV2Session } from "./v2.js";
|
|
13
|
+
import { modelRefFromBody, numberParam, OPENCODE_COMPAT_VERSION, promptAsync, } from "./route-services.js";
|
|
14
|
+
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,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
function v2Session(services, info) {
|
|
30
|
+
const managed = services.sessions.get(info.id);
|
|
31
|
+
return toV2Session(info, { model: managed?.model, agent: getAgentName() });
|
|
32
|
+
}
|
|
33
|
+
/** v2 `/api/project` list: the adapter has no project registry, so expose the
|
|
34
|
+
* directories it knows about (the default plus every restorable session). */
|
|
35
|
+
function v2Projects(services) {
|
|
36
|
+
const dirs = new Set([services.defaultDirectory]);
|
|
37
|
+
for (const s of services.sessions.listAll())
|
|
38
|
+
dirs.add(s.directory);
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
return [...dirs].map((dir) => ({
|
|
41
|
+
id: dir,
|
|
42
|
+
canonical: dir,
|
|
43
|
+
time: { created: now, updated: now },
|
|
44
|
+
sandboxes: [],
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
export async function handleV2(services, req, res, url, path, method, directory) {
|
|
48
|
+
const location = { directory };
|
|
49
|
+
const withLocation = (data) => ({ location, data });
|
|
50
|
+
// --- bootstrap ---
|
|
51
|
+
if (path === "/api/info" && method === "GET") {
|
|
52
|
+
json(res, 200, {
|
|
53
|
+
version: OPENCODE_COMPAT_VERSION ?? OPENCODE_API_VERSION,
|
|
54
|
+
pid: process.pid,
|
|
55
|
+
urls: [services.url],
|
|
56
|
+
paths: { tmp: tmpdir() },
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (path === "/api/location" && method === "GET") {
|
|
61
|
+
json(res, 200, { directory, project: { id: directory, directory, canonical: directory } });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (path === "/api/config" && method === "GET") {
|
|
65
|
+
json(res, 200, withLocation({ default_agent: getAgentName() }));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (path === "/api/provider" && method === "GET") {
|
|
69
|
+
const cat = await services.catalog();
|
|
70
|
+
json(res, 200, withLocation(cat.providers.map((p) => ({ ...p, activation: "enabled" }))));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (path === "/api/model" && method === "GET") {
|
|
74
|
+
const cat = await services.catalog();
|
|
75
|
+
json(res, 200, withLocation(flattenModels(cat)));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (path === "/api/model/default" && method === "GET") {
|
|
79
|
+
const cat = await services.catalog();
|
|
80
|
+
json(res, 200, withLocation(defaultV2Model(cat) ?? null));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (path === "/api/agent" && method === "GET") {
|
|
84
|
+
json(res, 200, withLocation(await v2Agents(services)));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const agentMatch = path.match(/^\/api\/agent\/([^/]+)$/);
|
|
88
|
+
if (agentMatch && method === "GET") {
|
|
89
|
+
const agentId = decodeURIComponent(agentMatch[1]);
|
|
90
|
+
const agent = (await v2Agents(services)).find((a) => a.id === agentId);
|
|
91
|
+
if (!agent) {
|
|
92
|
+
json(res, 404, errorBody("NotFoundError", `agent not found: ${agentId}`));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
json(res, 200, withLocation(agent));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (path === "/api/command" && method === "GET") {
|
|
99
|
+
json(res, 200, withLocation(await getCommands(directory)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (path === "/api/skill" && method === "GET") {
|
|
103
|
+
json(res, 200, withLocation([]));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// --- project / vcs / permission / form (2.0.4 bootstrap) ---
|
|
107
|
+
if (path === "/api/project" && method === "GET") {
|
|
108
|
+
json(res, 200, v2Projects(services));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const projectMatch = path.match(/^\/api\/project\/([^/]+)$/);
|
|
112
|
+
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
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (path === "/api/permission/request" && method === "GET") {
|
|
124
|
+
json(res, 200, withLocation(services.sessions.permissions().map(toV2Permission)));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (path === "/api/question/request" && method === "GET") {
|
|
128
|
+
json(res, 200, withLocation(services.sessions.questions()));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (path === "/api/permission/saved" && method === "GET") {
|
|
132
|
+
json(res, 200, { data: [] });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (path.match(/^\/api\/permission\/saved\/[^/]+$/) && method === "DELETE") {
|
|
136
|
+
res.writeHead(204).end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (path === "/api/form" && method === "GET") {
|
|
140
|
+
json(res, 200, withLocation(services.sessions.questions().map(toQuestionForm)));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
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([]));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (path === "/api/vcs/branch" && method === "GET") {
|
|
156
|
+
json(res, 200, withLocation([]));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (path === "/api/vcs/diff" && method === "GET") {
|
|
160
|
+
json(res, 200, withLocation(""));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// --- sessions ---
|
|
164
|
+
if (path === "/api/session" && method === "GET") {
|
|
165
|
+
const dir = url.searchParams.get("directory") ?? url.searchParams.get("location");
|
|
166
|
+
const search = url.searchParams.get("search");
|
|
167
|
+
const limit = numberParam(url.searchParams.get("limit"));
|
|
168
|
+
let infos = dir !== null && dir !== "" ? services.sessions.list(dir) : services.sessions.listAll();
|
|
169
|
+
if (search)
|
|
170
|
+
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);
|
|
173
|
+
const page = limit !== undefined ? sessions.slice(0, limit) : sessions;
|
|
174
|
+
// The 2.0.4 UI reads `cursor.previous` unconditionally — a list body
|
|
175
|
+
// without a cursor throws "Cannot read properties of undefined".
|
|
176
|
+
json(res, 200, { data: page, cursor: { previous: null, next: null } });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (path === "/api/session" && method === "POST") {
|
|
180
|
+
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);
|
|
183
|
+
json(res, 200, { data: v2Session(services, info) });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (path === "/api/session/active" && method === "GET") {
|
|
187
|
+
const data = {};
|
|
188
|
+
for (const [id, status] of Object.entries(services.sessions.statuses())) {
|
|
189
|
+
if (status.type !== "idle")
|
|
190
|
+
data[id] = { type: "running" };
|
|
191
|
+
}
|
|
192
|
+
json(res, 200, { data });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const sessionMatch = path.match(/^\/api\/session\/([^/]+)(\/.+)?$/);
|
|
196
|
+
if (sessionMatch) {
|
|
197
|
+
const sessionId = decodeURIComponent(sessionMatch[1]);
|
|
198
|
+
const sub = sessionMatch[2] ?? "";
|
|
199
|
+
const managed = services.sessions.get(sessionId);
|
|
200
|
+
if (!managed) {
|
|
201
|
+
json(res, 404, errorBody("NotFoundError", `session not found: ${sessionId}`));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const messageMatch = sub.match(/^\/message\/([^/]+)$/);
|
|
205
|
+
if (messageMatch && method === "GET") {
|
|
206
|
+
const messageId = decodeURIComponent(messageMatch[1]);
|
|
207
|
+
const found = (services.sessions.messages(sessionId) ?? []).find((m) => m.info.id === messageId);
|
|
208
|
+
if (!found) {
|
|
209
|
+
json(res, 404, errorBody("MessageNotFoundError", `message not found: ${messageId}`));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
json(res, 200, { data: toV2Message(found) });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const formMatch = sub.match(/^\/form\/([^/]+)(\/reply)?$/);
|
|
216
|
+
if (formMatch) {
|
|
217
|
+
const formId = decodeURIComponent(formMatch[1]);
|
|
218
|
+
const pending = services.sessions.questions(sessionId).find((q) => questionFormID(q.id) === formId);
|
|
219
|
+
if (!pending) {
|
|
220
|
+
json(res, 404, errorBody("FormNotFoundError", `form not found: ${formId}`));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (formMatch[2] === "/reply" && method === "POST") {
|
|
224
|
+
const body = (await services.body(req));
|
|
225
|
+
const reply = formQuestionAnswer(pending, body.answer);
|
|
226
|
+
if (!(await services.sessions.replyQuestion(sessionId, pending.id, reply.answers, reply.comment))) {
|
|
227
|
+
json(res, 409, errorBody("FormReplyError", "native question response refused"));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
res.writeHead(204).end();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (method === "DELETE") {
|
|
234
|
+
if (!(await services.sessions.rejectQuestion(sessionId, pending.id))) {
|
|
235
|
+
json(res, 409, errorBody("FormReplyError", "native question cancellation refused"));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
res.writeHead(204).end();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (method === "GET") {
|
|
242
|
+
json(res, 200, { data: { ...toQuestionForm(pending), state: { status: "pending" } } });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const questionMatch = sub.match(/^\/question\/([^/]+)(\/reply|\/reject)?$/);
|
|
247
|
+
if (questionMatch) {
|
|
248
|
+
const requestId = decodeURIComponent(questionMatch[1]);
|
|
249
|
+
if (questionMatch[2] === "/reply" && method === "POST") {
|
|
250
|
+
const body = (await services.body(req));
|
|
251
|
+
if (!services.sessions.questions(sessionId).some((q) => q.id === requestId)) {
|
|
252
|
+
json(res, 404, errorBody("QuestionNotFoundError", `question not found: ${requestId}`));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!(await services.sessions.replyQuestion(sessionId, requestId, body.answers ?? [], body.comment))) {
|
|
256
|
+
json(res, 409, errorBody("QuestionReplyError", "native question response refused"));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
res.writeHead(204).end();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (questionMatch[2] === "/reject" && method === "POST") {
|
|
263
|
+
if (!services.sessions.questions(sessionId).some((q) => q.id === requestId)) {
|
|
264
|
+
json(res, 404, errorBody("QuestionNotFoundError", `question not found: ${requestId}`));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (!(await services.sessions.rejectQuestion(sessionId, requestId))) {
|
|
268
|
+
json(res, 409, errorBody("QuestionReplyError", "native question cancellation refused"));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
res.writeHead(204).end();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (method === "GET") {
|
|
275
|
+
const question = services.sessions.questions(sessionId).find((entry) => entry.id === requestId);
|
|
276
|
+
if (question === undefined)
|
|
277
|
+
json(res, 404, errorBody("QuestionNotFoundError", `question not found: ${requestId}`));
|
|
278
|
+
else
|
|
279
|
+
json(res, 200, { data: question });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const permissionMatch = sub.match(/^\/permission\/([^/]+)(\/reply)?$/);
|
|
284
|
+
if (permissionMatch) {
|
|
285
|
+
const requestId = decodeURIComponent(permissionMatch[1]);
|
|
286
|
+
if (permissionMatch[2] === "/reply" && method === "POST") {
|
|
287
|
+
const body = (await services.body(req));
|
|
288
|
+
if (!body.reply || !services.sessions.replyPermission(sessionId, requestId, body.reply, body.message)) {
|
|
289
|
+
json(res, 404, errorBody("PermissionNotFoundError", `permission not found: ${requestId}`));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
res.writeHead(204).end();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (method === "GET") {
|
|
296
|
+
const permission = services.sessions.permissions(sessionId).find((entry) => entry.id === requestId);
|
|
297
|
+
if (permission === undefined)
|
|
298
|
+
json(res, 404, errorBody("PermissionNotFoundError", `permission not found: ${requestId}`));
|
|
299
|
+
else
|
|
300
|
+
json(res, 200, { data: toV2Permission(permission) });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
switch (`${method} ${sub}`) {
|
|
305
|
+
case "GET /todo":
|
|
306
|
+
json(res, 200, { data: services.sessions.todos(sessionId) });
|
|
307
|
+
return;
|
|
308
|
+
case "GET ": {
|
|
309
|
+
json(res, 200, { data: v2Session(services, managed.info) });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
case "PATCH ": {
|
|
313
|
+
const body = (await services.body(req));
|
|
314
|
+
services.sessions.update(sessionId, { title: body.title, archived: body.time?.archived });
|
|
315
|
+
res.writeHead(204).end();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
case "DELETE ": {
|
|
319
|
+
await services.sessions.remove(sessionId);
|
|
320
|
+
res.writeHead(204).end();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
case "GET /message": {
|
|
324
|
+
const order = url.searchParams.get("order");
|
|
325
|
+
const limit = numberParam(url.searchParams.get("limit"));
|
|
326
|
+
let messages = (services.sessions.messages(sessionId) ?? []).map((m) => toV2Message(m));
|
|
327
|
+
if (order !== "asc")
|
|
328
|
+
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 } });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
case "POST /summarize": {
|
|
335
|
+
await services.body(req);
|
|
336
|
+
const result = await compactSession(services.sessions, sessionId);
|
|
337
|
+
if (result.kind === "error") {
|
|
338
|
+
announceSessionError({ sessions: services.sessions, session: managed, emit: services.broadcast }, "BadRequestError", result.message);
|
|
339
|
+
json(res, 400, errorBody("BadRequestError", result.message));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
json(res, 200, { data: true });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
case "POST /shell": {
|
|
346
|
+
const command = parseShellCommand(await services.body(req));
|
|
347
|
+
if (command === undefined) {
|
|
348
|
+
json(res, 400, errorBody("BadRequestError", "command is required"));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (managed.status.type !== "idle") {
|
|
352
|
+
json(res, 409, errorBody("SessionBusyError", "session is busy"));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const result = await runShellCommand({
|
|
356
|
+
sessions: services.sessions,
|
|
357
|
+
session: managed,
|
|
358
|
+
command,
|
|
359
|
+
emit: services.broadcast,
|
|
360
|
+
});
|
|
361
|
+
if (result.kind === "error") {
|
|
362
|
+
json(res, 400, errorBody("BadRequestError", result.message));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
json(res, 200, { data: toV2Message(result.message) });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
case "POST /command": {
|
|
369
|
+
const input = parseSlashCommand(await services.body(req));
|
|
370
|
+
if (input === undefined) {
|
|
371
|
+
json(res, 400, errorBody("BadRequestError", "command is required"));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
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
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
case "POST /share":
|
|
381
|
+
case "DELETE /share":
|
|
382
|
+
announceSessionError({ sessions: services.sessions, session: managed, emit: services.broadcast }, unsupportedShare.name, unsupportedShare.data.message);
|
|
383
|
+
json(res, 400, errorBody(unsupportedShare.name, unsupportedShare.data.message));
|
|
384
|
+
return;
|
|
385
|
+
case "POST /prompt": {
|
|
386
|
+
const body = (await services.body(req));
|
|
387
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
388
|
+
const messageID = typeof body.id === "string" && body.id !== "" ? body.id : `msg_${randomUUID().replaceAll("-", "")}`;
|
|
389
|
+
const model = modelRefFromBody(body.model ?? (body.variant !== undefined ? managed.model : undefined), body.variant);
|
|
390
|
+
if (body.delivery !== undefined && body.delivery !== "steer" && body.delivery !== "queue") {
|
|
391
|
+
json(res, 400, errorBody("BadRequestError", 'delivery must be "steer" or "queue"'));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const delivery = body.delivery ?? "steer";
|
|
395
|
+
promptAsync(services, managed, text, model, false, messageID, delivery);
|
|
396
|
+
json(res, 200, {
|
|
397
|
+
data: {
|
|
398
|
+
id: messageID,
|
|
399
|
+
sessionID: sessionId,
|
|
400
|
+
time: { created: Date.now() },
|
|
401
|
+
type: "user",
|
|
402
|
+
text,
|
|
403
|
+
payload: { text },
|
|
404
|
+
delivery,
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
case "POST /interrupt": {
|
|
410
|
+
await services.sessions.abort(sessionId);
|
|
411
|
+
json(res, 200, { data: true });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
case "POST /agent": {
|
|
415
|
+
await services.body(req); // agent is fixed per connector — acknowledge and ignore
|
|
416
|
+
res.writeHead(204).end();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
case "POST /model": {
|
|
420
|
+
// The UI awaits this before every prompt (applySendSelection →
|
|
421
|
+
// switchModel), so it is the only route from the bottom-right picker
|
|
422
|
+
// to pi. Dropping the body used to leave the UI showing a model pi
|
|
423
|
+
// never ran. 204 empty is the contract; 400/404 are the statuses the
|
|
424
|
+
// client declares, and a loud failure beats a silent drift.
|
|
425
|
+
const body = (await services.body(req));
|
|
426
|
+
const ref = modelRefFromBody(body.model, body.variant);
|
|
427
|
+
if (ref === undefined) {
|
|
428
|
+
json(res, 400, errorBody("BadRequestError", "model is required: {providerID, modelID}"));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
const resolved = await services.sessions.setModel(sessionId, ref);
|
|
433
|
+
if (resolved.modelID !== ref.modelID || resolved.providerID !== ref.providerID) {
|
|
434
|
+
// pi resolved the request to a different concrete model (alias or
|
|
435
|
+
// provider-reported id). Not an error, but the reason the UI ends
|
|
436
|
+
// up showing something other than what was picked.
|
|
437
|
+
debugLog(`[model] session ${sessionId}: pi resolved ${ref.providerID}/${ref.modelID} -> ${resolved.providerID}/${resolved.modelID}`);
|
|
438
|
+
}
|
|
439
|
+
res.writeHead(204).end();
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
443
|
+
console.error(`[model] session ${sessionId} → ${ref.providerID}/${ref.modelID} failed:`, message);
|
|
444
|
+
json(res, message.startsWith("unknown session:") ? 404 : 400, errorBody(message.startsWith("unknown session:") ? "NotFoundError" : "BadRequestError", message));
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
case "GET /form": {
|
|
449
|
+
json(res, 200, { data: services.sessions.questions(sessionId).map(toQuestionForm) });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
case "GET /question": {
|
|
453
|
+
json(res, 200, { data: services.sessions.questions(sessionId) });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
case "GET /permission": {
|
|
457
|
+
json(res, 200, { data: services.sessions.permissions(sessionId).map(toV2Permission) });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
case "GET /inbox": {
|
|
461
|
+
json(res, 200, { data: [] });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
case "GET /diff":
|
|
465
|
+
json(res, 200, { data: await services.sessions.diff(sessionId) });
|
|
466
|
+
return;
|
|
467
|
+
case "GET /context": {
|
|
468
|
+
json(res, 501, errorBody("NotImplemented", `${method} ${path} is not implemented by this connector`));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
default:
|
|
472
|
+
json(res, 501, errorBody("NotImplemented", `${method} ${path} not implemented`));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
json(res, 404, errorBody("NotFoundError", `no route: ${method} ${path}`));
|
|
477
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** HTTP/SSE composition root for the OpenCode API backed by pi. */
|
|
2
|
+
export { isLoopbackHost, serverPassword } from "./http.js";
|
|
3
|
+
export interface AdapterServerOptions {
|
|
4
|
+
port: number;
|
|
5
|
+
host: string;
|
|
6
|
+
/** Fallback directory when requests carry no ?directory= param. */
|
|
7
|
+
defaultDirectory: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class AdapterServer {
|
|
10
|
+
private options;
|
|
11
|
+
private server;
|
|
12
|
+
private sessions;
|
|
13
|
+
private sse;
|
|
14
|
+
private sseClients;
|
|
15
|
+
private catalogPromise;
|
|
16
|
+
private actualPort;
|
|
17
|
+
private readonly http;
|
|
18
|
+
constructor(options: AdapterServerOptions);
|
|
19
|
+
private catalog;
|
|
20
|
+
start(): Promise<void>;
|
|
21
|
+
get url(): string;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
private broadcast;
|
|
24
|
+
private writeSSE;
|
|
25
|
+
private handleSSE;
|
|
26
|
+
private route;
|
|
27
|
+
private handle;
|
|
28
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** HTTP/SSE composition root for the OpenCode API backed by pi. */
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { debugLog } from "./misc-routes.js";
|
|
4
|
+
import { SessionManager } from "./sessions.js";
|
|
5
|
+
import { loadCatalog } from "./catalog.js";
|
|
6
|
+
import { Http } from "./http.js";
|
|
7
|
+
import { SSE } from "./sse.js";
|
|
8
|
+
import { handleV1 } from "./routes-v1.js";
|
|
9
|
+
import { handleV2 } from "./routes-v2.js";
|
|
10
|
+
export { isLoopbackHost, serverPassword } from "./http.js";
|
|
11
|
+
export class AdapterServer {
|
|
12
|
+
options;
|
|
13
|
+
server;
|
|
14
|
+
sessions;
|
|
15
|
+
sse;
|
|
16
|
+
sseClients = new Map();
|
|
17
|
+
catalogPromise;
|
|
18
|
+
actualPort;
|
|
19
|
+
http = new Http();
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.sse = new SSE(options, this.sseClients);
|
|
23
|
+
this.sessions = new SessionManager((directory, event) => this.broadcast(directory, event), undefined, options.defaultDirectory);
|
|
24
|
+
this.server = createServer((req, res) => void this.route(req, res));
|
|
25
|
+
}
|
|
26
|
+
catalog() {
|
|
27
|
+
this.catalogPromise ??= loadCatalog(this.options.defaultDirectory).catch((err) => {
|
|
28
|
+
console.error("[catalog] failed to load models from pi:", err);
|
|
29
|
+
return { providers: [], defaults: {} };
|
|
30
|
+
});
|
|
31
|
+
return this.catalogPromise;
|
|
32
|
+
}
|
|
33
|
+
async start() {
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
this.server.once("error", reject);
|
|
36
|
+
this.server.listen(this.options.port, this.options.host, () => resolve());
|
|
37
|
+
});
|
|
38
|
+
const addr = this.server.address();
|
|
39
|
+
this.actualPort = typeof addr === "object" && addr !== null ? addr.port : this.options.port;
|
|
40
|
+
}
|
|
41
|
+
get url() {
|
|
42
|
+
const port = this.actualPort ?? this.options.port;
|
|
43
|
+
const host = this.options.host.includes(":") && !this.options.host.startsWith("[")
|
|
44
|
+
? `[${this.options.host}]`
|
|
45
|
+
: this.options.host;
|
|
46
|
+
return `http://${host}:${port}`;
|
|
47
|
+
}
|
|
48
|
+
async stop() {
|
|
49
|
+
// Stop admission before ending SSE: its clients can reconnect while the
|
|
50
|
+
// session RPC groups and private task hosts are still being disposed.
|
|
51
|
+
const closed = new Promise((resolve) => this.server.close(() => resolve()));
|
|
52
|
+
this.sse.close();
|
|
53
|
+
try {
|
|
54
|
+
await this.sessions.disposeAll();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
// An in-flight HTTP request must not hold shutdown open after teardown.
|
|
58
|
+
this.server.closeAllConnections();
|
|
59
|
+
await closed;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------
|
|
63
|
+
// SSE
|
|
64
|
+
// ---------------------------------------------------------------
|
|
65
|
+
broadcast(directory, event) {
|
|
66
|
+
this.sse.broadcast(directory, event);
|
|
67
|
+
}
|
|
68
|
+
writeSSE(res, frame) {
|
|
69
|
+
return this.sse.writeSSE(res, frame);
|
|
70
|
+
}
|
|
71
|
+
handleSSE(res, flavor) {
|
|
72
|
+
this.sse.handleSSE(res, flavor, this.writeSSE.bind(this));
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------
|
|
75
|
+
// Router
|
|
76
|
+
// ---------------------------------------------------------------
|
|
77
|
+
async route(req, res) {
|
|
78
|
+
debugLog(`[http] ${req.method} ${(req.url ?? "/").split("?")[0]}`);
|
|
79
|
+
await this.http.route(req, res, () => this.handle(req, res));
|
|
80
|
+
}
|
|
81
|
+
async handle(req, res) {
|
|
82
|
+
const url = new URL(req.url ?? "/", this.url);
|
|
83
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
84
|
+
const method = req.method ?? "GET";
|
|
85
|
+
const directory = url.searchParams.get("directory") ??
|
|
86
|
+
url.searchParams.get("location[directory]") ??
|
|
87
|
+
url.searchParams.get("location") ??
|
|
88
|
+
req.headers["x-opencode-directory"] ??
|
|
89
|
+
this.options.defaultDirectory;
|
|
90
|
+
// SSE stream (v2 clients subscribe at /api/event; v1 at /event)
|
|
91
|
+
if (method === "GET" && (path === "/event" || path === "/global/event")) {
|
|
92
|
+
this.handleSSE(res, "v1");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (method === "GET" && path === "/api/event") {
|
|
96
|
+
this.handleSSE(res, "v2");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const services = {
|
|
100
|
+
sessions: this.sessions,
|
|
101
|
+
defaultDirectory: this.options.defaultDirectory,
|
|
102
|
+
url: this.url,
|
|
103
|
+
body: (request) => this.http.body(request),
|
|
104
|
+
catalog: () => this.catalog(),
|
|
105
|
+
broadcast: (dir, event) => this.broadcast(dir, event),
|
|
106
|
+
};
|
|
107
|
+
if (path === "/api" || path.startsWith("/api/")) {
|
|
108
|
+
await handleV2(services, req, res, url, path, method, directory);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await handleV1(services, req, res, url, path, method, directory);
|
|
112
|
+
}
|
|
113
|
+
}
|