dsh-opencode-acp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -2
- package/README.zh.md +121 -0
- package/client.js +568 -0
- package/cordis.patch.yml +24 -0
- package/icon.svg +6 -0
- package/index.js +653 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +55 -4
package/index.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode ACP 委派 — 宿主半。
|
|
3
|
+
*
|
|
4
|
+
* Registers one subagent provider named `opencode` that drives
|
|
5
|
+
* `opencode acp` (Agent Client Protocol over stdio) in a fresh subprocess, and
|
|
6
|
+
* serves the settings page's JSON routes on the DSH web server.
|
|
7
|
+
*
|
|
8
|
+
* Why a local ACP client instead of `@deepseek-ai/dsh-subagent-acp`: that
|
|
9
|
+
* provider returns only the final assistant text, while this page needs the
|
|
10
|
+
* remote session id and token usage of every run. The protocol surface used
|
|
11
|
+
* here is small: `initialize`, `session/new`, `session/prompt`,
|
|
12
|
+
* `session/cancel`, `session/update`, `session/request_permission`.
|
|
13
|
+
*
|
|
14
|
+
* Settings live in `$DSH_HOME/opencode-acp/settings.json` and are read at the
|
|
15
|
+
* start of every run, so a saved change applies to the next delegation without
|
|
16
|
+
* touching the profile patch or restarting the Host.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto'
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import os from 'node:os'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
|
|
23
|
+
export const name = 'dsh-opencode-acp'
|
|
24
|
+
export const inject = ['subagents', 'subprocess']
|
|
25
|
+
|
|
26
|
+
const ROUTE_PREFIX = '/.dsh-opencode-acp'
|
|
27
|
+
const PROVIDER_NAME = 'opencode'
|
|
28
|
+
const MAX_RUNS = 100
|
|
29
|
+
/** npm's global install of opencode on Windows; a bare name elsewhere. */
|
|
30
|
+
function defaultCommand() {
|
|
31
|
+
if (process.platform === 'win32' && typeof process.env.APPDATA === 'string') {
|
|
32
|
+
return path.join(process.env.APPDATA, 'npm', 'node_modules', '@opencode', 'cli', 'bin', 'opencode.exe')
|
|
33
|
+
}
|
|
34
|
+
return 'opencode'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_MODEL = 'opencode/deepseek-v4.1-flash-free'
|
|
38
|
+
const FALLBACK_MODELS = [
|
|
39
|
+
'opencode/deepseek-v4.1-flash-free',
|
|
40
|
+
'opencode/minimax-m3.1-flash-free',
|
|
41
|
+
'opencode/step-5-preview-free',
|
|
42
|
+
'opencode/mimo-v2.6-flash-free',
|
|
43
|
+
'opencode/mimo-v2.5-free',
|
|
44
|
+
]
|
|
45
|
+
const DEFAULT_SETTINGS = {
|
|
46
|
+
model: DEFAULT_MODEL,
|
|
47
|
+
smallModel: '',
|
|
48
|
+
permission: 'allow',
|
|
49
|
+
cwdMode: 'inherit',
|
|
50
|
+
cwdFixed: '',
|
|
51
|
+
command: defaultCommand(),
|
|
52
|
+
args: ['acp'],
|
|
53
|
+
env: {},
|
|
54
|
+
graceMs: 6000,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ACP_TOOL_KINDS = new Set([
|
|
58
|
+
'read', 'edit', 'delete', 'move', 'search', 'execute', 'think', 'fetch', 'switch_mode', 'other',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
/* ── storage ─────────────────────────────────────────────────────────────── */
|
|
62
|
+
|
|
63
|
+
function homeDirectory() {
|
|
64
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJsonFile(file, fallback) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
70
|
+
} catch {
|
|
71
|
+
return fallback
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeJsonFile(file, value) {
|
|
76
|
+
try {
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
78
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
79
|
+
} catch {
|
|
80
|
+
/* a read-only home must not break delegation */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeSettings(raw) {
|
|
85
|
+
const next = { ...DEFAULT_SETTINGS }
|
|
86
|
+
if (raw && typeof raw === 'object') {
|
|
87
|
+
if (typeof raw.model === 'string') next.model = raw.model.trim()
|
|
88
|
+
if (typeof raw.smallModel === 'string') next.smallModel = raw.smallModel.trim()
|
|
89
|
+
if (raw.permission === 'allow' || raw.permission === 'reject') next.permission = raw.permission
|
|
90
|
+
if (raw.cwdMode === 'fixed' || raw.cwdMode === 'inherit') next.cwdMode = raw.cwdMode
|
|
91
|
+
if (typeof raw.cwdFixed === 'string') next.cwdFixed = raw.cwdFixed.trim()
|
|
92
|
+
if (typeof raw.command === 'string' && raw.command.trim() !== '') next.command = raw.command.trim()
|
|
93
|
+
if (Array.isArray(raw.args)) next.args = raw.args.filter((a) => typeof a === 'string')
|
|
94
|
+
if (raw.env && typeof raw.env === 'object') {
|
|
95
|
+
const env = {}
|
|
96
|
+
for (const [k, v] of Object.entries(raw.env)) {
|
|
97
|
+
if (typeof k === 'string' && k !== '' && typeof v === 'string') env[k] = v
|
|
98
|
+
}
|
|
99
|
+
next.env = env
|
|
100
|
+
}
|
|
101
|
+
const grace = Number(raw.graceMs)
|
|
102
|
+
if (Number.isFinite(grace) && grace >= 500 && grace <= 120000) next.graceMs = Math.round(grace)
|
|
103
|
+
}
|
|
104
|
+
return next
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createStore() {
|
|
108
|
+
const dir = path.join(homeDirectory(), 'opencode-acp')
|
|
109
|
+
const settingsFile = path.join(dir, 'settings.json')
|
|
110
|
+
const runsFile = path.join(dir, 'runs.json')
|
|
111
|
+
const settings = normalizeSettings(readJsonFile(settingsFile, {}))
|
|
112
|
+
const loaded = readJsonFile(runsFile, [])
|
|
113
|
+
const runs = Array.isArray(loaded) ? loaded.filter((r) => r && typeof r === 'object') : []
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
dir,
|
|
117
|
+
settings,
|
|
118
|
+
runs,
|
|
119
|
+
saveSettings(patch) {
|
|
120
|
+
this.settings = normalizeSettings({ ...this.settings, ...patch })
|
|
121
|
+
writeJsonFile(settingsFile, this.settings)
|
|
122
|
+
return this.settings
|
|
123
|
+
},
|
|
124
|
+
startRun(run) {
|
|
125
|
+
this.runs = [run, ...this.runs].slice(0, MAX_RUNS)
|
|
126
|
+
writeJsonFile(runsFile, this.runs)
|
|
127
|
+
},
|
|
128
|
+
finishRun(id, patch) {
|
|
129
|
+
this.runs = this.runs.map((r) => (r.id === id ? { ...r, ...patch } : r))
|
|
130
|
+
writeJsonFile(runsFile, this.runs)
|
|
131
|
+
},
|
|
132
|
+
clearRuns() {
|
|
133
|
+
this.runs = []
|
|
134
|
+
writeJsonFile(runsFile, this.runs)
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Models opencode has used, read from its own state file (no subprocess). */
|
|
140
|
+
function opencodeModels() {
|
|
141
|
+
const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state')
|
|
142
|
+
const file = path.join(stateHome, 'opencode', 'model.json')
|
|
143
|
+
const raw = readJsonFile(file, undefined)
|
|
144
|
+
const found = []
|
|
145
|
+
for (const key of ['recent', 'favorite']) {
|
|
146
|
+
const list = raw && Array.isArray(raw[key]) ? raw[key] : []
|
|
147
|
+
for (const item of list) {
|
|
148
|
+
if (!item || typeof item.providerID !== 'string' || typeof item.modelID !== 'string') continue
|
|
149
|
+
const id = `${item.providerID}/${item.modelID}`
|
|
150
|
+
if (!found.includes(id)) found.push(id)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const id of FALLBACK_MODELS) if (!found.includes(id)) found.push(id)
|
|
154
|
+
return found
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ── ACP run ─────────────────────────────────────────────────────────────── */
|
|
158
|
+
|
|
159
|
+
function stopReasonOf(reason) {
|
|
160
|
+
switch (reason) {
|
|
161
|
+
case 'end_turn':
|
|
162
|
+
return 'completed'
|
|
163
|
+
case 'max_tokens':
|
|
164
|
+
return 'max-tokens'
|
|
165
|
+
case 'refusal':
|
|
166
|
+
return 'refusal'
|
|
167
|
+
case 'cancelled':
|
|
168
|
+
return 'aborted'
|
|
169
|
+
default:
|
|
170
|
+
return 'error'
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function failureDiagnostic(stage, category) {
|
|
175
|
+
return `Subagent failure (provider: opencode; stage: ${stage}; category: ${category})`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function promptText(prompt) {
|
|
179
|
+
const parts = []
|
|
180
|
+
for (const block of Array.isArray(prompt) ? prompt : []) {
|
|
181
|
+
if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
182
|
+
}
|
|
183
|
+
return parts.join('\n\n')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Start one ACP child, complete the handshake, then run the prompt.
|
|
188
|
+
* A handshake failure rejects so the delegation tool reports an error instead
|
|
189
|
+
* of publishing an empty child.
|
|
190
|
+
*/
|
|
191
|
+
async function startAcpRun({ ctx, store, settings, cwd, prompt, signal }) {
|
|
192
|
+
const runId = randomUUID()
|
|
193
|
+
const startedAt = Date.now()
|
|
194
|
+
const record = {
|
|
195
|
+
id: runId,
|
|
196
|
+
at: startedAt,
|
|
197
|
+
cwd,
|
|
198
|
+
model: settings.model,
|
|
199
|
+
status: 'running',
|
|
200
|
+
stopReason: null,
|
|
201
|
+
ms: null,
|
|
202
|
+
sessionId: null,
|
|
203
|
+
tokens: null,
|
|
204
|
+
toolCalls: 0,
|
|
205
|
+
promptPreview: prompt.slice(0, 160),
|
|
206
|
+
diagnostic: null,
|
|
207
|
+
}
|
|
208
|
+
store.startRun(record)
|
|
209
|
+
|
|
210
|
+
const env = { ...process.env, ...settings.env }
|
|
211
|
+
const inline = { autoupdate: false }
|
|
212
|
+
if (settings.model !== '') inline.model = settings.model
|
|
213
|
+
if (settings.smallModel !== '') inline.small_model = settings.smallModel
|
|
214
|
+
env.OPENCODE_CONFIG_CONTENT = JSON.stringify(inline)
|
|
215
|
+
|
|
216
|
+
let child
|
|
217
|
+
try {
|
|
218
|
+
child = ctx.subprocess.spawn({
|
|
219
|
+
argv: [settings.command, ...settings.args],
|
|
220
|
+
cwd,
|
|
221
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
|
|
222
|
+
graceMs: settings.graceMs,
|
|
223
|
+
env,
|
|
224
|
+
})
|
|
225
|
+
} catch (error) {
|
|
226
|
+
store.finishRun(runId, {
|
|
227
|
+
status: 'error',
|
|
228
|
+
stopReason: 'error',
|
|
229
|
+
ms: Date.now() - startedAt,
|
|
230
|
+
diagnostic: failureDiagnostic('process', 'process-start'),
|
|
231
|
+
})
|
|
232
|
+
throw new Error(failureDiagnostic('process', 'process-start'), { cause: error })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const text = []
|
|
236
|
+
let tokens = null
|
|
237
|
+
let sessionId = null
|
|
238
|
+
let toolCalls = 0
|
|
239
|
+
let exitSeen = null
|
|
240
|
+
const pending = new Map()
|
|
241
|
+
let nextId = 1
|
|
242
|
+
let buffer = ''
|
|
243
|
+
let disposed = false
|
|
244
|
+
let disposedPromise
|
|
245
|
+
|
|
246
|
+
child.done.then(
|
|
247
|
+
(outcome) => {
|
|
248
|
+
exitSeen = outcome
|
|
249
|
+
},
|
|
250
|
+
() => {
|
|
251
|
+
exitSeen = { exitCode: null, signal: null }
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
const send = (message) => {
|
|
256
|
+
try {
|
|
257
|
+
child.stdin?.write(`${JSON.stringify(message)}\n`)
|
|
258
|
+
} catch {
|
|
259
|
+
/* the exit path reports the failure */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const notify = (method, params) => send({ jsonrpc: '2.0', method, params })
|
|
263
|
+
const request = (method, params) => {
|
|
264
|
+
const id = nextId++
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
pending.set(id, { resolve, reject })
|
|
267
|
+
send({ jsonrpc: '2.0', id, method, params })
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const reply = (id, result) => send({ jsonrpc: '2.0', id, result })
|
|
272
|
+
const replyError = (id, message) =>
|
|
273
|
+
send({ jsonrpc: '2.0', id, error: { code: -32601, message } })
|
|
274
|
+
|
|
275
|
+
const handleUpdate = (update) => {
|
|
276
|
+
if (!update || typeof update !== 'object') return
|
|
277
|
+
const kind = update.sessionUpdate
|
|
278
|
+
if (kind === 'agent_message_chunk') {
|
|
279
|
+
const part = update.content
|
|
280
|
+
if (part && part.type === 'text' && typeof part.text === 'string') text.push(part.text)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (kind === 'tool_call' || kind === 'tool_call_update') {
|
|
284
|
+
if (kind === 'tool_call') toolCalls += 1
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (kind === 'usage_update') {
|
|
288
|
+
const usage = update.usage ?? update.tokens ?? update
|
|
289
|
+
if (usage && typeof usage === 'object') tokens = readTokens(usage)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const handleLine = (line) => {
|
|
294
|
+
let message
|
|
295
|
+
try {
|
|
296
|
+
message = JSON.parse(line)
|
|
297
|
+
} catch {
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
if (!message || typeof message !== 'object') return
|
|
301
|
+
|
|
302
|
+
if (typeof message.method === 'string' && message.id !== undefined) {
|
|
303
|
+
const method = message.method
|
|
304
|
+
if (method === 'session/request_permission') {
|
|
305
|
+
const params = message.params ?? {}
|
|
306
|
+
const options = Array.isArray(params.options) ? params.options : []
|
|
307
|
+
const kind = ACP_TOOL_KINDS.has(params.toolCall?.kind) ? params.toolCall.kind : 'unknown'
|
|
308
|
+
if (settings.permission === 'allow') {
|
|
309
|
+
const allow = options.find((o) => o && (o.kind === 'allow_once' || o.kind === 'allow_always'))
|
|
310
|
+
if (allow) {
|
|
311
|
+
reply(message.id, { outcome: { outcome: 'selected', optionId: allow.optionId } })
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
reply(message.id, { outcome: { outcome: 'cancelled' } })
|
|
316
|
+
if (settings.permission !== 'allow' || !options.length) {
|
|
317
|
+
store.finishRun(runId, { diagnostic: `permission denied (kind: ${kind})` })
|
|
318
|
+
}
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
if (method === 'fs/read_text_file' || method === 'fs/write_text_file') {
|
|
322
|
+
replyError(message.id, 'client file access is not offered')
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
replyError(message.id, `unsupported client method: ${method}`)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (typeof message.method === 'string') {
|
|
330
|
+
if (message.method === 'session/update') handleUpdate(message.params?.update)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const waiter = pending.get(message.id)
|
|
335
|
+
if (waiter === undefined) return
|
|
336
|
+
pending.delete(message.id)
|
|
337
|
+
if (message.error) {
|
|
338
|
+
const textPart = typeof message.error.message === 'string' ? message.error.message : 'ACP error'
|
|
339
|
+
waiter.reject(new Error(textPart))
|
|
340
|
+
} else {
|
|
341
|
+
waiter.resolve(message.result)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
child.stdout?.on('data', (chunk) => {
|
|
346
|
+
buffer += chunk.toString('utf8')
|
|
347
|
+
let index
|
|
348
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
349
|
+
const line = buffer.slice(0, index).trim()
|
|
350
|
+
buffer = buffer.slice(index + 1)
|
|
351
|
+
if (line !== '') handleLine(line)
|
|
352
|
+
}
|
|
353
|
+
if (buffer.length > 8 * 1024 * 1024) buffer = ''
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const dispose = () => {
|
|
357
|
+
if (disposedPromise !== undefined) return disposedPromise
|
|
358
|
+
disposed = true
|
|
359
|
+
disposedPromise = (async () => {
|
|
360
|
+
try {
|
|
361
|
+
child.stdin?.end()
|
|
362
|
+
} catch {
|
|
363
|
+
/* already closed */
|
|
364
|
+
}
|
|
365
|
+
let exited = false
|
|
366
|
+
try {
|
|
367
|
+
const bound = AbortSignal.timeout(settings.graceMs)
|
|
368
|
+
exited = await child.waitForExit(bound)
|
|
369
|
+
} catch {
|
|
370
|
+
exited = false
|
|
371
|
+
}
|
|
372
|
+
if (!exited) {
|
|
373
|
+
try {
|
|
374
|
+
child.terminate()
|
|
375
|
+
} catch {
|
|
376
|
+
/* already gone */
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
await child.waitForExit(AbortSignal.timeout(settings.graceMs))
|
|
380
|
+
} catch {
|
|
381
|
+
/* bounded anyway */
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const waiter of pending.values()) waiter.reject(new Error('ACP child disposed'))
|
|
385
|
+
pending.clear()
|
|
386
|
+
})()
|
|
387
|
+
return disposedPromise
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const onAbort = () => {
|
|
391
|
+
if (sessionId !== null) notify('session/cancel', { sessionId })
|
|
392
|
+
}
|
|
393
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
394
|
+
|
|
395
|
+
const settle = (stopReason, diagnostic) => {
|
|
396
|
+
const ms = Date.now() - startedAt
|
|
397
|
+
store.finishRun(runId, {
|
|
398
|
+
status: stopReason === 'completed' ? 'completed' : stopReason,
|
|
399
|
+
stopReason,
|
|
400
|
+
ms,
|
|
401
|
+
sessionId,
|
|
402
|
+
tokens,
|
|
403
|
+
toolCalls,
|
|
404
|
+
diagnostic: diagnostic ?? null,
|
|
405
|
+
})
|
|
406
|
+
signal?.removeEventListener('abort', onAbort)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* handshake — failures reject before the child is published */
|
|
410
|
+
try {
|
|
411
|
+
await request('initialize', { protocolVersion: 1, clientCapabilities: {} })
|
|
412
|
+
const session = await request('session/new', { cwd, mcpServers: [] })
|
|
413
|
+
const id = session && typeof session.sessionId === 'string' ? session.sessionId : undefined
|
|
414
|
+
if (id === undefined) throw new Error('child published without a session id')
|
|
415
|
+
sessionId = id
|
|
416
|
+
store.finishRun(runId, { sessionId })
|
|
417
|
+
} catch (error) {
|
|
418
|
+
settle('error', failureDiagnostic('initialize', 'handshake'))
|
|
419
|
+
await dispose().catch(() => {})
|
|
420
|
+
throw new Error(failureDiagnostic('initialize', 'handshake'), { cause: error })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const result = (async () => {
|
|
424
|
+
let stopReason = 'error'
|
|
425
|
+
let diagnostic = null
|
|
426
|
+
try {
|
|
427
|
+
if (signal?.aborted) throw new Error('cancelled before the prompt started')
|
|
428
|
+
const answered = await request('session/prompt', {
|
|
429
|
+
sessionId,
|
|
430
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
431
|
+
})
|
|
432
|
+
stopReason = stopReasonOf(answered?.stopReason)
|
|
433
|
+
if (answered?.usage && typeof answered.usage === 'object') {
|
|
434
|
+
tokens = readTokens(answered.usage)
|
|
435
|
+
}
|
|
436
|
+
if (stopReason === 'error') {
|
|
437
|
+
diagnostic = `Subagent failure (provider: opencode; stage: prompt; category: remote-limit)`
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
stopReason = signal?.aborted ? 'aborted' : 'error'
|
|
441
|
+
if (!signal?.aborted) {
|
|
442
|
+
const category = exitSeen === null ? 'transport' : 'process-exit'
|
|
443
|
+
const suffix = exitSeen && typeof exitSeen.exitCode === 'number' ? `; exit code: ${exitSeen.exitCode}` : ''
|
|
444
|
+
diagnostic = `Subagent failure (provider: opencode; stage: prompt; category: ${category}${suffix})`
|
|
445
|
+
}
|
|
446
|
+
void error
|
|
447
|
+
} finally {
|
|
448
|
+
settle(stopReason, diagnostic)
|
|
449
|
+
await dispose().catch(() => {})
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
output: text.length === 0 ? [] : [{ type: 'text', text: text.join('') }],
|
|
453
|
+
stopReason,
|
|
454
|
+
...(diagnostic === null ? {} : { diagnostic }),
|
|
455
|
+
}
|
|
456
|
+
})()
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
id: runId,
|
|
460
|
+
result,
|
|
461
|
+
dispose,
|
|
462
|
+
/* used by the plugin's unload cleanup */
|
|
463
|
+
get disposed() {
|
|
464
|
+
return disposed
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function readTokens(usage) {
|
|
470
|
+
const num = (value) => (Number.isFinite(value) ? value : 0)
|
|
471
|
+
return {
|
|
472
|
+
input: num(usage.inputTokens ?? usage.input ?? 0),
|
|
473
|
+
output: num(usage.outputTokens ?? usage.output ?? 0),
|
|
474
|
+
total: num(usage.totalTokens ?? usage.total ?? 0),
|
|
475
|
+
cacheRead: num(usage.cachedReadTokens ?? usage.cacheRead ?? 0),
|
|
476
|
+
cacheWrite: num(usage.cachedWriteTokens ?? usage.cacheWrite ?? 0),
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
class OpencodeProvider {
|
|
481
|
+
constructor(ctx, store, live) {
|
|
482
|
+
this.name = PROVIDER_NAME
|
|
483
|
+
this.capabilities = {
|
|
484
|
+
agentOptions: false,
|
|
485
|
+
agentPreset: false,
|
|
486
|
+
outputSchema: false,
|
|
487
|
+
depthLimit: false,
|
|
488
|
+
}
|
|
489
|
+
this.inheritsParentContext = false
|
|
490
|
+
this.ctx = ctx
|
|
491
|
+
this.store = store
|
|
492
|
+
this.live = live
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async start(request) {
|
|
496
|
+
if (request.signal?.aborted) throw new Error('subagent request was aborted before start')
|
|
497
|
+
const settings = this.store.settings
|
|
498
|
+
const cwd =
|
|
499
|
+
settings.cwdMode === 'fixed' && settings.cwdFixed !== ''
|
|
500
|
+
? settings.cwdFixed
|
|
501
|
+
: typeof request.cwd === 'string' && request.cwd !== ''
|
|
502
|
+
? request.cwd
|
|
503
|
+
: process.cwd()
|
|
504
|
+
let stat
|
|
505
|
+
try {
|
|
506
|
+
stat = fs.statSync(cwd)
|
|
507
|
+
} catch {
|
|
508
|
+
throw new Error(failureDiagnostic('initialize', 'configuration'))
|
|
509
|
+
}
|
|
510
|
+
if (!stat.isDirectory()) throw new Error(failureDiagnostic('initialize', 'configuration'))
|
|
511
|
+
|
|
512
|
+
const run = await startAcpRun({
|
|
513
|
+
ctx: this.ctx,
|
|
514
|
+
store: this.store,
|
|
515
|
+
settings,
|
|
516
|
+
cwd,
|
|
517
|
+
prompt: promptText(request.prompt),
|
|
518
|
+
signal: request.signal,
|
|
519
|
+
})
|
|
520
|
+
this.live.add(run)
|
|
521
|
+
const release = () => this.live.delete(run)
|
|
522
|
+
run.result.then(release, release)
|
|
523
|
+
return { id: run.id, result: run.result, dispose: run.dispose }
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/* ── web routes ──────────────────────────────────────────────────────────── */
|
|
528
|
+
|
|
529
|
+
function writeJson(res, status, value) {
|
|
530
|
+
res.statusCode = status
|
|
531
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
532
|
+
res.setHeader('cache-control', 'no-store')
|
|
533
|
+
res.end(JSON.stringify(value))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function readBody(req) {
|
|
537
|
+
const chunks = []
|
|
538
|
+
let size = 0
|
|
539
|
+
for await (const chunk of req) {
|
|
540
|
+
size += chunk.length
|
|
541
|
+
if (size > 1_000_000) throw new Error('body too large')
|
|
542
|
+
chunks.push(chunk)
|
|
543
|
+
}
|
|
544
|
+
if (chunks.length === 0) return {}
|
|
545
|
+
try {
|
|
546
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
547
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
548
|
+
} catch {
|
|
549
|
+
throw new Error('invalid JSON body')
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Spawn `opencode --version` in collect mode: proves the executable resolves and
|
|
555
|
+
* returns the version string, without starting a server.
|
|
556
|
+
*/
|
|
557
|
+
async function probeVersion(ctx, settings) {
|
|
558
|
+
const child = ctx.subprocess.spawn({
|
|
559
|
+
argv: [settings.command, '--version'],
|
|
560
|
+
cwd: homeDirectory(),
|
|
561
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } },
|
|
562
|
+
graceMs: 8000,
|
|
563
|
+
env: { ...process.env },
|
|
564
|
+
})
|
|
565
|
+
const timeout = AbortSignal.timeout(15000)
|
|
566
|
+
try {
|
|
567
|
+
await child.done
|
|
568
|
+
} catch {
|
|
569
|
+
/* outcome below carries the facts */
|
|
570
|
+
}
|
|
571
|
+
let outcome = null
|
|
572
|
+
try {
|
|
573
|
+
outcome = await child.waitForExit(timeout)
|
|
574
|
+
} catch {
|
|
575
|
+
outcome = null
|
|
576
|
+
}
|
|
577
|
+
if (outcome === false) {
|
|
578
|
+
try {
|
|
579
|
+
child.terminate()
|
|
580
|
+
} catch {
|
|
581
|
+
/* already gone */
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const stdout = child.collected?.stdout?.readFrom(0).text ?? ''
|
|
585
|
+
const stderr = child.collected?.stderr?.readFrom(0).text ?? ''
|
|
586
|
+
const version = (stdout || stderr).trim().split(/\r?\n/).filter(Boolean)[0] ?? ''
|
|
587
|
+
return { ok: version !== '', version }
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function registerRoutes(ctx, store, state) {
|
|
591
|
+
const webServer = ctx.get('webServer')
|
|
592
|
+
if (webServer === undefined) return () => {}
|
|
593
|
+
|
|
594
|
+
return webServer.register({
|
|
595
|
+
kind: 'prefix',
|
|
596
|
+
path: ROUTE_PREFIX,
|
|
597
|
+
handler: async (req, res) => {
|
|
598
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
599
|
+
const route = url.pathname
|
|
600
|
+
try {
|
|
601
|
+
if (route === `${ROUTE_PREFIX}/state` && req.method === 'GET') {
|
|
602
|
+
return writeJson(res, 200, {
|
|
603
|
+
settings: store.settings,
|
|
604
|
+
models: opencodeModels(),
|
|
605
|
+
runs: store.runs,
|
|
606
|
+
home: homeDirectory(),
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
if (route === `${ROUTE_PREFIX}/settings` && req.method === 'POST') {
|
|
610
|
+
const body = await readBody(req)
|
|
611
|
+
const settings = store.saveSettings(body)
|
|
612
|
+
return writeJson(res, 200, { settings })
|
|
613
|
+
}
|
|
614
|
+
if (route === `${ROUTE_PREFIX}/health` && req.method === 'POST') {
|
|
615
|
+
const probe = await probeVersion(ctx, store.settings)
|
|
616
|
+
state.health = { ...probe, at: Date.now() }
|
|
617
|
+
return writeJson(res, 200, state.health)
|
|
618
|
+
}
|
|
619
|
+
if (route === `${ROUTE_PREFIX}/runs/clear` && req.method === 'POST') {
|
|
620
|
+
store.clearRuns()
|
|
621
|
+
return writeJson(res, 200, { runs: [] })
|
|
622
|
+
}
|
|
623
|
+
return writeJson(res, 404, { error: 'not found' })
|
|
624
|
+
} catch (error) {
|
|
625
|
+
return writeJson(res, 500, { error: error instanceof Error ? error.message : 'internal error' })
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/* ── plugin ──────────────────────────────────────────────────────────────── */
|
|
632
|
+
|
|
633
|
+
export function apply(ctx) {
|
|
634
|
+
const store = createStore()
|
|
635
|
+
const live = new Set()
|
|
636
|
+
const state = { health: null }
|
|
637
|
+
const provider = new OpencodeProvider(ctx, store, live)
|
|
638
|
+
|
|
639
|
+
ctx.effect(() => {
|
|
640
|
+
const disposeProvider = ctx.subagents.registerProvider(provider)
|
|
641
|
+
const disposeRoutes = registerRoutes(ctx, store, state)
|
|
642
|
+
return async () => {
|
|
643
|
+
try {
|
|
644
|
+
disposeRoutes()
|
|
645
|
+
} catch {
|
|
646
|
+
/* already disposed */
|
|
647
|
+
}
|
|
648
|
+
disposeProvider()
|
|
649
|
+
await Promise.all([...live].map((run) => run.dispose().catch(() => {})))
|
|
650
|
+
live.clear()
|
|
651
|
+
}
|
|
652
|
+
})
|
|
653
|
+
}
|
package/locale/en.json
ADDED