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/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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenCode ACP Delegation",
4
+ "description": "Runs opencode as an ACP subagent process and adds a subagent_opencode delegation tool that uses opencode's free models."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "OpenCode ACP 委派",
4
+ "description": "把 opencode 作为 ACP 子进程接成子代理,并新增 subagent_opencode 委派工具,使用 opencode 的免费模型。"
5
+ }
6
+ }