@dirwin517/bot-farm 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/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- package/test/workspaces.mjs.tmp +0 -0
package/src/api.mjs
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
// Thin client for the opencode server (REST + SSE). Built-in fetch only.
|
|
2
|
+
//
|
|
3
|
+
// opencode's HTTP surface is explicitly experimental and has moved once already
|
|
4
|
+
// (v1: /session, /event -> v2: /api/session, /api/event). Rather than pin to
|
|
5
|
+
// one, we sniff the dialect at connect time and keep every path in one table.
|
|
6
|
+
|
|
7
|
+
const DIALECTS = {
|
|
8
|
+
v2: {
|
|
9
|
+
name: "v2",
|
|
10
|
+
probe: "/api/info",
|
|
11
|
+
events: "/api/event",
|
|
12
|
+
sessions: () => "/api/session",
|
|
13
|
+
active: () => "/api/session/active",
|
|
14
|
+
session: (id) => `/api/session/${id}`,
|
|
15
|
+
messages: (id) => `/api/session/${id}/message`,
|
|
16
|
+
prompt: (id) => `/api/session/${id}/prompt`,
|
|
17
|
+
interrupt: (id) => `/api/session/${id}/interrupt`,
|
|
18
|
+
synthetic: (id) => `/api/session/${id}/synthetic`,
|
|
19
|
+
mcp: (name) => `/api/experimental/mcp/${name}`,
|
|
20
|
+
mcpList: () => "/api/mcp",
|
|
21
|
+
mcpConnect: (name) => `/api/experimental/mcp/${name}/connect`,
|
|
22
|
+
mcpDisconnect: (name) => `/api/experimental/mcp/${name}/disconnect`,
|
|
23
|
+
toolIds: () => "/api/experimental/tool/ids",
|
|
24
|
+
compact: (id) => `/api/session/${id}/compact`,
|
|
25
|
+
permissions: (id) => `/api/session/${id}/permission`,
|
|
26
|
+
permissionReply: (id, rid) => `/api/session/${id}/permission/${rid}/reply`,
|
|
27
|
+
projects: () => "/api/project",
|
|
28
|
+
createBody: ({ title, directory, agent, model }) => ({
|
|
29
|
+
title,
|
|
30
|
+
agent: agent ?? null,
|
|
31
|
+
model: model ?? null,
|
|
32
|
+
location: directory ? { directory } : null,
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
v1: {
|
|
36
|
+
name: "v1",
|
|
37
|
+
probe: "/app",
|
|
38
|
+
events: "/event",
|
|
39
|
+
sessions: () => "/session",
|
|
40
|
+
active: () => null,
|
|
41
|
+
session: (id) => `/session/${id}`,
|
|
42
|
+
messages: (id) => `/session/${id}/message`,
|
|
43
|
+
prompt: (id) => `/session/${id}/message`,
|
|
44
|
+
interrupt: (id) => `/session/${id}/abort`,
|
|
45
|
+
synthetic: null,
|
|
46
|
+
mcp: null,
|
|
47
|
+
mcpList: null,
|
|
48
|
+
mcpConnect: null,
|
|
49
|
+
mcpDisconnect: null,
|
|
50
|
+
toolIds: null,
|
|
51
|
+
compact: (id) => `/session/${id}/summarize`,
|
|
52
|
+
permissions: () => null,
|
|
53
|
+
permissionReply: (id, rid) => `/session/${id}/permissions/${rid}`,
|
|
54
|
+
projects: () => "/project",
|
|
55
|
+
createBody: ({ title }) => ({ title }),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class OpencodeClient {
|
|
60
|
+
constructor(baseUrl = "http://127.0.0.1:4096") {
|
|
61
|
+
this.base = baseUrl.replace(/\/+$/, "")
|
|
62
|
+
this.dialect = null
|
|
63
|
+
this.info = null
|
|
64
|
+
this.shapes = new Map() // endpoint -> the request body shape this server took
|
|
65
|
+
// Sessions run by the instance engine (/session/… ?directory=), keyed to
|
|
66
|
+
// their directory. On current servers the v2 session runner cannot drive
|
|
67
|
+
// some providers — Bedrock fails with "Unsupported API … aisdk:@ai-sdk/
|
|
68
|
+
// amazon-bedrock" — while the instance engine (what the TUI uses) can.
|
|
69
|
+
this.v1 = new Map()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
markV1(id, directory) {
|
|
73
|
+
if (id && directory) this.v1.set(id, directory)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async connect() {
|
|
77
|
+
for (const d of [DIALECTS.v2, DIALECTS.v1]) {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(this.base + d.probe, { signal: AbortSignal.timeout(3000) })
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
this.dialect = d
|
|
82
|
+
this.info = await res.json().catch(() => null)
|
|
83
|
+
return d.name
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`no opencode server at ${this.base} (start one with: opencode serve)`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get ready() {
|
|
91
|
+
return this.dialect !== null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async req(path, { method = "GET", body, query } = {}) {
|
|
95
|
+
if (!path) return null
|
|
96
|
+
const url = new URL(this.base + path)
|
|
97
|
+
for (const [k, v] of Object.entries(query ?? {})) {
|
|
98
|
+
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v))
|
|
99
|
+
}
|
|
100
|
+
const res = await fetch(url, {
|
|
101
|
+
method,
|
|
102
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
103
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
104
|
+
signal: AbortSignal.timeout(30000),
|
|
105
|
+
})
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const text = await res.text().catch(() => "")
|
|
108
|
+
throw new Error(`${method} ${url.pathname} -> ${res.status} ${text.slice(0, 300)}`)
|
|
109
|
+
}
|
|
110
|
+
if (res.status === 204) return null
|
|
111
|
+
const ct = res.headers.get("content-type") ?? ""
|
|
112
|
+
return ct.includes("json") ? res.json() : res.text()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// `data` wrapping differs between routes and versions; unwrap defensively.
|
|
116
|
+
static unwrap(payload) {
|
|
117
|
+
if (payload == null) return []
|
|
118
|
+
if (Array.isArray(payload)) return payload
|
|
119
|
+
if (Array.isArray(payload.data)) return payload.data
|
|
120
|
+
if (payload.data && typeof payload.data === "object") return payload.data
|
|
121
|
+
return payload
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async listSessions({ directory, limit = 100 } = {}) {
|
|
125
|
+
const out = await this.req(this.dialect.sessions(), { query: { directory, limit: String(limit), order: "desc" } })
|
|
126
|
+
const list = OpencodeClient.unwrap(out)
|
|
127
|
+
return Array.isArray(list) ? list : []
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sessions with a live agent loop owned by this server process.
|
|
131
|
+
async activeSessionIds() {
|
|
132
|
+
const path = this.dialect.active()
|
|
133
|
+
let out = null
|
|
134
|
+
if (path) {
|
|
135
|
+
try {
|
|
136
|
+
const data = OpencodeClient.unwrap(await this.req(path))
|
|
137
|
+
out = new Set(Array.isArray(data) ? data.map((x) => (typeof x === "string" ? x : x.id ?? x.sessionID)) : Object.keys(data ?? {}))
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
// Instance-engine sessions report through /session/status, per directory.
|
|
141
|
+
for (const dir of new Set(this.v1.values())) {
|
|
142
|
+
try {
|
|
143
|
+
const map = await this.req("/session/status", { query: { directory: dir } })
|
|
144
|
+
out ??= new Set()
|
|
145
|
+
for (const [id, st] of Object.entries(map ?? {})) if ((st?.type ?? st) !== "idle") out.add(id)
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getSession(id) {
|
|
152
|
+
return this.req(this.dialect.session(id)).then(OpencodeClient.unwrap)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
createSession(opts) {
|
|
156
|
+
const query = this.dialect.name === "v1" ? { directory: opts.directory } : undefined
|
|
157
|
+
// No variant on the model ref: the session runner rejects a variant the
|
|
158
|
+
// model does not list ("Variant unavailable … medium") and the turn dies.
|
|
159
|
+
// The reasoning level lives on the bot's opencode agent instead.
|
|
160
|
+
const ref = modelRef(opts.model)
|
|
161
|
+
const body = this.dialect.createBody(opts)
|
|
162
|
+
// v2 takes model as { id, providerID, variant }. Fall back to no model
|
|
163
|
+
// (and then no agent) rather than failing to create the session.
|
|
164
|
+
return this.postShaped("create", this.dialect.sessions(), [
|
|
165
|
+
() => ({ ...body, model: ref }),
|
|
166
|
+
() => ({ ...body, model: ref ? { providerID: ref.providerID, modelID: ref.id } : null }),
|
|
167
|
+
() => ({ ...body, model: null }),
|
|
168
|
+
() => ({ ...body, model: null, agent: null }),
|
|
169
|
+
], { query }).then(OpencodeClient.unwrap)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
deleteSession(id) {
|
|
173
|
+
return this.req(this.dialect.session(id), { method: "DELETE" })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
messages(id, { limit = 20, order = "desc" } = {}) {
|
|
177
|
+
if (this.v1.has(id)) {
|
|
178
|
+
// Oldest first there; everything here reads newest first.
|
|
179
|
+
return this.req(`/session/${id}/message`, { query: { directory: this.v1.get(id), limit: String(limit) } }).then((out) => {
|
|
180
|
+
const list = OpencodeClient.unwrap(out)
|
|
181
|
+
return Array.isArray(list) ? list.slice().reverse() : []
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return this.req(this.dialect.messages(id), { query: { limit: String(limit), order } }).then((out) => {
|
|
185
|
+
const list = OpencodeClient.unwrap(out)
|
|
186
|
+
return Array.isArray(list) ? list : []
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Body shapes here have already changed under us once: v2 wraps the text as
|
|
192
|
+
* { prompt: { text } } where v1 took { parts: [...] }. Rather than pin to
|
|
193
|
+
* one and fail with a 400 that says nothing useful, try the known shapes in
|
|
194
|
+
* order and remember which one this server accepted.
|
|
195
|
+
*/
|
|
196
|
+
async postShaped(key, path, builders, { query } = {}) {
|
|
197
|
+
// Cache the *index* of the shape that worked, never the body itself:
|
|
198
|
+
// caching the body would resend the first message's text forever.
|
|
199
|
+
const known = this.shapes.get(key)
|
|
200
|
+
const order = known === undefined ? builders.map((_, i) => i) : [known, ...builders.map((_, i) => i).filter((i) => i !== known)]
|
|
201
|
+
let last
|
|
202
|
+
for (const i of order) {
|
|
203
|
+
try {
|
|
204
|
+
const out = await this.req(path, { method: "POST", body: builders[i](), query })
|
|
205
|
+
this.shapes.set(key, i)
|
|
206
|
+
return out
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Only a payload rejection means "wrong shape" — anything else is real.
|
|
209
|
+
if (!/-> 4(00|22)\b/.test(err.message)) throw err
|
|
210
|
+
last = err
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw new Error(`${path}: no accepted request shape (${last?.message ?? "unknown"})`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `tools` is opencode's per-turn switch map ({ bash: false, … }): the only
|
|
218
|
+
* reliable way to narrow what one session in a shared worktree may use.
|
|
219
|
+
* Shapes with it are tried first; a server that refuses the key still gets
|
|
220
|
+
* the message.
|
|
221
|
+
*/
|
|
222
|
+
/** Questions opencode's own question tool is waiting on for this session. */
|
|
223
|
+
async pendingQuestions(id) {
|
|
224
|
+
if (!this.v1.has(id)) return []
|
|
225
|
+
const list = await this.req("/question", { query: { directory: this.v1.get(id) } }).catch(() => [])
|
|
226
|
+
return (Array.isArray(list) ? list : []).filter((q) => q.sessionID === id).map((q) => ({ id: q.id, questions: q.questions ?? [] }))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** answers: one array of chosen labels (or typed text) per question. */
|
|
230
|
+
replyQuestion(id, requestId, answers) {
|
|
231
|
+
return this.req(`/question/${requestId}/reply`, { method: "POST", body: { answers }, query: { directory: this.v1.get(id) } })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
rejectQuestion(id, requestId) {
|
|
235
|
+
return this.req(`/question/${requestId}/reject`, { method: "POST", body: {}, query: { directory: this.v1.get(id) } })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Instance engine: create a session in a directory, on an agent and model. */
|
|
239
|
+
async createSessionV1({ title, directory, agent, model }) {
|
|
240
|
+
const ref = modelRef(model)
|
|
241
|
+
const body = { title, ...(agent ? { agent } : {}), ...(ref ? { model: { providerID: ref.providerID, modelID: ref.id } } : {}) }
|
|
242
|
+
let info
|
|
243
|
+
try {
|
|
244
|
+
info = OpencodeClient.unwrap(await this.req("/session", { method: "POST", body, query: { directory } }))
|
|
245
|
+
} catch (e) {
|
|
246
|
+
// Older servers take neither agent nor model at creation.
|
|
247
|
+
if (!/-> 4(00|22)\b/.test(e.message)) throw e
|
|
248
|
+
info = OpencodeClient.unwrap(await this.req("/session", { method: "POST", body: { title }, query: { directory } }))
|
|
249
|
+
}
|
|
250
|
+
if (info?.id) this.markV1(info.id, directory)
|
|
251
|
+
return info
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Instance engine: queue a turn. Agent, model and tool switches go with every turn. */
|
|
255
|
+
promptV1(id, text, { agent, model, variant, tools, synthetic = false } = {}) {
|
|
256
|
+
const ref = modelRef(model)
|
|
257
|
+
return this.req(`/session/${id}/prompt_async`, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
query: { directory: this.v1.get(id) },
|
|
260
|
+
body: {
|
|
261
|
+
parts: [{ type: "text", text, ...(synthetic ? { synthetic: true } : {}) }],
|
|
262
|
+
...(agent ? { agent } : {}),
|
|
263
|
+
...(ref ? { model: { providerID: ref.providerID, modelID: ref.id } } : {}),
|
|
264
|
+
...(variant ? { variant } : {}),
|
|
265
|
+
...(tools ? { tools } : {}),
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
prompt(id, text, { agent, model, variant, tools } = {}) {
|
|
271
|
+
if (this.v1.has(id)) return this.promptV1(id, text, { agent, model, variant, tools })
|
|
272
|
+
const base = this.dialect.name === "v2"
|
|
273
|
+
? [() => ({ prompt: { text } }), () => ({ prompt: text }), () => ({ text })]
|
|
274
|
+
: [() => ({ parts: [{ type: "text", text }] }), () => ({ prompt: { text } })]
|
|
275
|
+
const extras = turnExtras({ agent, model, variant, tools })
|
|
276
|
+
const shapes = extras ? [...base.map((b) => () => ({ ...b(), ...extras })), ...base] : base
|
|
277
|
+
return this.postShaped(extras ? "prompt+x" : "prompt", this.dialect.prompt(id), shapes)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interrupt(id, { resume = false } = {}) {
|
|
281
|
+
if (this.v1.has(id)) return this.req(`/session/${id}/abort`, { method: "POST", body: {}, query: { directory: this.v1.get(id) } })
|
|
282
|
+
return this.req(this.dialect.interrupt(id), {
|
|
283
|
+
method: "POST",
|
|
284
|
+
body: this.dialect.name === "v2" ? undefined : {},
|
|
285
|
+
query: this.dialect.name === "v2" ? { resume: String(resume) } : undefined,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Deliver text into a running session. A synthetic message is the right
|
|
291
|
+
* shape for peer traffic: it enters the transcript as content the session
|
|
292
|
+
* did not ask for, rather than masquerading as the operator's own turn.
|
|
293
|
+
*/
|
|
294
|
+
async synthetic(id, text, description, { tools, agent, model, variant } = {}) {
|
|
295
|
+
if (this.v1.has(id)) return this.promptV1(id, text, { tools, agent, model, variant })
|
|
296
|
+
const path = this.dialect.synthetic?.(id)
|
|
297
|
+
if (!path) return this.prompt(id, text, { tools, agent, model, variant })
|
|
298
|
+
try {
|
|
299
|
+
// Text first in every shape: a lenient server that accepts the body but
|
|
300
|
+
// finds no text would deliver an empty message, which is worse than a
|
|
301
|
+
// 400 because nothing reports it.
|
|
302
|
+
const base = [
|
|
303
|
+
() => ({ text, description, resume: true }),
|
|
304
|
+
() => ({ text }),
|
|
305
|
+
() => ({ message: { text }, description, resume: true }),
|
|
306
|
+
() => ({ prompt: { text }, description, resume: true }),
|
|
307
|
+
]
|
|
308
|
+
const extras = turnExtras({ agent, model, variant, tools })
|
|
309
|
+
const shapes = extras ? [...base.map((b) => () => ({ ...b(), ...extras })), ...base] : base
|
|
310
|
+
return await this.postShaped(extras ? "synthetic+x" : "synthetic", path, shapes)
|
|
311
|
+
} catch {
|
|
312
|
+
return this.prompt(id, text, { tools, agent, model, variant })
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Register a remote MCP server for one location, if the server supports it. */
|
|
317
|
+
async addMcpServer(name, config, directory) {
|
|
318
|
+
// Current servers: POST /mcp?directory=… { name, config } (the instance
|
|
319
|
+
// API, which is what actually runs a location's MCP servers).
|
|
320
|
+
try {
|
|
321
|
+
return await this.req("/mcp", { method: "POST", body: { name, config }, query: { directory } })
|
|
322
|
+
} catch (e) {
|
|
323
|
+
if (!/-> (404|405)\b/.test(e.message)) throw e
|
|
324
|
+
}
|
|
325
|
+
const path = this.dialect.mcp?.(name)
|
|
326
|
+
if (!path) throw new Error("this opencode version has no runtime MCP registration")
|
|
327
|
+
for (const query of [{ "location[directory]": directory }, { location: JSON.stringify({ directory }) }, { directory }]) {
|
|
328
|
+
try {
|
|
329
|
+
return await this.req(path, { method: "PUT", body: { config }, query })
|
|
330
|
+
} catch (e) {
|
|
331
|
+
var last = e
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
throw last
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Location-scoped queries need the directory encoded the way this server wants it. */
|
|
338
|
+
async locationReq(path, { method = "GET", body, directory } = {}) {
|
|
339
|
+
if (!path) return null
|
|
340
|
+
for (const query of [{ "location[directory]": directory }, { location: JSON.stringify({ directory }) }, { directory }]) {
|
|
341
|
+
try {
|
|
342
|
+
return await this.req(path, { method, body, query })
|
|
343
|
+
} catch (e) {
|
|
344
|
+
var last = e
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
throw last
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async mcpServers(directory) {
|
|
351
|
+
// GET /mcp?directory=… answers { name: { status, error? } }.
|
|
352
|
+
const map = await this.req("/mcp", { query: { directory } }).catch(() => null)
|
|
353
|
+
if (map && typeof map === "object" && !Array.isArray(map) && !map.data) {
|
|
354
|
+
return Object.entries(map).map(([name, st]) => ({ name, status: st?.status ?? "unknown", error: st?.error ?? null, connected: st?.status === "connected" }))
|
|
355
|
+
}
|
|
356
|
+
const out = await this.locationReq(this.dialect.mcpList?.(), { directory }).catch(() => null)
|
|
357
|
+
const list = OpencodeClient.unwrap(out)
|
|
358
|
+
return Array.isArray(list) ? list : []
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The config the server resolves for a directory. Asking for it also makes
|
|
363
|
+
* the server open that directory as an instance and read its project
|
|
364
|
+
* config — which a session created through the v2 API alone does not do.
|
|
365
|
+
*/
|
|
366
|
+
/** Providers and their models as opencode resolves them for a directory (with variants where known). */
|
|
367
|
+
async providers(directory) {
|
|
368
|
+
const out = await this.req("/config/providers", { query: { directory } }).catch(() => null)
|
|
369
|
+
const list = out?.providers ?? out?.data?.providers ?? (Array.isArray(out) ? out : [])
|
|
370
|
+
return list.flatMap((p) => Object.entries(p.models ?? {}).map(([id, m]) => ({
|
|
371
|
+
id: `${p.id}/${id}`,
|
|
372
|
+
name: m.name ?? id,
|
|
373
|
+
provider: p.name ?? p.id,
|
|
374
|
+
variants: Object.keys(m.variants ?? {}),
|
|
375
|
+
reasoning: !!(m.reasoning ?? m.capabilities?.reasoning),
|
|
376
|
+
})))
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async locationConfig(directory) {
|
|
380
|
+
return this.req("/config", { query: { directory } }).catch(() => null)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** v2: set a session's agent / model after the fact. */
|
|
384
|
+
async setSessionAgent(id, agent) {
|
|
385
|
+
return this.req(`/api/session/${id}/agent`, { method: "POST", body: { agent } })
|
|
386
|
+
}
|
|
387
|
+
async setSessionModel(id, model, variant) {
|
|
388
|
+
const ref = modelRef(model)
|
|
389
|
+
return this.req(`/api/session/${id}/model`, { method: "POST", body: { model: ref } })
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async toolIds(directory) {
|
|
393
|
+
const out = await this.locationReq(this.dialect.toolIds?.(), { directory }).catch(() => null)
|
|
394
|
+
const list = OpencodeClient.unwrap(out)
|
|
395
|
+
return Array.isArray(list) ? list : []
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async setMcpConnected(name, connected, directory) {
|
|
399
|
+
try {
|
|
400
|
+
return await this.req(`/mcp/${encodeURIComponent(name)}/${connected ? "connect" : "disconnect"}`, { method: "POST", body: {}, query: { directory } })
|
|
401
|
+
} catch (e) {
|
|
402
|
+
if (!/-> (404|405)\b/.test(e.message)) throw e
|
|
403
|
+
}
|
|
404
|
+
const path = connected ? this.dialect.mcpConnect?.(name) : this.dialect.mcpDisconnect?.(name)
|
|
405
|
+
return this.locationReq(path, { method: "POST", body: {}, directory })
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Ask opencode to drop what it has cached for a location, so the next
|
|
410
|
+
* session there re-reads its config. Best effort: the route has moved
|
|
411
|
+
* between versions and older servers do not have it.
|
|
412
|
+
*/
|
|
413
|
+
async disposeInstance(directory) {
|
|
414
|
+
const paths = this.dialect.name === "v2" ? ["/api/instance/dispose", "/instance/dispose"] : ["/instance/dispose"]
|
|
415
|
+
for (const path of paths) {
|
|
416
|
+
for (const query of [{ directory }, { "location[directory]": directory }]) {
|
|
417
|
+
try {
|
|
418
|
+
await this.req(path, { method: "POST", body: {}, query })
|
|
419
|
+
return true
|
|
420
|
+
} catch {}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return false
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
compact(id) {
|
|
427
|
+
return this.req(this.dialect.compact(id), { method: "POST", body: {} })
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async pendingPermissions(id) {
|
|
431
|
+
if (this.v1.has(id)) {
|
|
432
|
+
// Instance engine: GET /permission?directory= lists every pending ask there.
|
|
433
|
+
const list = await this.req("/permission", { query: { directory: this.v1.get(id) } }).catch(() => [])
|
|
434
|
+
return (Array.isArray(list) ? list : []).filter((p) => p.sessionID === id).map((p) => ({
|
|
435
|
+
id: p.id,
|
|
436
|
+
action: p.permission,
|
|
437
|
+
patterns: p.patterns ?? [],
|
|
438
|
+
always: p.always ?? [],
|
|
439
|
+
metadata: p.metadata ?? {},
|
|
440
|
+
}))
|
|
441
|
+
}
|
|
442
|
+
const path = this.dialect.permissions(id)
|
|
443
|
+
if (!path) return []
|
|
444
|
+
try {
|
|
445
|
+
const out = await this.req(path)
|
|
446
|
+
const list = OpencodeClient.unwrap(out)
|
|
447
|
+
return Array.isArray(list) ? list : []
|
|
448
|
+
} catch {
|
|
449
|
+
return []
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async replyPermission(id, requestId, decision) {
|
|
454
|
+
if (this.v1.has(id)) {
|
|
455
|
+
const directory = this.v1.get(id)
|
|
456
|
+
try {
|
|
457
|
+
return await this.req(`/permission/${requestId}/reply`, { method: "POST", body: { reply: decision }, query: { directory } })
|
|
458
|
+
} catch (e) {
|
|
459
|
+
if (!/-> (404|405)\b/.test(e.message)) throw e
|
|
460
|
+
return this.req(`/session/${id}/permissions/${requestId}`, { method: "POST", body: { response: decision }, query: { directory } })
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return this.req(this.dialect.permissionReply(id, requestId), {
|
|
464
|
+
method: "POST",
|
|
465
|
+
body: this.dialect.name === "v2" ? { decision } : { response: decision },
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Long-lived SSE subscription with backoff. `onEvent` receives the decoded
|
|
471
|
+
* payload plus whatever routing envelope the server wrapped it in
|
|
472
|
+
* (v2 wraps as { directory, project, workspace, payload }).
|
|
473
|
+
*/
|
|
474
|
+
subscribe({ onEvent, onStatus = () => {} }) {
|
|
475
|
+
let stopped = false
|
|
476
|
+
let backoff = 500
|
|
477
|
+
const ctrls = new Set()
|
|
478
|
+
const controller = { stop: () => { stopped = true; for (const c of ctrls) c.abort() } }
|
|
479
|
+
|
|
480
|
+
const run = async (path, primary) => {
|
|
481
|
+
while (!stopped) {
|
|
482
|
+
const ctrl = new AbortController()
|
|
483
|
+
ctrls.add(ctrl)
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch(this.base + path, {
|
|
486
|
+
headers: { accept: "text/event-stream" },
|
|
487
|
+
signal: ctrl.signal,
|
|
488
|
+
})
|
|
489
|
+
if (!res.ok || !res.body) throw new Error(`event stream ${res.status}`)
|
|
490
|
+
if (primary) onStatus("connected")
|
|
491
|
+
backoff = 500
|
|
492
|
+
let buf = ""
|
|
493
|
+
for await (const chunk of res.body) {
|
|
494
|
+
buf += Buffer.from(chunk).toString("utf8")
|
|
495
|
+
let idx
|
|
496
|
+
while ((idx = buf.indexOf("\n\n")) !== -1) {
|
|
497
|
+
const raw = buf.slice(0, idx)
|
|
498
|
+
buf = buf.slice(idx + 2)
|
|
499
|
+
const data = raw
|
|
500
|
+
.split("\n")
|
|
501
|
+
.filter((l) => l.startsWith("data:"))
|
|
502
|
+
.map((l) => l.slice(5).trim())
|
|
503
|
+
.join("")
|
|
504
|
+
if (!data) continue
|
|
505
|
+
try {
|
|
506
|
+
const parsed = JSON.parse(data)
|
|
507
|
+
onEvent(parsed.payload ?? parsed, parsed)
|
|
508
|
+
} catch {}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
throw new Error("event stream ended")
|
|
512
|
+
} catch (err) {
|
|
513
|
+
if (stopped) return
|
|
514
|
+
if (primary) onStatus("disconnected", err)
|
|
515
|
+
// A server without the global (instance-engine) stream: stop asking.
|
|
516
|
+
if (!primary && /event stream 404/.test(err.message)) return
|
|
517
|
+
await new Promise((r) => setTimeout(r, backoff))
|
|
518
|
+
backoff = Math.min(backoff * 2, 15000)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
run(this.dialect.events, true)
|
|
523
|
+
// The instance engine publishes on /global/event, not the v2 stream.
|
|
524
|
+
if (this.dialect.name === "v2") run("/global/event", false)
|
|
525
|
+
return controller
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** "amazon-bedrock/anthropic.claude-sonnet-5" → { providerID, modelID }. */
|
|
530
|
+
export function modelRef(model, variant = null) {
|
|
531
|
+
if (!model) return null
|
|
532
|
+
if (typeof model === "object") return { ...model, ...(variant ? { variant } : {}) }
|
|
533
|
+
const i = String(model).indexOf("/")
|
|
534
|
+
const ref = i > 0 ? { providerID: model.slice(0, i), id: model.slice(i + 1) } : { providerID: "", id: model }
|
|
535
|
+
if (variant) ref.variant = variant
|
|
536
|
+
return ref
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** What each turn should run with; null when there is nothing to say. */
|
|
540
|
+
function turnExtras({ agent, model, variant, tools }) {
|
|
541
|
+
const x = {}
|
|
542
|
+
if (agent) x.agent = agent
|
|
543
|
+
if (model) { const r = modelRef(model, variant); x.model = { providerID: r.providerID, modelID: r.id } }
|
|
544
|
+
if (variant) x.variant = variant
|
|
545
|
+
if (tools) x.tools = tools
|
|
546
|
+
return Object.keys(x).length ? x : null
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Pull the session id out of an event payload without depending on the exact
|
|
551
|
+
* event name — opencode renames these between releases, the id field is stable.
|
|
552
|
+
*/
|
|
553
|
+
export function sessionIdOf(payload) {
|
|
554
|
+
if (!payload || typeof payload !== "object") return null
|
|
555
|
+
const p = payload.properties ?? payload
|
|
556
|
+
return (
|
|
557
|
+
p.sessionID ??
|
|
558
|
+
p.sessionId ??
|
|
559
|
+
payload.data?.sessionID ??
|
|
560
|
+
p.session?.id ??
|
|
561
|
+
p.info?.sessionID ??
|
|
562
|
+
p.message?.sessionID ??
|
|
563
|
+
p.part?.sessionID ??
|
|
564
|
+
p.item?.sessionID ??
|
|
565
|
+
(typeof p.id === "string" && p.id.startsWith("ses") ? p.id : null)
|
|
566
|
+
)
|
|
567
|
+
}
|