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/client.js ADDED
@@ -0,0 +1,568 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-opencode-acp',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const { useState, useEffect, useCallback } = React
7
+
8
+ const PREFIX = '/.dsh-opencode-acp'
9
+ const MODULE_NAME = 'dsh-opencode-acp'
10
+
11
+ /** Every page falls back to this when a theme token is missing. */
12
+ const T = {
13
+ base: 'var(--dsw-alias-bg-base)',
14
+ layer1: 'var(--dsw-alias-bg-layer-1)',
15
+ layer2: 'var(--dsw-alias-bg-layer-2)',
16
+ border1: 'var(--dsw-alias-border-l1)',
17
+ border2: 'var(--dsw-alias-border-l2)',
18
+ text: 'var(--dsw-alias-label-primary)',
19
+ dim: 'var(--dsw-alias-label-secondary)',
20
+ brand: 'var(--dsw-alias-brand-primary)',
21
+ ok: 'var(--dsw-alias-state-success-primary)',
22
+ err: 'var(--dsw-alias-state-error-primary)',
23
+ warn: 'var(--dsw-alias-state-warn-primary)',
24
+ idle: 'var(--dsw-alias-state-idle-primary)',
25
+ }
26
+
27
+ const S = {
28
+ page: { display: 'flex', flexDirection: 'column', gap: 18, padding: '4px 2px 32px', color: T.text },
29
+ card: {
30
+ background: T.layer1,
31
+ border: `1px solid ${T.border1}`,
32
+ borderRadius: 12,
33
+ padding: '14px 16px',
34
+ display: 'flex',
35
+ flexDirection: 'column',
36
+ gap: 12,
37
+ },
38
+ cardTitle: { fontSize: 13, fontWeight: 600, color: T.text, margin: 0 },
39
+ hint: { fontSize: 12, color: T.dim, lineHeight: 1.5, margin: 0 },
40
+ row: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' },
41
+ label: { fontSize: 13, color: T.text, minWidth: 96 },
42
+ input: {
43
+ flex: 1,
44
+ minWidth: 220,
45
+ fontSize: 13,
46
+ padding: '7px 10px',
47
+ borderRadius: 8,
48
+ border: `1px solid ${T.border2}`,
49
+ background: T.base,
50
+ color: T.text,
51
+ fontFamily: 'inherit',
52
+ },
53
+ select: {
54
+ flex: 1,
55
+ minWidth: 220,
56
+ fontSize: 13,
57
+ padding: '7px 10px',
58
+ borderRadius: 8,
59
+ border: `1px solid ${T.border2}`,
60
+ background: T.layer1,
61
+ color: T.text,
62
+ fontFamily: 'inherit',
63
+ },
64
+ button: {
65
+ fontSize: 13,
66
+ padding: '7px 14px',
67
+ borderRadius: 8,
68
+ border: `1px solid ${T.border2}`,
69
+ background: T.layer2,
70
+ color: T.text,
71
+ cursor: 'pointer',
72
+ fontFamily: 'inherit',
73
+ },
74
+ primary: {
75
+ fontSize: 13,
76
+ padding: '7px 16px',
77
+ borderRadius: 8,
78
+ border: `1px solid ${T.brand}`,
79
+ background: T.brand,
80
+ color: '#fff',
81
+ cursor: 'pointer',
82
+ fontFamily: 'inherit',
83
+ fontWeight: 600,
84
+ },
85
+ table: { width: '100%', borderCollapse: 'collapse', fontSize: 12 },
86
+ th: {
87
+ textAlign: 'left',
88
+ fontWeight: 600,
89
+ color: T.dim,
90
+ padding: '6px 8px',
91
+ borderBottom: `1px solid ${T.border1}`,
92
+ whiteSpace: 'nowrap',
93
+ },
94
+ td: { padding: '6px 8px', borderBottom: `1px solid ${T.border1}`, verticalAlign: 'top' },
95
+ mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11.5 },
96
+ }
97
+
98
+ async function call(path, init) {
99
+ const response = await fetch(path, {
100
+ ...init,
101
+ headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
102
+ })
103
+ const text = await response.text()
104
+ let body = null
105
+ try {
106
+ body = text === '' ? null : JSON.parse(text)
107
+ } catch {
108
+ body = null
109
+ }
110
+ if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`)
111
+ return body
112
+ }
113
+
114
+ function stamp(ms) {
115
+ if (!Number.isFinite(ms)) return '—'
116
+ const date = new Date(ms)
117
+ const pad = (n) => String(n).padStart(2, '0')
118
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
119
+ }
120
+
121
+ function duration(ms) {
122
+ if (!Number.isFinite(ms)) return '—'
123
+ if (ms < 1000) return `${ms}ms`
124
+ const seconds = ms / 1000
125
+ if (seconds < 60) return `${seconds.toFixed(1)}s`
126
+ return `${Math.floor(seconds / 60)}m${String(Math.round(seconds % 60)).padStart(2, '0')}s`
127
+ }
128
+
129
+ function statusColor(status) {
130
+ if (status === 'completed') return T.ok
131
+ if (status === 'running') return T.brand
132
+ if (status === 'aborted') return T.idle
133
+ return T.err
134
+ }
135
+
136
+ function statusText(status) {
137
+ switch (status) {
138
+ case 'completed':
139
+ return '完成'
140
+ case 'running':
141
+ return '运行中'
142
+ case 'aborted':
143
+ return '已取消'
144
+ case 'error':
145
+ return '失败'
146
+ case 'max-tokens':
147
+ return '超长截断'
148
+ case 'refusal':
149
+ return '拒绝'
150
+ default:
151
+ return status ?? '—'
152
+ }
153
+ }
154
+
155
+ function tokensText(tokens) {
156
+ if (!tokens) return '—'
157
+ const parts = [`↑${tokens.input ?? 0}`, `↓${tokens.output ?? 0}`]
158
+ if (tokens.cacheRead) parts.push(`cache ${tokens.cacheRead}`)
159
+ return parts.join(' · ')
160
+ }
161
+
162
+ function Field({ label, children }) {
163
+ return h('div', { style: S.row }, h('div', { style: S.label }, label), children)
164
+ }
165
+
166
+ function Section({ title, hint, children, extra }) {
167
+ return h(
168
+ 'section',
169
+ { style: S.card },
170
+ h(
171
+ 'div',
172
+ { style: { display: 'flex', alignItems: 'center', gap: 8 } },
173
+ h('h3', { style: S.cardTitle }, title),
174
+ extra ? h('div', { style: { marginLeft: 'auto', display: 'flex', gap: 8 } }, extra) : null,
175
+ ),
176
+ hint ? h('p', { style: S.hint }, hint) : null,
177
+ children,
178
+ )
179
+ }
180
+
181
+ function OpencodeSettings() {
182
+ const [data, setData] = useState(null)
183
+ const [draft, setDraft] = useState(null)
184
+ const [envText, setEnvText] = useState('{}')
185
+ const [note, setNote] = useState('')
186
+ const [error, setError] = useState('')
187
+ const [busy, setBusy] = useState('')
188
+ const [health, setHealth] = useState(null)
189
+ const [openRun, setOpenRun] = useState(null)
190
+
191
+ const load = useCallback(async () => {
192
+ setError('')
193
+ try {
194
+ const state = await call(`${PREFIX}/state`)
195
+ setData(state)
196
+ setDraft(state.settings)
197
+ setEnvText(JSON.stringify(state.settings.env ?? {}, null, 2))
198
+ } catch (cause) {
199
+ setError(cause instanceof Error ? cause.message : String(cause))
200
+ }
201
+ }, [])
202
+
203
+ useEffect(() => {
204
+ void load()
205
+ }, [load])
206
+
207
+ const update = (patch) => setDraft((current) => ({ ...current, ...patch }))
208
+
209
+ const save = async () => {
210
+ setBusy('save')
211
+ setNote('')
212
+ setError('')
213
+ try {
214
+ let env = {}
215
+ const trimmed = envText.trim()
216
+ if (trimmed !== '') {
217
+ const parsed = JSON.parse(trimmed)
218
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
219
+ throw new Error('环境变量必须是 JSON 对象')
220
+ }
221
+ env = parsed
222
+ }
223
+ const payload = {
224
+ ...draft,
225
+ args: String(draft.argsText ?? draft.args.join(' '))
226
+ .split(/\s+/)
227
+ .filter(Boolean),
228
+ env,
229
+ }
230
+ delete payload.argsText
231
+ const body = await call(`${PREFIX}/settings`, { method: 'POST', body: JSON.stringify(payload) })
232
+ setDraft(body.settings)
233
+ setNote('已保存,下一次委派即生效')
234
+ void load()
235
+ } catch (cause) {
236
+ setError(cause instanceof Error ? cause.message : String(cause))
237
+ } finally {
238
+ setBusy('')
239
+ }
240
+ }
241
+
242
+ const runHealth = async () => {
243
+ setBusy('health')
244
+ setError('')
245
+ try {
246
+ const result = await call(`${PREFIX}/health`, { method: 'POST', body: '{}' })
247
+ setHealth(result)
248
+ } catch (cause) {
249
+ setError(cause instanceof Error ? cause.message : String(cause))
250
+ } finally {
251
+ setBusy('')
252
+ }
253
+ }
254
+
255
+ const clearRuns = async () => {
256
+ setBusy('clear')
257
+ try {
258
+ await call(`${PREFIX}/runs/clear`, { method: 'POST', body: '{}' })
259
+ await load()
260
+ } catch (cause) {
261
+ setError(cause instanceof Error ? cause.message : String(cause))
262
+ } finally {
263
+ setBusy('')
264
+ }
265
+ }
266
+
267
+ if (draft === null) {
268
+ return h(
269
+ 'div',
270
+ { style: S.page },
271
+ h('p', { style: S.hint }, error === '' ? '正在读取 OpenCode 设置…' : `读取失败:${error}`),
272
+ )
273
+ }
274
+
275
+ const models = Array.isArray(data?.models) ? data.models : []
276
+ const known = models.includes(draft.model)
277
+ const runs = Array.isArray(data?.runs) ? data.runs : []
278
+
279
+ return h(
280
+ 'div',
281
+ { style: S.page },
282
+ h(
283
+ 'div',
284
+ null,
285
+ h('h2', { style: { fontSize: 16, fontWeight: 600, margin: '0 0 4px' } }, 'OpenCode 委派'),
286
+ h(
287
+ 'p',
288
+ { style: S.hint },
289
+ '每次委派在独立进程里启动 ', h('code', { style: S.mono }, 'opencode acp'),
290
+ ',用你自己的 opencode 登录与免费模型干活。设置保存在 ',
291
+ h('code', { style: S.mono }, data?.home ?? ''),
292
+ '/opencode-acp。',
293
+ ),
294
+ ),
295
+
296
+ error !== '' ? h('p', { style: { ...S.hint, color: T.err } }, `出错了:${error}`) : null,
297
+
298
+ h(
299
+ Section,
300
+ {
301
+ title: '模型',
302
+ hint: 'opencode 侧的模型选择:ACP 协议不带模型参数,所以在这里改,并通过 OPENCODE_CONFIG_CONTENT 注入子进程。',
303
+ extra: h(
304
+ 'button',
305
+ { style: S.button, onClick: runHealth, disabled: busy !== '' },
306
+ busy === 'health' ? '检查中…' : '健康检查',
307
+ ),
308
+ },
309
+ health !== null
310
+ ? h(
311
+ 'p',
312
+ { style: { ...S.hint, color: health.ok ? T.ok : T.err } },
313
+ health.ok
314
+ ? `opencode 可用:${health.version || '版本未知'}`
315
+ : 'opencode 不可用:请检查下面的命令路径',
316
+ )
317
+ : null,
318
+ h(
319
+ Field,
320
+ { label: '主模型' },
321
+ h(
322
+ 'select',
323
+ {
324
+ style: S.select,
325
+ value: known ? draft.model : '__custom',
326
+ onChange: (event) => {
327
+ const value = event.target.value
328
+ if (value === '__custom') update({ model: '' })
329
+ else update({ model: value })
330
+ },
331
+ },
332
+ models.map((id) => h('option', { key: id, value: id }, id)),
333
+ h('option', { value: '__custom' }, '自定义 / 手填…'),
334
+ ),
335
+ ),
336
+ known
337
+ ? null
338
+ : h(
339
+ Field,
340
+ { label: '模型 id' },
341
+ h('input', {
342
+ style: S.input,
343
+ value: draft.model,
344
+ placeholder: 'provider/model,例如 opencode/deepseek-v4.1-flash-free',
345
+ onChange: (event) => update({ model: event.target.value }),
346
+ }),
347
+ ),
348
+ h(
349
+ Field,
350
+ { label: '小模型' },
351
+ h('input', {
352
+ style: S.input,
353
+ value: draft.smallModel ?? '',
354
+ placeholder: '留空则让 opencode 自己挑(标题、小任务用)',
355
+ onChange: (event) => update({ smallModel: event.target.value }),
356
+ }),
357
+ ),
358
+ ),
359
+
360
+ h(
361
+ Section,
362
+ { title: '执行方式' },
363
+ h(
364
+ Field,
365
+ { label: '权限应答' },
366
+ h(
367
+ 'select',
368
+ {
369
+ style: S.select,
370
+ value: draft.permission,
371
+ onChange: (event) => update({ permission: event.target.value }),
372
+ },
373
+ h('option', { value: 'allow' }, 'allow — 自动放行(能干真活)'),
374
+ h('option', { value: 'reject' }, 'reject — 一律拒绝(只读试探)'),
375
+ ),
376
+ ),
377
+ h(
378
+ Field,
379
+ { label: '工作目录' },
380
+ h(
381
+ 'select',
382
+ {
383
+ style: S.select,
384
+ value: draft.cwdMode,
385
+ onChange: (event) => update({ cwdMode: event.target.value }),
386
+ },
387
+ h('option', { value: 'inherit' }, '跟随调用方会话的目录'),
388
+ h('option', { value: 'fixed' }, '固定目录'),
389
+ ),
390
+ ),
391
+ draft.cwdMode === 'fixed'
392
+ ? h(
393
+ Field,
394
+ { label: '固定路径' },
395
+ h('input', {
396
+ style: S.input,
397
+ value: draft.cwdFixed ?? '',
398
+ placeholder: 'D:\\项目\\某个仓库',
399
+ onChange: (event) => update({ cwdFixed: event.target.value }),
400
+ }),
401
+ )
402
+ : null,
403
+ ),
404
+
405
+ h(
406
+ 'details',
407
+ { style: { ...S.card, gap: 10 } },
408
+ h('summary', { style: { ...S.cardTitle, cursor: 'pointer' } }, '高级'),
409
+ h(
410
+ Field,
411
+ { label: '启动命令' },
412
+ h('input', {
413
+ style: { ...S.input, ...S.mono },
414
+ value: draft.command,
415
+ onChange: (event) => update({ command: event.target.value }),
416
+ }),
417
+ ),
418
+ h(
419
+ Field,
420
+ { label: '参数' },
421
+ h('input', {
422
+ style: { ...S.input, ...S.mono },
423
+ value: draft.argsText ?? draft.args.join(' '),
424
+ onChange: (event) => update({ argsText: event.target.value }),
425
+ }),
426
+ ),
427
+ h(
428
+ Field,
429
+ { label: '收尾宽限' },
430
+ h('input', {
431
+ style: { ...S.input, maxWidth: 140 },
432
+ type: 'number',
433
+ min: 500,
434
+ max: 120000,
435
+ step: 500,
436
+ value: draft.graceMs,
437
+ onChange: (event) => update({ graceMs: Number(event.target.value) }),
438
+ }),
439
+ h('span', { style: S.hint }, '毫秒'),
440
+ ),
441
+ h(
442
+ 'div',
443
+ { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
444
+ h('div', { style: S.label }, '额外环境变量(JSON 对象)'),
445
+ h('textarea', {
446
+ style: { ...S.input, ...S.mono, minHeight: 72, resize: 'vertical' },
447
+ value: envText,
448
+ onChange: (event) => setEnvText(event.target.value),
449
+ }),
450
+ ),
451
+ ),
452
+
453
+ h(
454
+ 'div',
455
+ { style: { display: 'flex', alignItems: 'center', gap: 12 } },
456
+ h(
457
+ 'button',
458
+ { style: S.primary, onClick: save, disabled: busy !== '' },
459
+ busy === 'save' ? '保存中…' : '保存设置',
460
+ ),
461
+ note !== '' ? h('span', { style: { ...S.hint, color: T.ok } }, note) : null,
462
+ ),
463
+
464
+ h(
465
+ Section,
466
+ {
467
+ title: `运行记录${runs.length === 0 ? '' : `(最近 ${runs.length} 次)`}`,
468
+ hint: '每次委派一行;“会话”是 opencode 里那条会话的 id,可以直接在 opencode 里找。',
469
+ extra: h(
470
+ 'button',
471
+ { style: S.button, onClick: clearRuns, disabled: busy !== '' || runs.length === 0 },
472
+ '清空',
473
+ ),
474
+ },
475
+ runs.length === 0
476
+ ? h('p', { style: S.hint }, '还没有记录。让 DSH 用 subagent_opencode 委派一次试试。')
477
+ : h(
478
+ 'table',
479
+ { style: S.table },
480
+ h(
481
+ 'thead',
482
+ null,
483
+ h(
484
+ 'tr',
485
+ null,
486
+ ['时间', '状态', '模型', '目录', '耗时', 'token', '会话'].map((label) =>
487
+ h('th', { key: label, style: S.th }, label),
488
+ ),
489
+ ),
490
+ ),
491
+ h(
492
+ 'tbody',
493
+ null,
494
+ runs.slice(0, 30).map((run) =>
495
+ h(
496
+ React.Fragment,
497
+ { key: run.id },
498
+ h(
499
+ 'tr',
500
+ {
501
+ style: { cursor: 'pointer' },
502
+ onClick: () => setOpenRun(openRun === run.id ? null : run.id),
503
+ },
504
+ h('td', { style: S.td }, stamp(run.at)),
505
+ h('td', { style: { ...S.td, color: statusColor(run.status) } }, statusText(run.status)),
506
+ h('td', { style: { ...S.td, ...S.mono } }, run.model || '—'),
507
+ h(
508
+ 'td',
509
+ { style: { ...S.td, ...S.mono }, title: run.cwd },
510
+ String(run.cwd ?? '').split(/[\\/]/).filter(Boolean).pop() || '—',
511
+ ),
512
+ h('td', { style: S.td }, duration(run.ms)),
513
+ h('td', { style: { ...S.td, ...S.mono } }, tokensText(run.tokens)),
514
+ h(
515
+ 'td',
516
+ { style: { ...S.td, ...S.mono, color: T.dim } },
517
+ run.sessionId === null || run.sessionId === undefined
518
+ ? '—'
519
+ : String(run.sessionId).slice(0, 18),
520
+ ),
521
+ ),
522
+ openRun === run.id
523
+ ? h(
524
+ 'tr',
525
+ null,
526
+ h(
527
+ 'td',
528
+ { style: { ...S.td, background: T.layer2 }, colSpan: 7 },
529
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
530
+ h('div', { style: S.mono }, `cwd: ${run.cwd}`),
531
+ h('div', { style: S.mono }, `session: ${run.sessionId ?? '—'}`),
532
+ h('div', { style: S.mono }, `工具调用: ${run.toolCalls ?? 0}`),
533
+ h('div', { style: S.mono }, `任务: ${run.promptPreview ?? ''}`),
534
+ run.diagnostic
535
+ ? h('div', { style: { ...S.mono, color: T.err } }, run.diagnostic)
536
+ : null,
537
+ ),
538
+ ),
539
+ )
540
+ : null,
541
+ ),
542
+ ),
543
+ ),
544
+ ),
545
+ ),
546
+ )
547
+ }
548
+
549
+ const inject = ['slots']
550
+
551
+ function apply(ctx) {
552
+ ctx.slots.inject('settings.section', () =>
553
+ ctx.slots.register(
554
+ {
555
+ name: 'settings.section',
556
+ id: 'opencode',
557
+ order: 42,
558
+ label: () => 'OpenCode 委派',
559
+ inject: () => ({}),
560
+ },
561
+ OpencodeSettings,
562
+ ),
563
+ )
564
+ }
565
+
566
+ return { name: MODULE_NAME, inject, apply }
567
+ },
568
+ })
@@ -0,0 +1,24 @@
1
+ # Installed by the dsh-opencode-acp bundle:
2
+ # 1. `dsh-opencode-acp` — the Host plugin: an ACP client that drives
3
+ # `opencode acp` in a subprocess, plus the settings page's JSON routes and
4
+ # run history;
5
+ # 2. `tool-subagent-opencode` — the model-facing delegation tool that calls it.
6
+ #
7
+ # Both rows live in the Host plane. The subagent registry and its backends are
8
+ # host singletons (see dsh-web-app/cordis.patch.yml), and a host-level tool row
9
+ # joins the tools registry's global layer, so every agent in this profile sees
10
+ # `subagent_opencode` without a per-preset declaration.
11
+ #
12
+ # The provider advertises no start-time capabilities, so the tool must not send
13
+ # a numeric depth cap: `provider-managed` is the only valid choice here.
14
+
15
+ - insert:
16
+ - id: opencode-acp
17
+ name: 'dsh-opencode-acp'
18
+
19
+ - id: tool-subagent-opencode
20
+ name: '@deepseek-ai/dsh-tool-subagent'
21
+ config:
22
+ provider: opencode
23
+ toolName: subagent_opencode
24
+ maxDepth: provider-managed
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="OpenCode ACP delegation">
2
+ <rect width="24" height="24" rx="6" fill="#0B1220" />
3
+ <circle cx="9" cy="9" r="3.1" fill="none" stroke="#7DD3FC" stroke-width="1.8" />
4
+ <circle cx="16.2" cy="15.4" r="2.4" fill="#A78BFA" />
5
+ <path d="M11.2 10.9 L14.2 13.6" stroke="#94A3B8" stroke-width="1.4" stroke-linecap="round" />
6
+ </svg>