@twinsearth/dsh-master 0.0.0-stage → 3.2.8

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 (95) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +89 -2
  3. package/client.js +263 -0
  4. package/cordis.patch.yml +71 -0
  5. package/dist-host/contract/client.js +82 -0
  6. package/dist-host/contract/constants.js +70 -0
  7. package/dist-host/contract/envelope.js +84 -0
  8. package/dist-host/contract/index.js +17 -0
  9. package/dist-host/contract/negotiate.js +87 -0
  10. package/dist-host/contract/server.js +71 -0
  11. package/dist-host/contract/transports.js +360 -0
  12. package/dist-host/index.js +260 -0
  13. package/dist-host/kernel.js +186 -0
  14. package/dist-host/plugins/cost-optimizer.js +147 -0
  15. package/dist-host/plugins/dual-manager.js +251 -0
  16. package/dist-host/plugins/superagent-router.js +499 -0
  17. package/dist-host/plugins/superagent-worker.js +73 -0
  18. package/dist-host/superagent/bridge-tokens.js +236 -0
  19. package/dist-host/superagent/dispatch.js +198 -0
  20. package/dist-host/superagent/feishu.js +115 -0
  21. package/dist-host/superagent/github.js +119 -0
  22. package/dist-host/superagent/pr-delivery.js +351 -0
  23. package/dist-host/superagent/router.js +219 -0
  24. package/dist-host/superagent/stream-adapter.js +238 -0
  25. package/dist-host/superagent/stream-tokens.js +141 -0
  26. package/dist-host/superagent/ticket-store.js +108 -0
  27. package/dist-host/superagent/trust.js +145 -0
  28. package/dist-host/superagent/types.js +144 -0
  29. package/dist-host/superagent/worker.js +95 -0
  30. package/dist-host/superagent/writeback.js +162 -0
  31. package/dist-worker/agent-loop.js +493 -0
  32. package/dist-worker/errors.js +41 -0
  33. package/dist-worker/github-pr.js +44 -0
  34. package/dist-worker/index.js +323 -0
  35. package/dist-worker/llm-http.js +351 -0
  36. package/dist-worker/main.js +45 -0
  37. package/dist-worker/prompt.js +35 -0
  38. package/dist-worker/sink-http.js +135 -0
  39. package/dist-worker/tools.js +542 -0
  40. package/dist-worker/types.js +14 -0
  41. package/docs/ARCHITECTURE.md +114 -0
  42. package/docs/CHANGELOG.md +377 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/REVIEW-v3.2.7.md +121 -0
  45. package/docs/ROADMAP.md +106 -0
  46. package/docs/SECURITY.md +153 -0
  47. package/docs/SMOKE-3.2.5.md +77 -0
  48. package/docs/SMOKE-3.2.7.md +73 -0
  49. package/docs/SUPERAGENT.md +141 -0
  50. package/docs/WORKER-AGENT-LOOP.md +139 -0
  51. package/icon.svg +9 -0
  52. package/locale/en.json +6 -0
  53. package/locale/zh.json +6 -0
  54. package/package.json +79 -4
  55. package/src/contract/client.js +86 -0
  56. package/src/contract/constants.js +79 -0
  57. package/src/contract/envelope.js +83 -0
  58. package/src/contract/index.js +19 -0
  59. package/src/contract/negotiate.js +106 -0
  60. package/src/contract/server.js +77 -0
  61. package/src/contract/transports.js +319 -0
  62. package/src/index.ts +265 -0
  63. package/src/kernel.ts +190 -0
  64. package/src/plugins/cost-optimizer.ts +155 -0
  65. package/src/plugins/dual-manager.ts +252 -0
  66. package/src/plugins/superagent-router.ts +465 -0
  67. package/src/plugins/superagent-worker.ts +85 -0
  68. package/src/superagent/bridge-tokens.ts +236 -0
  69. package/src/superagent/dispatch.ts +197 -0
  70. package/src/superagent/feishu.ts +104 -0
  71. package/src/superagent/github.ts +111 -0
  72. package/src/superagent/pr-delivery.ts +308 -0
  73. package/src/superagent/router.ts +201 -0
  74. package/src/superagent/stream-adapter.ts +184 -0
  75. package/src/superagent/stream-tokens.ts +142 -0
  76. package/src/superagent/ticket-store.ts +104 -0
  77. package/src/superagent/trust.ts +139 -0
  78. package/src/superagent/types.ts +135 -0
  79. package/src/superagent/worker.ts +100 -0
  80. package/src/superagent/writeback.ts +154 -0
  81. package/tsconfig.host.build.json +12 -0
  82. package/tsconfig.host.json +21 -0
  83. package/tsconfig.worker.build.json +13 -0
  84. package/tsconfig.worker.json +17 -0
  85. package/types/superagent.d.ts +99 -0
  86. package/worker/agent-loop.ts +524 -0
  87. package/worker/errors.ts +47 -0
  88. package/worker/github-pr.ts +65 -0
  89. package/worker/index.ts +334 -0
  90. package/worker/llm-http.ts +359 -0
  91. package/worker/main.ts +45 -0
  92. package/worker/prompt.ts +39 -0
  93. package/worker/sink-http.ts +159 -0
  94. package/worker/tools.ts +531 -0
  95. package/worker/types.ts +247 -0
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+
4
+ import { CAPABILITIES, CONTRACT_VERSION, ERROR_CODES, METHOD_VERSION, METHODS } from './constants.js'
5
+ import { createResponse, normalizeError, validateEnvelope } from './envelope.js'
6
+ import { helloPayload, parseVersion, unsupportedMethodError } from './negotiate.js'
7
+
8
+ /**
9
+ * 契约服务端(Fang 侧/任意一侧):
10
+ * * hello() 返回协商信息;
11
+ * * invoke(envelope) 校验信封、检查方法版本、调用 handler、返回标准响应。
12
+ *
13
+ * 兼容原则:新增方法只需加 handler;删除/改义必须同时保留 method@old 一个 MINOR 周期。
14
+ */
15
+ export function createContractServer({
16
+ role = 'mother',
17
+ instanceId = '',
18
+ version = CONTRACT_VERSION,
19
+ capabilities = CAPABILITIES,
20
+ transports = [],
21
+ handlers = {},
22
+ logger = null,
23
+ startedAt = Date.now(),
24
+ } = {}) {
25
+ const registry = new Map(Object.entries(handlers))
26
+ const hello = () => helloPayload({
27
+ role,
28
+ instanceId,
29
+ version,
30
+ methods: [...new Set([METHODS.HELLO, METHODS.HEALTH, ...registry.keys()])],
31
+ capabilities,
32
+ transports,
33
+ startedAt,
34
+ })
35
+ const logs = { invoked: 0, failed: 0, lastError: null }
36
+
37
+ const invoke = async (envelope) => {
38
+ const validated = validateEnvelope(envelope)
39
+ if (!validated.ok) {
40
+ return createResponse({ id: envelope?.id, error: { code: ERROR_CODES.INVALID_ENVELOPE, message: validated.errors.join(', ') } })
41
+ }
42
+ const method = String(envelope.method)
43
+ try {
44
+ if (method === METHODS.HELLO) return createResponse({ id: envelope.id, result: hello() })
45
+ if (method === METHODS.HEALTH) {
46
+ return createResponse({ id: envelope.id, result: { ok: true, role, instanceId, version, uptimeMs: Date.now() - startedAt, methods: [...registry.keys()] } })
47
+ }
48
+ const handler = registry.get(method)
49
+ if (!handler) return createResponse({ id: envelope.id, error: unsupportedMethodError(method) })
50
+ const methodVersion = parseVersion(METHOD_VERSION[method] ?? '1.0')
51
+ const localVersion = parseVersion(version)
52
+ if (methodVersion && localVersion && methodVersion.major !== localVersion.major) {
53
+ return createResponse({ id: envelope.id, error: { code: ERROR_CODES.UNSUPPORTED_VERSION, message: `method ${method} requires contract major ${methodVersion.major}` } })
54
+ }
55
+ logs.invoked += 1
56
+ const result = await handler(envelope.params ?? null, envelope)
57
+ return createResponse({ id: envelope.id, result: result ?? null, meta: { role, instanceId } })
58
+ } catch (error) {
59
+ logs.failed += 1
60
+ logs.lastError = normalizeError(error, ERROR_CODES.HANDLER_THREW)
61
+ if (!error?.code) logs.lastError.code = ERROR_CODES.HANDLER_THREW
62
+ try { logger?.warn?.(`[contract] ${method} failed: ${logs.lastError.code} ${logs.lastError.message}`) } catch { /* ignore */ }
63
+ return createResponse({ id: envelope?.id, error: logs.lastError })
64
+ }
65
+ }
66
+
67
+ return {
68
+ hello,
69
+ invoke,
70
+ describe() {
71
+ return {
72
+ protocol: 'fang-master', contractVersion: version, role, instanceId,
73
+ methods: [...registry.keys()], capabilities, transports, stats: { ...logs },
74
+ }
75
+ },
76
+ }
77
+ }
@@ -0,0 +1,319 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang/dsh-Master contributors — see LICENSE.
3
+
4
+ import fs from 'node:fs/promises'
5
+ import path from 'node:path'
6
+
7
+ import {
8
+ CONTRACT_VERSION,
9
+ DEFAULT_TRANSPORT_ORDER,
10
+ ERROR_CODES,
11
+ LEGACY_SERVICE_KEYS,
12
+ METHODS,
13
+ PROTOCOL,
14
+ } from './constants.js'
15
+ import { ContractTransportError, createEnvelope, createResponse, normalizeError, validateEnvelope } from './envelope.js'
16
+ import { helloPayload, negotiate } from './negotiate.js'
17
+
18
+ function safeGet(ctx, name) {
19
+ try {
20
+ if (!ctx || typeof ctx !== 'object') return null
21
+ if (typeof ctx.get === 'function') return ctx.get(name) ?? null
22
+ return ctx[name] ?? null
23
+ } catch { return null }
24
+ }
25
+
26
+ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(1, ms))) }
27
+
28
+ function authHeaders(token) {
29
+ return token ? { authorization: `Bearer ${token}` } : {}
30
+ }
31
+
32
+ function baseOf(url) { return String(url ?? '').trim().replace(/\/+$/, '') }
33
+
34
+ /** 统一传输接口:{name, kind, available(), hello(), invoke(method, params, opts)}。 */
35
+
36
+ export function createLocalTransport({ ctx, serviceName = 'fangContract' } = {}) {
37
+ const service = () => safeGet(ctx, serviceName)
38
+ return {
39
+ name: 'local', kind: 'local', available: () => Boolean(service() && typeof service().invoke === 'function'),
40
+ async hello() {
41
+ const s = service()
42
+ if (!s) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'local contract service is absent', { transport: 'local' })
43
+ return await s.hello()
44
+ },
45
+ async invoke(method, params, opts = {}) {
46
+ const s = service()
47
+ if (!s || typeof s.invoke !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'local contract service is absent', { transport: 'local' })
48
+ const response = await s.invoke(createEnvelope({ method, params, source: 'local', meta: opts.meta, timeoutMs: opts.timeoutMs }))
49
+ const check = validateEnvelope(response, { response: true })
50
+ if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'local' })
51
+ return response
52
+ },
53
+ }
54
+ }
55
+
56
+ export function createHttpTransport({ baseUrl = '', token = '', headers: extraHeaders = {}, fetchImpl = fetch, timeoutMs = 15_000, pathPrefix = '/fang/api/contract' } = {}) {
57
+ const base = baseOf(baseUrl)
58
+ const prefix = `/${String(pathPrefix).replace(/^\/+|\/+$/g, '')}`
59
+ const available = () => Boolean(base)
60
+ const call = async (endpoint, role, fn, callTimeoutMs = timeoutMs) => {
61
+ if (!available()) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'http contract baseUrl is missing', { transport: 'http' })
62
+ const effectiveTimeout = Math.max(1, Number(callTimeoutMs) || timeoutMs)
63
+ const controller = new AbortController()
64
+ const timer = setTimeout(() => controller.abort(), effectiveTimeout)
65
+ timer.unref?.()
66
+ try {
67
+ const response = await fetchImpl(`${base}${prefix}${endpoint}`, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...authHeaders(token), ...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}) },
70
+ body: JSON.stringify(role),
71
+ signal: controller.signal,
72
+ })
73
+ const text = await response.text()
74
+ if (!response.ok) {
75
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_HTTP_ERROR, `HTTP ${response.status}`, { transport: 'http', detail: text.slice(0, 500) })
76
+ }
77
+ let parsed = null
78
+ try { parsed = JSON.parse(text) } catch { throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, 'HTTP response is not JSON', { transport: 'http' }) }
79
+ return fn(parsed)
80
+ } catch (error) {
81
+ if (error instanceof ContractTransportError) throw error
82
+ if (error?.name === 'AbortError') throw new ContractTransportError(ERROR_CODES.TRANSPORT_TIMEOUT, `HTTP contract timeout (>${timeoutMs}ms)`, { transport: 'http', retryable: true })
83
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_HTTP_ERROR, String(error?.message ?? error), { transport: 'http', retryable: true })
84
+ } finally { clearTimeout(timer) }
85
+ }
86
+ return {
87
+ name: 'http', kind: 'http', available,
88
+ async hello() {
89
+ return await call('/hello', { method: METHODS.HELLO }, (parsed) => {
90
+ // 允许 /hello 直接返回 payload,或返回标准 response envelope。
91
+ if (parsed && parsed.protocol) return parsed
92
+ if (parsed && parsed.ok && parsed.result) return parsed.result
93
+ throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, 'invalid hello payload', { transport: 'http' })
94
+ })
95
+ },
96
+ async invoke(method, params, opts = {}) {
97
+ const envelope = createEnvelope({ method, params, source: 'http', meta: opts.meta, timeoutMs: opts.timeoutMs })
98
+ return await call('/invoke', envelope, (parsed) => {
99
+ const check = validateEnvelope(parsed, { response: true })
100
+ if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'http' })
101
+ return parsed
102
+ }, opts.timeoutMs)
103
+ },
104
+ }
105
+ }
106
+
107
+ function atomicWrite(file, text) {
108
+ const tmp = `${file}.${process.pid}.tmp`
109
+ return fs.writeFile(tmp, text, 'utf8').then(() => fs.rename(tmp, file))
110
+ }
111
+
112
+ /** Windows 对端仍持有句柄时 unlink 可能短暂 EPERM;成功响应后做有界重试清理。 */
113
+ async function removeFileWithRetry(file, attempts = 8) {
114
+ for (let index = 0; index < attempts; index += 1) {
115
+ try { await fs.unlink(file); return true } catch (error) {
116
+ if (error?.code === 'ENOENT') return true
117
+ if (index + 1 < attempts) await sleep(25 * (index + 1))
118
+ }
119
+ }
120
+ return false
121
+ }
122
+
123
+ /**
124
+ * 文件 outbox 兜底传输:同一文件系统、无网络/无 HTTP 时使用。
125
+ * 请求写 requests/<id>.json,对端(startFileContractServer)写 responses/<id>.json。
126
+ */
127
+ export function createFileTransport({ dir = '', fetchImpl = null, pollMs = 100, timeoutMs = 15_000 } = {}) {
128
+ const root = String(dir ?? '').trim()
129
+ const directories = {
130
+ request: path.join(root, 'requests'),
131
+ response: path.join(root, 'responses'),
132
+ }
133
+ const ensure = async () => {
134
+ await fs.mkdir(directories.request, { recursive: true })
135
+ await fs.mkdir(directories.response, { recursive: true })
136
+ }
137
+ return {
138
+ name: 'file', kind: 'file',
139
+ available: () => Boolean(root),
140
+ async hello() {
141
+ if (!root) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'file transport dir is missing', { transport: 'file' })
142
+ return await this.invoke(METHODS.HELLO, null).then((response) => (response.ok ? response.result : Promise.reject(new ContractTransportError(ERROR_CODES.REMOTE_ERROR, response.error?.message ?? 'hello failed', { transport: 'file' }))))
143
+ },
144
+ async invoke(method, params, opts = {}) {
145
+ if (!root) throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'file transport dir is missing', { transport: 'file' })
146
+ await ensure()
147
+ const envelope = createEnvelope({ method, params, source: 'file', meta: opts.meta, timeoutMs: opts.timeoutMs })
148
+ const requestFile = path.join(directories.request, `${envelope.id}.json`)
149
+ const responseFile = path.join(directories.response, `${envelope.id}.json`)
150
+ await atomicWrite(requestFile, JSON.stringify(envelope))
151
+ const effectiveTimeout = Math.max(1, Number(opts.timeoutMs) || timeoutMs)
152
+ const deadline = Date.now() + effectiveTimeout
153
+ while (Date.now() < deadline) {
154
+ try {
155
+ const text = await fs.readFile(responseFile, 'utf8')
156
+ await removeFileWithRetry(responseFile)
157
+ const parsed = JSON.parse(text)
158
+ const check = validateEnvelope(parsed, { response: true })
159
+ if (!check.ok) throw new ContractTransportError(ERROR_CODES.INVALID_ENVELOPE, check.errors.join(','), { transport: 'file' })
160
+ // 请求文件由调用方负责回收:服务端实现可能只写响应而不删请求,
161
+ // 成功匹配响应后必须幂等清理,避免 contract-outbox 长期积累。
162
+ await removeFileWithRetry(requestFile)
163
+ return parsed
164
+ } catch (error) {
165
+ if (error instanceof ContractTransportError) throw error
166
+ }
167
+ await sleep(pollMs)
168
+ }
169
+ await removeFileWithRetry(requestFile)
170
+ await removeFileWithRetry(responseFile)
171
+ throw new ContractTransportError(ERROR_CODES.TRANSPORT_TIMEOUT, `file contract timeout (>${effectiveTimeout}ms)`, { transport: 'file', retryable: true })
172
+ },
173
+ }
174
+ }
175
+
176
+ /**
177
+ * 文件 fallback 的服务端轮询器:读取 requests/,调用 contractServer.invoke,写 responses/。
178
+ * @returns {{stop:()=>void, describe:()=>(object)}}
179
+ */
180
+ export function startFileContractServer({ dir, server, pollMs = 100, maxAgeMs = 60_000, logger = null } = {}) {
181
+ const root = String(dir ?? '').trim()
182
+ if (!root) throw new Error('startFileContractServer requires dir')
183
+ const requestDir = path.join(root, 'requests')
184
+ const responseDir = path.join(root, 'responses')
185
+ let stopped = false
186
+ let timer = null
187
+ const tick = async () => {
188
+ if (stopped) return
189
+ try {
190
+ await fs.mkdir(requestDir, { recursive: true })
191
+ await fs.mkdir(responseDir, { recursive: true })
192
+ const entries = await fs.readdir(requestDir).catch(() => [])
193
+ for (const name of entries) {
194
+ if (!name.endsWith('.json')) continue
195
+ const file = path.join(requestDir, name)
196
+ try {
197
+ const stat = await fs.stat(file)
198
+ if (Date.now() - stat.mtimeMs > maxAgeMs) { await fs.unlink(file).catch(() => {}); continue }
199
+ const envelope = JSON.parse(await fs.readFile(file, 'utf8'))
200
+ const response = await server.invoke(envelope)
201
+ await atomicWrite(path.join(responseDir, `${envelope.id}.json`), JSON.stringify(response))
202
+ await fs.unlink(file).catch(() => {})
203
+ } catch (error) {
204
+ try { logger?.warn?.(`[contract:file] ${name}: ${String(error?.message ?? error)}`) } catch { /* ignore */ }
205
+ await fs.unlink(file).catch(() => {})
206
+ }
207
+ }
208
+ } catch { /* poll must not die */ }
209
+ if (!stopped) timer = setTimeout(tick, pollMs)
210
+ }
211
+ timer = setTimeout(tick, pollMs)
212
+ return {
213
+ stop() { stopped = true; if (timer) clearTimeout(timer); timer = null },
214
+ describe() { return { transport: 'file', dir: root, pollMs, stopped } },
215
+ }
216
+ }
217
+
218
+ /** 旧版 3.1.x 服务键回退:接口包被替换/新端点不存在时仍能工作。 */
219
+ export function createLegacyTransport({ ctx } = {}) {
220
+ const forker = () => safeGet(ctx, LEGACY_SERVICE_KEYS.forker)
221
+ const decision = () => safeGet(ctx, LEGACY_SERVICE_KEYS.decisionCenter)
222
+ const sessions = () => safeGet(ctx, LEGACY_SERVICE_KEYS.sessionCatalog)
223
+ const orchestrator = () => safeGet(ctx, LEGACY_SERVICE_KEYS.orchestrator)
224
+ const available = () => Boolean(forker() || decision() || sessions() || orchestrator())
225
+ return {
226
+ name: 'legacy', kind: 'legacy', available,
227
+ async hello() {
228
+ return {
229
+ protocol: PROTOCOL, contractVersion: '0.9.0', role: 'mother', instanceId: 'legacy',
230
+ methods: [METHODS.HEALTH, METHODS.FORK_SPAWN, METHODS.DECISION_CREATE, METHODS.SESSION_LIST],
231
+ capabilities: ['sync'], transports: ['legacy'],
232
+ }
233
+ },
234
+ async invoke(method, params) {
235
+ if (method === METHODS.HEALTH) return createResponse({ result: { ok: true, legacy: true } })
236
+ if (method === METHODS.FORK_SPAWN) {
237
+ const service = forker()
238
+ if (!service || typeof service.spawn !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangForker is absent', { transport: 'legacy' })
239
+ return createResponse({ result: await service.spawn(params ?? {}) })
240
+ }
241
+ if (method === METHODS.DECISION_CREATE) {
242
+ const service = decision()
243
+ if (!service || typeof service.create !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangDecisionCenter is absent', { transport: 'legacy' })
244
+ return createResponse({ result: await service.create(params ?? {}) })
245
+ }
246
+ if (method === METHODS.SESSION_LIST) {
247
+ const service = sessions()
248
+ if (!service || typeof service.list !== 'function') throw new ContractTransportError(ERROR_CODES.TRANSPORT_UNAVAILABLE, 'legacy fangSessionCatalog is absent', { transport: 'legacy' })
249
+ return createResponse({ result: await service.list(params ?? {}) })
250
+ }
251
+ throw new ContractTransportError(ERROR_CODES.UNSUPPORTED_METHOD, `legacy transport cannot serve ${method}`, { transport: 'legacy' })
252
+ },
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 传输路由器:按 local → http → file → legacy 顺序尝试;
258
+ * 传输不可用/超时/协议不兼容/未知方法时自动降级;业务错误不降级(避免重复副作用)。
259
+ */
260
+ export function createTransportRouter({ transports = [], order = DEFAULT_TRANSPORT_ORDER, localHello = null, logger = null } = {}) {
261
+ const byName = new Map(transports.map((transport) => [transport.name, transport]))
262
+ const ordered = order.map((name) => byName.get(name)).filter((transport) => transport && (!transport.available || transport.available()))
263
+ const attempts = []
264
+ const record = (entry) => { attempts.push({ at: Date.now(), ...entry }); if (attempts.length > 100) attempts.shift() }
265
+ return {
266
+ transports: () => ordered.map((transport) => ({ name: transport.name, kind: transport.kind })),
267
+ attempts: () => [...attempts],
268
+ describe() {
269
+ return {
270
+ order,
271
+ available: ordered.map((transport) => transport.name),
272
+ configured: transports.map((transport) => ({ name: transport.name, kind: transport.kind, available: transport.available ? transport.available() : true })),
273
+ }
274
+ },
275
+ async hello() {
276
+ const local = localHello ?? helloPayload({ role: 'client', instanceId: 'contract-client' })
277
+ for (const transport of ordered) {
278
+ try {
279
+ const remote = await transport.hello()
280
+ const negotiation = negotiate(local, remote)
281
+ record({ transport: transport.name, action: 'hello', ok: true, compatible: negotiation.compatible, reason: negotiation.reason ?? null })
282
+ if (negotiation.compatible) return { ok: true, transport: transport.name, hello: remote, negotiation, fallback: null }
283
+ // remote-too-old/新:若还有后续传输(如 legacy),继续降级。
284
+ if (transport.name !== 'legacy') continue
285
+ return { ok: true, transport: transport.name, hello: remote, negotiation, fallback: 'legacy' }
286
+ } catch (error) {
287
+ record({ transport: transport.name, action: 'hello', ok: false, code: error?.code ?? null })
288
+ try { logger?.warn?.(`[contract] hello via ${transport.name} failed: ${error?.code ?? error?.message ?? error}`) } catch { /* ignore */ }
289
+ }
290
+ }
291
+ return { ok: false, code: ERROR_CODES.ALL_TRANSPORTS_FAILED, error: 'no transport could complete hello', attempts: this.attempts() }
292
+ },
293
+ async invoke(method, params, { idempotencyKey = '', timeoutMs = 30_000 } = {}) {
294
+ const envelopeMeta = idempotencyKey ? { idempotencyKey } : {}
295
+ const failures = []
296
+ for (const transport of ordered) {
297
+ try {
298
+ const response = await transport.invoke(method, params, { timeoutMs, meta: envelopeMeta })
299
+ // 有的传输(local)返回 response envelope;统一读取。
300
+ const ok = response?.ok === true
301
+ record({ transport: transport.name, action: method, ok, code: response?.error?.code ?? null })
302
+ if (ok) return { ok: true, transport: transport.name, response }
303
+ const code = String(response?.error?.code ?? '')
304
+ const fallbackable = code === ERROR_CODES.UNSUPPORTED_METHOD || code === ERROR_CODES.UNSUPPORTED_VERSION || response?.error?.retryable === true
305
+ failures.push({ transport: transport.name, code, message: response?.error?.message ?? '' })
306
+ if (!fallbackable) return { ok: false, transport: transport.name, response, error: response?.error ?? normalizeError(code), attempts: this.attempts() }
307
+ } catch (error) {
308
+ const code = error?.code ?? ERROR_CODES.TRANSPORT_UNAVAILABLE
309
+ failures.push({ transport: transport.name, code, message: String(error?.message ?? error) })
310
+ record({ transport: transport.name, action: method, ok: false, code })
311
+ try { logger?.warn?.(`[contract] ${method} via ${transport.name} failed: ${code} ${error?.message ?? ''}`) } catch { /* ignore */ }
312
+ }
313
+ }
314
+ return { ok: false, code: ERROR_CODES.ALL_TRANSPORTS_FAILED, error: 'all contract transports failed', failures, attempts: this.attempts() }
315
+ },
316
+ }
317
+ }
318
+
319
+ export { CONTRACT_VERSION, LEGACY_SERVICE_KEYS, METHODS }
package/src/index.ts ADDED
@@ -0,0 +1,265 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ // @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
4
+
5
+ /**
6
+ * Dsh-Master(房主)核心插件 / 主 Agent 控制面。
7
+ *
8
+ * 定位:与瓮城 dsh-Fang 并行的主插件。房主不接管瓮城的镜像/原身/分身生命周期,
9
+ * 只在自己的命名空间里托管三个 Agent 子插件,并通过 DSH 服务键与瓮城协作:
10
+ *
11
+ * ┌──────────────────────────── DSH Host ────────────────────────────┐
12
+ * │ 瓮城 bundle (dsh-Fang) 房主 bundle (dsh-Master) │
13
+ * │ mirror / essence / derived core ─┬─ superagent (fork) │
14
+ * │ fangForker / fangDecisionCenter ├─ cost (fork) │
15
+ * │ └─ dual (mother/essence)
16
+ * └──────────────────────────────────────────────────────────────────┘
17
+ *
18
+ * 协作只走服务键(见 kernel.mjs FANG_SERVICE):房主不 import 瓮城代码;瓮城服务
19
+ * 不可用时子插件必须 fail-closed,绝不本地降级执行。
20
+ */
21
+
22
+ import {
23
+ FANG_CONTRACT_MIN,
24
+ FANG_SERVICE,
25
+ MASTER_VERSION,
26
+ ROLE_MATRIX,
27
+ ROW,
28
+ SERVICE,
29
+ SUBPLUGIN_CATALOG,
30
+ bearerOf,
31
+ detectRole,
32
+ enabledOf,
33
+ jsonResponse,
34
+ masterError,
35
+ readJsonBody,
36
+ roleAllowed,
37
+ safeGet,
38
+ tokenEquals,
39
+ } from './kernel.ts'
40
+ import { BRIDGE_PATH, filterEnv } from './superagent/dispatch.ts'
41
+ import { createBridgeTokenManager } from './superagent/bridge-tokens.ts'
42
+
43
+ export const name = 'dsh-master'
44
+ export const inject = []
45
+
46
+ /** 状态路由(Client UI 读取;只读、非敏感、loopback-only 由宿主 webServer 保证)。 */
47
+ export const STATUS_ROUTE = '/dsh-master/api/status'
48
+
49
+ /**
50
+ * 房主核心 apply。
51
+ * @param {object} ctx Cordis context
52
+ * @param {object} [config] loader 行配置
53
+ */
54
+ export function apply(ctx, config = {}) {
55
+ if (!enabledOf(config)) return undefined
56
+ const role = detectRole()
57
+ const startedAt = Date.now()
58
+ // 桥 token:自动轮换 + 宽限重叠 + 摘要持久化 + 审计(v3.2.5)。
59
+ const bridgeTokens = createBridgeTokenManager({
60
+ staticToken: String(config.bridgeToken ?? '').trim(),
61
+ rotateMs: config.bridgeTokenRotateMs,
62
+ graceMs: config.bridgeTokenGraceMs,
63
+ dataDir: String(config.dataDir ?? '').trim(),
64
+ auditLimit: config.tokenAuditLimit,
65
+ })
66
+ const logInfo = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* ignore */ } }
67
+ const logWarn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* ignore */ } }
68
+ const subplugins = new Map()
69
+
70
+ const cooperation = () => {
71
+ const services = {}
72
+ for (const [alias, serviceName] of Object.entries(FANG_SERVICE)) {
73
+ const found = safeGet(ctx, serviceName)
74
+ services[alias] = {
75
+ service: serviceName,
76
+ available: Boolean(found),
77
+ spawn: typeof found?.spawn === 'function',
78
+ decide: typeof found?.decide === 'function' || typeof found?.create === 'function',
79
+ }
80
+ }
81
+ return {
82
+ contract: `dsh-fang >= ${FANG_CONTRACT_MIN}`,
83
+ mode: 'service-key-only',
84
+ services,
85
+ available: Object.values(services).some((item) => item.available),
86
+ canSpawn: services.forker.spawn === true,
87
+ }
88
+ }
89
+
90
+ const catalog = () => SUBPLUGIN_CATALOG.map((entry) => {
91
+ const active = subplugins.get(entry.id) ?? null
92
+ const allowed = roleAllowed(entry.row, role)
93
+ return {
94
+ id: entry.id,
95
+ row: entry.row,
96
+ name: entry.name,
97
+ nameEn: entry.nameEn,
98
+ roadmap: entry.roadmap,
99
+ description: entry.description,
100
+ allowedRoles: entry.roles,
101
+ roleAllowed: allowed,
102
+ active: Boolean(active) && allowed,
103
+ registered: Boolean(active),
104
+ capabilities: active?.capabilities ?? [],
105
+ blockedReason: active || allowed ? null : `role-not-allowed:${role}`,
106
+ }
107
+ })
108
+
109
+ const service = {
110
+ id: SERVICE.master,
111
+ name: 'dsh-master',
112
+ version: MASTER_VERSION,
113
+ role,
114
+ startedAt,
115
+ /** 子插件启动时注册;重复 id 直接抛(fail loud)。 */
116
+ registerSubplugin(descriptor) {
117
+ const id = String(descriptor?.id ?? '').trim()
118
+ if (!id) throw new Error('dsh-master: subplugin id is required')
119
+ if (subplugins.has(id)) throw new Error(`dsh-master: subplugin already registered: ${id}`)
120
+ const record = Object.freeze({
121
+ id,
122
+ name: String(descriptor.name ?? id),
123
+ capabilities: Object.freeze([...(descriptor.capabilities ?? [])].map(String)),
124
+ description: String(descriptor.description ?? ''),
125
+ registeredAt: Date.now(),
126
+ })
127
+ subplugins.set(id, record)
128
+ logInfo(`[dsh-master] subplugin registered: ${id} (${record.capabilities.join(',')})`)
129
+ return () => { subplugins.delete(id) }
130
+ },
131
+ subplugins() {
132
+ return catalog()
133
+ },
134
+ cooperation,
135
+ /** 桥 token 运维:只读描述 / 审计 / 手动轮换(无明文 token)。 */
136
+ bridgeTokens: () => bridgeTokens.describe(),
137
+ bridgeTokenAudit: (limit) => bridgeTokens.audit(limit),
138
+ rotateBridgeToken: () => {
139
+ const rotated = bridgeTokens.rotate({ reason: 'manual' })
140
+ return { ok: true, token: rotated.token, tokenId: rotated.tokenId, graceMs: rotated.graceMs }
141
+ },
142
+ /** 统一状态:Client 状态页与工具/测试共用。 */
143
+ status() {
144
+ const dispatchCfg = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {}
145
+ return {
146
+ ok: true,
147
+ id: 'dsh-master',
148
+ version: MASTER_VERSION,
149
+ role,
150
+ startedAt,
151
+ uptimeMs: Date.now() - startedAt,
152
+ cooperation: cooperation(),
153
+ subplugins: catalog(),
154
+ roleMatrix: ROLE_MATRIX,
155
+ bridge: {
156
+ enabled: Boolean(String(config.bridgeToken ?? '').trim()) || bridgeTokens.describe().tokens.length > 0,
157
+ dispatchConfigured: Boolean(String(dispatchCfg.homeDir ?? '').trim() && String(dispatchCfg.cliPath ?? '').trim()),
158
+ tokens: bridgeTokens.describe(),
159
+ tokenAudit: bridgeTokens.audit(10),
160
+ },
161
+ }
162
+ },
163
+ /** 房主自检:缺瓮城服务时给出可读原因,不抛。 */
164
+ health() {
165
+ const coop = cooperation()
166
+ const warnings = []
167
+ if (!coop.available) warnings.push('fang-unavailable: 瓮城 bundle 未加载或版本低于契约')
168
+ else if (!coop.canSpawn) warnings.push('fang-forker-spawn-unavailable')
169
+ return {
170
+ ok: warnings.length === 0,
171
+ version: MASTER_VERSION,
172
+ role,
173
+ warnings,
174
+ cooperation: coop,
175
+ }
176
+ },
177
+ }
178
+
179
+ try { ctx.effect(() => ctx.provide(SERVICE.master, service)) } catch { /* fake ctx */ }
180
+ try { ctx.effect(() => () => bridgeTokens.stop()) } catch { /* fake ctx */ }
181
+ try {
182
+ if (typeof ctx.provide === 'function' && typeof ctx.effect !== 'function') ctx.provide(SERVICE.master, service)
183
+ } catch { /* ignore */ }
184
+
185
+ // Client 状态页数据源:只读、limit 到 non-sensitive 字段。
186
+ try {
187
+ if (typeof ctx.inject === 'function') {
188
+ ctx.inject(['webServer'], (child) => {
189
+ const webServer = child?.get?.('webServer') ?? child?.webServer
190
+ if (!webServer || typeof webServer.register !== 'function') return
191
+ try {
192
+ webServer.register({
193
+ kind: 'exact',
194
+ path: STATUS_ROUTE,
195
+ method: 'GET',
196
+ handler: async (req, res) => jsonResponse(res, 200, service.status()),
197
+ })
198
+ } catch (error) {
199
+ logWarn(`[dsh-master] status route registration failed: ${String(error?.message ?? error)}`)
200
+ }
201
+ // v3.2.3:原身 Router → 母体 forker 的跨进程派发桥。
202
+ try {
203
+ webServer.register({
204
+ kind: 'exact',
205
+ path: BRIDGE_PATH,
206
+ method: 'POST',
207
+ handler: async (req, res) => {
208
+ const bridge = bridgeTokens.describe()
209
+ if (!bridge.enabled) return jsonResponse(res, 503, masterError('bridge-disabled', 'bridgeToken is not configured in this host'))
210
+ const verified = bridgeTokens.verify(bearerOf(req?.headers))
211
+ if (!verified.ok) {
212
+ return jsonResponse(res, 401, masterError('bridge-unauthorized', 'invalid bridge token'), { reason: verified.reason })
213
+ }
214
+ const parsed = await readJsonBody(req)
215
+ if (!parsed.ok) {
216
+ return jsonResponse(res, parsed.code === 'body-too-large' ? 413 : 400, masterError(parsed.code, 'invalid bridge request body'))
217
+ }
218
+ const ticket = parsed.body?.ticket
219
+ if (!ticket || typeof ticket !== 'object' || typeof ticket.id !== 'string' || !ticket.id) {
220
+ return jsonResponse(res, 400, masterError('ticket-invalid', 'bridge request must include a ticket with id'))
221
+ }
222
+ const forker = safeGet(ctx, FANG_SERVICE.forker)
223
+ if (!forker || typeof forker.spawn !== 'function') {
224
+ return jsonResponse(res, 503, masterError('fang-forker-unavailable', 'fangForker.spawn is not available in this host'))
225
+ }
226
+ const dispatchCfg = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {}
227
+ const homeDir = String(dispatchCfg.homeDir ?? '').trim()
228
+ const cliPath = String(dispatchCfg.cliPath ?? '').trim()
229
+ const profile = String(dispatchCfg.profile ?? '').trim() || 'default'
230
+ if (!homeDir || !cliPath) {
231
+ return jsonResponse(res, 503, masterError('dispatch-config-incomplete', 'bridge host requires dispatch.homeDir and dispatch.cliPath'))
232
+ }
233
+ const env = { ...filterEnv(parsed.body?.env), DSH_MASTER_TICKET: JSON.stringify(ticket) }
234
+ try {
235
+ const fork = await forker.spawn({ homeDir, cliPath, profile, taskTitle: `SuperAgent ${ticket.id}`, env })
236
+ if (fork && fork.ok === false) {
237
+ return jsonResponse(res, 502, { ...masterError('fang-spawn-failed', 'forker.spawn returned an error', fork), fork })
238
+ }
239
+ return jsonResponse(res, 200, { ok: true, fork })
240
+ } catch (error) {
241
+ return jsonResponse(res, 500, masterError('fang-spawn-threw', String(error?.message ?? error)))
242
+ }
243
+ },
244
+ })
245
+ } catch (error) {
246
+ logWarn(`[dsh-master] bridge route registration failed: ${String(error?.message ?? error)}`)
247
+ }
248
+ })
249
+ }
250
+ } catch { /* 宿主没有 webServer 时状态仍可通过服务读取 */ }
251
+
252
+ logInfo(`[dsh-master] core ready version=${MASTER_VERSION} role=${role}`)
253
+ return service
254
+ }
255
+
256
+ /** 给子插件复用:取核心服务;不可用时返回 error(启动阶段应 fail loud)。 */
257
+ export function requireMaster(ctx) {
258
+ const master = safeGet(ctx, SERVICE.master)
259
+ if (!master || typeof master.registerSubplugin !== 'function') {
260
+ return { ok: false, error: masterError('master-core-unavailable', 'dsh-master core service is not available') }
261
+ }
262
+ return { ok: true, master }
263
+ }
264
+
265
+ export { ROLE_MATRIX, ROW, SERVICE, SUBPLUGIN_CATALOG }