@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.
Files changed (55) hide show
  1. package/.idea/bot-farm.iml +9 -0
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +584 -0
  7. package/package.json +13 -0
  8. package/src/api.mjs +567 -0
  9. package/src/cli.mjs +241 -0
  10. package/src/craft.mjs +172 -0
  11. package/src/db.mjs +93 -0
  12. package/src/farmhands.mjs +111 -0
  13. package/src/git.mjs +578 -0
  14. package/src/identity.mjs +199 -0
  15. package/src/legacy-defaults.mjs +44 -0
  16. package/src/mcp.mjs +681 -0
  17. package/src/mesh.mjs +222 -0
  18. package/src/packets.mjs +125 -0
  19. package/src/patches.mjs +141 -0
  20. package/src/pipelines.mjs +2145 -0
  21. package/src/pricing.mjs +91 -0
  22. package/src/projects.mjs +148 -0
  23. package/src/public/classic.html +1810 -0
  24. package/src/public/index.html +3284 -0
  25. package/src/questions.mjs +69 -0
  26. package/src/quota.mjs +76 -0
  27. package/src/registry.mjs +88 -0
  28. package/src/replay.mjs +151 -0
  29. package/src/rooms.mjs +320 -0
  30. package/src/server.mjs +765 -0
  31. package/src/store.mjs +546 -0
  32. package/src/supervisor.mjs +1803 -0
  33. package/src/tasks.mjs +191 -0
  34. package/src/template.mjs +86 -0
  35. package/src/tools.mjs +241 -0
  36. package/src/workspaces.mjs +605 -0
  37. package/src/ws.mjs +130 -0
  38. package/src/yaml.mjs +266 -0
  39. package/test/board.mjs +108 -0
  40. package/test/craft.mjs +281 -0
  41. package/test/extend.mjs +145 -0
  42. package/test/flow.mjs +165 -0
  43. package/test/handoff.mjs +228 -0
  44. package/test/linked.mjs +83 -0
  45. package/test/loop.mjs +168 -0
  46. package/test/mock-opencode.mjs +217 -0
  47. package/test/parallel.mjs +135 -0
  48. package/test/patches.mjs +93 -0
  49. package/test/repos.mjs +118 -0
  50. package/test/roles.mjs +157 -0
  51. package/test/shot.mjs +65 -0
  52. package/test/smoke.mjs +70 -0
  53. package/test/transport.mjs +131 -0
  54. package/test/workspaces.mjs +228 -0
  55. 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
+ }