@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4
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/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang v2.6.4 — ForkClient:母体侧命令外观(传输 + 幂等 + inflight + 超时兜底)。
|
|
4
|
+
//
|
|
5
|
+
// 分层(契约:docs/FORK-CLIENT.md):
|
|
6
|
+
// * 传输层:`createAdapterClient()`(/commands JSON + SSE 事件流、光标续传、重连);
|
|
7
|
+
// 本类只依赖它的 commands/connect/stop/onEvent/onReconnect/onDisconnect 与 lastState/lastCursor;
|
|
8
|
+
// * 幂等层:`CommandCache.run()`(in-flight 合并 + completed 重放 + 失败契约);
|
|
9
|
+
// * 命令层(本类):commandId 生成、同步/异步分派、trackingId → inflight 匹配、
|
|
10
|
+
// 异步命令等待 `command-done` 的超时兜底、SSE 生命周期对 inflight 的处置。
|
|
11
|
+
//
|
|
12
|
+
// 三条处置规则:
|
|
13
|
+
// 1. SSE 断开(网络抖动)→ inflight 保留:cursor 续传会补发 command-done;
|
|
14
|
+
// 2. 重连 resetCursor=true(分身重启/session 变了)→ 所有 inflight reject;
|
|
15
|
+
// 3. disconnect()(用户主动)→ 所有 inflight reject。
|
|
16
|
+
//
|
|
17
|
+
// 幂等/缓存语义:异步命令的缓存结果必须等到 command-done 终态才写入(选项 B)——
|
|
18
|
+
// 缓存 202 accepted 会把「已接受」误报成「已完成」,并让重试跳过真正的等待。
|
|
19
|
+
import { ADAPTER_OP } from '../kernel/types.mjs'
|
|
20
|
+
import { createAdapterClient } from './adapter-client.mjs'
|
|
21
|
+
import { CommandCache } from './command-cache.mjs'
|
|
22
|
+
|
|
23
|
+
export const FORK_CLIENT_DEFAULTS = Object.freeze({
|
|
24
|
+
synchronousTimeoutMs: 30_000,
|
|
25
|
+
asynchronousTimeoutMs: 120_000,
|
|
26
|
+
cacheCapacity: 500,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const msgOf = (e) => String(e && e.message !== undefined ? e.message : e)
|
|
30
|
+
|
|
31
|
+
function makeError(message, extra = null) {
|
|
32
|
+
const error = new Error(message)
|
|
33
|
+
if (extra && typeof extra === 'object') Object.assign(error, extra)
|
|
34
|
+
return error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function positiveMs(value, fallback) {
|
|
38
|
+
const n = Number(value)
|
|
39
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requireTransportMethods(transport) {
|
|
43
|
+
const required = ['connect', 'stop', 'commands', 'onEvent', 'onReconnect', 'onDisconnect']
|
|
44
|
+
for (const method of required) {
|
|
45
|
+
if (typeof transport?.[method] !== 'function') {
|
|
46
|
+
throw new TypeError(`fork client transport is missing ${method}()`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return transport
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 母体侧命令外观。
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {string} [options.url] 分身 web URL(可带 `?token=`)
|
|
57
|
+
* @param {string} [options.token] DSH web token
|
|
58
|
+
* @param {string} [options.adapterToken] v3 Adapter Bearer token
|
|
59
|
+
* @param {Function} [options.fetchImpl] 透传给传输层(测试用)
|
|
60
|
+
* @param {object} [options.transport] 直接注入传输层(测试用)
|
|
61
|
+
* @param {(opts:object)=>object} [options.transportFactory] 自定义传输层工厂
|
|
62
|
+
* @param {CommandCache} [options.commandCache] 直接注入缓存(测试用)
|
|
63
|
+
* @param {number} [options.cacheCapacity=500]
|
|
64
|
+
* @param {number} [options.synchronousTimeoutMs=30000] 同步 POST 的传输超时
|
|
65
|
+
* @param {number} [options.asynchronousTimeoutMs=120000] 异步命令等待 command-done 的超时
|
|
66
|
+
* @param {{debug?:Function, warn?:Function}} [options.logger]
|
|
67
|
+
*/
|
|
68
|
+
export class ForkClient {
|
|
69
|
+
constructor(options = {}) {
|
|
70
|
+
const opts = options && typeof options === 'object' ? options : {}
|
|
71
|
+
this.opts = { ...opts }
|
|
72
|
+
this.syncTimeoutMs = positiveMs(opts.synchronousTimeoutMs, FORK_CLIENT_DEFAULTS.synchronousTimeoutMs)
|
|
73
|
+
this.asyncTimeoutMs = positiveMs(opts.asynchronousTimeoutMs, FORK_CLIENT_DEFAULTS.asynchronousTimeoutMs)
|
|
74
|
+
this.logger = opts.logger && typeof opts.logger === 'object' ? opts.logger : {}
|
|
75
|
+
|
|
76
|
+
const factory = typeof opts.transportFactory === 'function'
|
|
77
|
+
? opts.transportFactory
|
|
78
|
+
: (transportOptions) => createAdapterClient(transportOptions)
|
|
79
|
+
this.transport = requireTransportMethods(
|
|
80
|
+
opts.transport ?? factory({
|
|
81
|
+
url: opts.url,
|
|
82
|
+
token: opts.token,
|
|
83
|
+
adapterToken: opts.adapterToken,
|
|
84
|
+
fetchImpl: opts.fetchImpl,
|
|
85
|
+
prefix: opts.prefix ?? '/fang',
|
|
86
|
+
timeoutMs: this.syncTimeoutMs,
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
this.cache = opts.commandCache instanceof CommandCache
|
|
91
|
+
? opts.commandCache
|
|
92
|
+
: new CommandCache({
|
|
93
|
+
capacity: positiveMs(opts.cacheCapacity, FORK_CLIENT_DEFAULTS.cacheCapacity),
|
|
94
|
+
onEvict: (commandId, entry) => {
|
|
95
|
+
try {
|
|
96
|
+
this.logger.debug?.(`[fork-client] cache evict ${commandId}`, { result: entry?.result })
|
|
97
|
+
} catch {
|
|
98
|
+
/* logger 异常隔离 */
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** @type {Map<string, {trackingId:string, commandId:string, op:string, resolve:Function, reject:Function, timer:any, startedAt:number}>} */
|
|
104
|
+
this.inflight = new Map()
|
|
105
|
+
this.commandCounter = 0
|
|
106
|
+
this.stopped = false
|
|
107
|
+
this.connected = false
|
|
108
|
+
this.attached = false
|
|
109
|
+
this.lastSnapshot = null
|
|
110
|
+
this.transportUnsubs = []
|
|
111
|
+
this.listeners = { event: new Set(), disconnect: new Set(), reconnect: new Set() }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 生命周期 ──────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** 建立连接(版本协商 → 快照 → SSE)。失败抛出带 code/fatal 的错误。 */
|
|
117
|
+
async connect() {
|
|
118
|
+
if (this.stopped) throw makeError('fork client already disconnected', { code: 'disconnected' })
|
|
119
|
+
this.attach()
|
|
120
|
+
const result = await this.transport.connect()
|
|
121
|
+
if (result && result.ok === false) {
|
|
122
|
+
const error = makeError(String(result.code ?? result.error ?? 'connect-failed'), {
|
|
123
|
+
code: result.code ?? 'connect-failed',
|
|
124
|
+
fatal: !!result.fatal,
|
|
125
|
+
retryable: !!result.retryable,
|
|
126
|
+
})
|
|
127
|
+
throw error
|
|
128
|
+
}
|
|
129
|
+
this.connected = true
|
|
130
|
+
this.lastSnapshot = this.transport.lastState ?? this.lastSnapshot
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 主动断开:取消订阅、reject 所有 inflight、停止传输层。幂等。
|
|
136
|
+
*/
|
|
137
|
+
async disconnect() {
|
|
138
|
+
if (this.stopped) return
|
|
139
|
+
this.stopped = true
|
|
140
|
+
this.connected = false
|
|
141
|
+
for (const off of this.transportUnsubs.splice(0)) {
|
|
142
|
+
try {
|
|
143
|
+
off()
|
|
144
|
+
} catch {
|
|
145
|
+
/* 取消订阅失败不阻塞断开 */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
this.rejectAllInflight('client disconnected')
|
|
149
|
+
await this.transport.stop()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @deprecated 用 disconnect();保留兼容。 */
|
|
153
|
+
stop() {
|
|
154
|
+
return this.disconnect()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── 查询 ──────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
getState() {
|
|
160
|
+
return this.lastSnapshot ?? this.transport.lastState ?? null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
getCursor() {
|
|
164
|
+
return String(this.transport.lastCursor ?? '0')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getInflightCount() {
|
|
168
|
+
return this.inflight.size
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get cacheStats() {
|
|
172
|
+
return this.cache.stats()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 测试/诊断:某 commandId 是否仍在缓存(running 或 completed)。 */
|
|
176
|
+
hasCachedCommand(commandId) {
|
|
177
|
+
return this.cache.has(String(commandId ?? ''))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── 事件订阅 ──────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
onEvent(handler) {
|
|
183
|
+
return addListener(this.listeners.event, handler)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
onDisconnect(handler) {
|
|
187
|
+
return addListener(this.listeners.disconnect, handler)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
onReconnect(handler) {
|
|
191
|
+
return addListener(this.listeners.reconnect, handler)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── 命令 API(dsh-Fang Adapter 的 9 个 op)───────────────────────────────
|
|
195
|
+
|
|
196
|
+
command(op, payload = {}, options = {}) {
|
|
197
|
+
const commandId = String(options?.commandId ?? '') || this.nextCommandId()
|
|
198
|
+
return this.runCommand(commandId, op, payload, { async: !!options?.async })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 低层入口:复用给定 commandId(网络层重试/测试用),命中缓存时不会重复执行。 */
|
|
202
|
+
commandWithId(commandId, op, payload = {}, options = {}) {
|
|
203
|
+
return this.runCommand(String(commandId ?? ''), op, payload, { async: !!options?.async })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
ping(payload = {}) {
|
|
207
|
+
return this.command(ADAPTER_OP.PING, payload)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
status(payload = {}) {
|
|
211
|
+
return this.command(ADAPTER_OP.STATUS, payload)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
flushBuffer(payload = {}) {
|
|
215
|
+
return this.command(ADAPTER_OP.FLUSH_BUFFER, payload)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
scorePlugin(payload = {}) {
|
|
219
|
+
return this.command(ADAPTER_OP.SCORE_PLUGIN, payload)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
readPlugin(payload = {}) {
|
|
223
|
+
return this.command(ADAPTER_OP.READ_PLUGIN, payload)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
routeConfirm(payload = {}) {
|
|
227
|
+
return this.command(ADAPTER_OP.ROUTE_CONFIRM, payload)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
approvals(payload = {}) {
|
|
231
|
+
return this.command(ADAPTER_OP.APPROVALS, payload)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
approvalDecide(payload = {}) {
|
|
235
|
+
return this.command(ADAPTER_OP.APPROVAL_DECIDE, payload)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
archive(payload = {}) {
|
|
239
|
+
return this.command(ADAPTER_OP.ARCHIVE, payload)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── 内部:命令执行(CommandCache 包装)────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 幂等执行:`cache.run()` 内完成「POST → (异步)等待 command-done → 返回终态」。
|
|
246
|
+
* 传输错误/超时/session 变化都会让 fn 抛错 → 缓存移除 → 允许用同一 commandId 重试。
|
|
247
|
+
*/
|
|
248
|
+
runCommand(commandId, op, payload, { async: isAsync = false } = {}) {
|
|
249
|
+
const id = String(commandId ?? '').trim()
|
|
250
|
+
if (!id) return Promise.reject(makeError('commandId is required', { code: 'bad-command-id' }))
|
|
251
|
+
const operation = String(op ?? '').trim()
|
|
252
|
+
if (!operation) return Promise.reject(makeError('op is required', { code: 'missing-op' }))
|
|
253
|
+
|
|
254
|
+
return this.cache.run(id, async () => {
|
|
255
|
+
if (this.stopped) throw makeError('client disconnected', { code: 'disconnected' })
|
|
256
|
+
if (!this.connected) throw makeError('fork client is not connected', { code: 'not-connected' })
|
|
257
|
+
const envelope = { commandId: id, op: operation, payload: payload && typeof payload === 'object' ? payload : {} }
|
|
258
|
+
if (isAsync) envelope.async = true
|
|
259
|
+
|
|
260
|
+
const res = await this.transport.commands(envelope)
|
|
261
|
+
if (!res || res.ok !== true) {
|
|
262
|
+
const code = String(res?.data?.code ?? res?.error ?? res?.kind ?? 'command-failed')
|
|
263
|
+
const error = makeError(`command ${operation} failed: ${code}`, {
|
|
264
|
+
code,
|
|
265
|
+
status: Number(res?.status) || 0,
|
|
266
|
+
transport: true,
|
|
267
|
+
})
|
|
268
|
+
throw error
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const body = res.data && typeof res.data === 'object' ? res.data : {}
|
|
272
|
+
if (body.status === 'accepted') {
|
|
273
|
+
const trackingId = String(body.trackingId ?? id)
|
|
274
|
+
return this.waitForCommandDone(trackingId, { commandId: id, op: operation })
|
|
275
|
+
}
|
|
276
|
+
return body
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── 内部:inflight(trackingId → promise)────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 等待异步命令的 `command-done`。
|
|
284
|
+
* 超时 reject(code=command-timeout)→ 缓存移除;迟到的 command-done 会被静默忽略。
|
|
285
|
+
*/
|
|
286
|
+
waitForCommandDone(trackingId, meta = {}) {
|
|
287
|
+
const trackId = String(trackingId ?? '').trim()
|
|
288
|
+
if (this.stopped) return Promise.reject(makeError('client disconnected', { code: 'disconnected' }))
|
|
289
|
+
if (!trackId) return Promise.reject(makeError('trackingId is required', { code: 'bad-tracking-id' }))
|
|
290
|
+
if (this.inflight.has(trackId)) {
|
|
291
|
+
return Promise.reject(makeError(`duplicate trackingId: ${trackId}`, { code: 'duplicate-tracking-id' }))
|
|
292
|
+
}
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
const timer = setTimeout(() => {
|
|
295
|
+
this.inflight.delete(trackId)
|
|
296
|
+
const error = makeError(
|
|
297
|
+
`command ${meta.op ?? '?'} timed out after ${this.asyncTimeoutMs}ms (trackingId=${trackId})`,
|
|
298
|
+
{ code: 'command-timeout' },
|
|
299
|
+
)
|
|
300
|
+
reject(error)
|
|
301
|
+
}, this.asyncTimeoutMs)
|
|
302
|
+
this.inflight.set(trackId, {
|
|
303
|
+
trackingId: trackId,
|
|
304
|
+
commandId: String(meta.commandId ?? ''),
|
|
305
|
+
op: String(meta.op ?? ''),
|
|
306
|
+
resolve,
|
|
307
|
+
reject,
|
|
308
|
+
timer,
|
|
309
|
+
startedAt: Date.now(),
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
resolveInflight(trackingId, result) {
|
|
315
|
+
const entry = this.inflight.get(String(trackingId ?? ''))
|
|
316
|
+
if (!entry) {
|
|
317
|
+
this.logger.debug?.(`[fork-client] stale command-done: ${trackingId}`)
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
clearTimeout(entry.timer)
|
|
321
|
+
this.inflight.delete(entry.trackingId)
|
|
322
|
+
entry.resolve(result)
|
|
323
|
+
return true
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
rejectAllInflight(reason) {
|
|
327
|
+
let count = 0
|
|
328
|
+
for (const [trackingId, entry] of [...this.inflight]) {
|
|
329
|
+
clearTimeout(entry.timer)
|
|
330
|
+
this.inflight.delete(trackingId)
|
|
331
|
+
entry.reject(makeError(String(reason ?? 'inflight rejected'), { code: 'inflight-rejected' }))
|
|
332
|
+
count += 1
|
|
333
|
+
}
|
|
334
|
+
return count
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── 内部:传输层事件接线 ─────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
attach() {
|
|
340
|
+
if (this.attached) return
|
|
341
|
+
this.attached = true
|
|
342
|
+
this.transportUnsubs.push(this.transport.onEvent((envelope) => this.handleTransportEvent(envelope)))
|
|
343
|
+
this.transportUnsubs.push(this.transport.onReconnect((payload) => this.handleTransportReconnect(payload)))
|
|
344
|
+
this.transportUnsubs.push(this.transport.onDisconnect((payload) => this.handleTransportDisconnect(payload)))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
handleTransportEvent(envelope) {
|
|
348
|
+
const event = envelope && typeof envelope === 'object' ? envelope.event : null
|
|
349
|
+
if (event && typeof event === 'object') {
|
|
350
|
+
if (event.kind === 'command-done') {
|
|
351
|
+
this.resolveInflight(event.trackingId, event.result)
|
|
352
|
+
} else if (event.kind === 'state-changed' && event.snapshot) {
|
|
353
|
+
this.lastSnapshot = event.snapshot
|
|
354
|
+
} else if (event.kind === 'error') {
|
|
355
|
+
this.logger.warn?.(`[fork-client] adapter error: ${event.code} ${event.message}`, { event })
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const handler of [...this.listeners.event]) {
|
|
359
|
+
try {
|
|
360
|
+
handler(envelope)
|
|
361
|
+
} catch (e) {
|
|
362
|
+
this.logger.warn?.(`[fork-client] event handler threw: ${msgOf(e)}`)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
handleTransportReconnect(payload) {
|
|
368
|
+
const resetCursor = typeof payload === 'boolean' ? payload : !!payload?.resetCursor
|
|
369
|
+
if (resetCursor) {
|
|
370
|
+
// 分身进程变了:旧 session 的 command-done 不会再来,继续等只会拖到超时。
|
|
371
|
+
this.rejectAllInflight('session-changed: adapter restarted')
|
|
372
|
+
}
|
|
373
|
+
for (const handler of [...this.listeners.reconnect]) {
|
|
374
|
+
try {
|
|
375
|
+
handler(payload)
|
|
376
|
+
} catch {
|
|
377
|
+
/* 隔离 */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
handleTransportDisconnect(payload) {
|
|
383
|
+
// 网络抖动:保留 inflight,等 cursor 续传补发 command-done。
|
|
384
|
+
for (const handler of [...this.listeners.disconnect]) {
|
|
385
|
+
try {
|
|
386
|
+
handler(payload)
|
|
387
|
+
} catch {
|
|
388
|
+
/* 隔离 */
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
nextCommandId() {
|
|
394
|
+
this.commandCounter += 1
|
|
395
|
+
return `cmd_${this.commandCounter}_${Date.now().toString(36)}`
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function addListener(set, handler) {
|
|
400
|
+
if (typeof handler !== 'function') return () => {}
|
|
401
|
+
set.add(handler)
|
|
402
|
+
return () => set.delete(handler)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** 便捷工厂(与 createAdapterClient 对称)。 */
|
|
406
|
+
export function createForkClient(options = {}) {
|
|
407
|
+
return new ForkClient(options)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export const _internals = { positiveMs, requireTransportMethods }
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// v3.0.9 分身 = 生产盘:派生 profile 里关闭插件安装 / 设置入口。
|
|
3
|
+
// 只锁定在当前 DSH 组合里确认存在的条目 id;DSH_FANG_FORK_CONFIG_LOCK=0 可整体关闭。
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const FORK_LOCKED_ENTRY_IDS = Object.freeze([
|
|
8
|
+
// 只禁用不影响 DSH 客户端服务图的条目。
|
|
9
|
+
// 禁用 config-editor / ui-settings 会移除 configForms 服务,导致 locale 及之后
|
|
10
|
+
// 50 个客户端插件永久 pending(实测:web boot 50 entries did not activate)。
|
|
11
|
+
'tool-plugin-manager',
|
|
12
|
+
// 只隐藏设置页的插件清单;不能禁用 ui-plugin-manager:它提供 pluginNavigation,
|
|
13
|
+
// 禁掉会让 dsh-client-ui-plugin-recommendations 永久 pending(v3.1.5 实测)。
|
|
14
|
+
'ui-settings-plugin-inventory',
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
/** DSH first-run notice needs a durable ui-settings-general config form in every fork profile. */
|
|
18
|
+
const WELCOME_NOTICE_ENTRY = Object.freeze({
|
|
19
|
+
id: 'ui-settings-general',
|
|
20
|
+
name: '@deepseek-ai/dsh-client-ui-settings-general',
|
|
21
|
+
config: Object.freeze({ welcomeNoticeVersion: '2026-09-28.1' }),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// v3.1.30:DSH 0.2.2 每次启动都会重写 profile 的 package.json
|
|
25
|
+
// (dsh-app-boot dropRetiredBundles → writeProfileManifest),cordis.patch.yml 也会被
|
|
26
|
+
// 规范化写回。把这两个文件设成只读会让子进程在引导阶段直接
|
|
27
|
+
// EPERM: operation not permitted, open '...\profiles\<profile>\package.json'
|
|
28
|
+
// 然后立即退出——上层看到的就是 spawn-failed(派生与还原都失败,v3.1.28 实测)。
|
|
29
|
+
//
|
|
30
|
+
// 因此 readonly=true 不再 chmod 任何 DSH 启动必须写的文件;readonly=false 仍会 chmod 0644,
|
|
31
|
+
// 用于自动修复历史上被设成只读的分身 profile。行为锁由 applyForkConfigLock 承担。
|
|
32
|
+
export const PROFILE_CONFIG_FILES = Object.freeze(['package.json', 'cordis.patch.yml'])
|
|
33
|
+
|
|
34
|
+
/** 真正会被 chmod 成只读的文件:v3.1.30 起为空(见上面的根因说明)。 */
|
|
35
|
+
export const READONLY_LOCKED_FILES = Object.freeze([])
|
|
36
|
+
|
|
37
|
+
/** v3.1.5 曾错误禁用 ui-plugin-manager(它提供 pluginNavigation);升级时自动移除该历史锁。 */
|
|
38
|
+
export const LEGACY_LOCKED_ENTRY_IDS = Object.freeze(['ui-plugin-manager'])
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 把分身 profile 的配置文件设为只读(防止误写;不防有意绕过)。 */
|
|
42
|
+
export function setProfileConfigReadonly(profileDir, readonly = true) {
|
|
43
|
+
if (String(process.env.DSH_FANG_FORK_PROFILE_RO ?? '1') === '0') return { ok: true, skipped: true, changed: [] }
|
|
44
|
+
const changed = []
|
|
45
|
+
const repaired = []
|
|
46
|
+
for (const name of PROFILE_CONFIG_FILES) {
|
|
47
|
+
const file = path.join(String(profileDir), name)
|
|
48
|
+
// readonly=true:只记录、不 chmod(DSH 启动必须能写这两个文件,v3.1.30 根因修复)。
|
|
49
|
+
// readonly=false:解锁——顺带修掉历史版本留下的 0444,让旧分身恢复可用。
|
|
50
|
+
if (readonly) continue
|
|
51
|
+
try {
|
|
52
|
+
if (!fs.existsSync(file)) continue
|
|
53
|
+
const stat = fs.statSync(file)
|
|
54
|
+
const wasReadonly = (stat.mode & 0o222) === 0
|
|
55
|
+
fs.chmodSync(file, 0o644)
|
|
56
|
+
changed.push(name)
|
|
57
|
+
if (wasReadonly) repaired.push(name)
|
|
58
|
+
} catch { /* 平台不支持/文件被占用则跳过 */ }
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, changed, repaired, enforced: false, files: PROFILE_CONFIG_FILES }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Essence-derived profiles serialize their patch as a JSON object `{ insert: [...] }`.
|
|
65
|
+
* Appending YAML lines after that object produces invalid YAML (the v3.0.10 spawn-failed
|
|
66
|
+
* bug). Return the semantic entry array when the file is JSON, so the lock can be merged
|
|
67
|
+
* structurally instead.
|
|
68
|
+
* @param {string} text
|
|
69
|
+
* @returns {Array<Record<string, unknown>> | null}
|
|
70
|
+
*/
|
|
71
|
+
function jsonPatchEntries(text) {
|
|
72
|
+
const trimmed = String(text ?? '').trim()
|
|
73
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null
|
|
74
|
+
let value
|
|
75
|
+
try { value = JSON.parse(trimmed) } catch { return null }
|
|
76
|
+
if (Array.isArray(value)) return value.length === 0 ? null : value
|
|
77
|
+
if (value !== null && typeof value === 'object' && Array.isArray(value.insert)) return [value]
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Add the lock rows and the onboarding row without duplicating ids. */
|
|
82
|
+
function withLockRows(entries, ids) {
|
|
83
|
+
const out = entries.slice()
|
|
84
|
+
let added = 0
|
|
85
|
+
for (const id of ids) {
|
|
86
|
+
if (!out.some((entry) => entry !== null && typeof entry === 'object' && entry.id === id)) {
|
|
87
|
+
out.push({ id, disabled: true })
|
|
88
|
+
added += 1
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!out.some((entry) => entry !== null && typeof entry === 'object' && entry.id === WELCOME_NOTICE_ENTRY.id)) {
|
|
92
|
+
out.push({ ...WELCOME_NOTICE_ENTRY, config: { ...WELCOME_NOTICE_ENTRY.config } })
|
|
93
|
+
}
|
|
94
|
+
return { entries: out, added }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function applyForkConfigLock(profileDir, options = {}) {
|
|
98
|
+
if (String(process.env.DSH_FANG_FORK_CONFIG_LOCK ?? '1') === '0') return { ok: true, skipped: true }
|
|
99
|
+
// 自动修复 v3.1.5 遗留的 ui-plugin-manager disabled:true(会断 pluginNavigation)。
|
|
100
|
+
try { clearForkConfigLock(profileDir, { ids: LEGACY_LOCKED_ENTRY_IDS }) } catch { /* best effort */ }
|
|
101
|
+
const file = path.join(String(profileDir), 'cordis.patch.yml')
|
|
102
|
+
let text = ''
|
|
103
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { text = '' }
|
|
104
|
+
const ids = options.ids ?? FORK_LOCKED_ENTRY_IDS
|
|
105
|
+
// JSON object/array patches are semantic entry lists; merge them structurally. Appending
|
|
106
|
+
// YAML text after `{ insert: [...] }` would make the whole file unparsable.
|
|
107
|
+
const jsonEntries = jsonPatchEntries(text)
|
|
108
|
+
if (jsonEntries !== null) {
|
|
109
|
+
const merged = withLockRows(jsonEntries, ids)
|
|
110
|
+
fs.writeFileSync(file, `${JSON.stringify(merged.entries, null, 2)}\n`, 'utf8')
|
|
111
|
+
return { ok: true, entries: merged.added }
|
|
112
|
+
}
|
|
113
|
+
// 模板里的 `[]`(空 YAML 数组)必须去掉,否则追加 `- id:` 会变成非法 YAML。
|
|
114
|
+
const kept = text
|
|
115
|
+
.replace(/^\uFEFF/, '')
|
|
116
|
+
.split(/\r?\n/)
|
|
117
|
+
.filter((line) => !/^\s*\[\s*\]\s*(#.*)?$/.test(line))
|
|
118
|
+
text = kept.join('\n')
|
|
119
|
+
const lines = []
|
|
120
|
+
for (const id of ids) {
|
|
121
|
+
const present = new RegExp(`(^|\\n)\\s*-\\s*id:\\s*${id}\\s*$`, 'm').test(text)
|
|
122
|
+
if (present) continue
|
|
123
|
+
lines.push(`- id: ${id}`, ' disabled: true')
|
|
124
|
+
}
|
|
125
|
+
if (!new RegExp(`(^|\\n)\\s*-\\s*id:\\s*${WELCOME_NOTICE_ENTRY.id}\\s*$`, 'm').test(text)) {
|
|
126
|
+
lines.push(
|
|
127
|
+
`- id: ${WELCOME_NOTICE_ENTRY.id}`,
|
|
128
|
+
` name: "${WELCOME_NOTICE_ENTRY.name}"`,
|
|
129
|
+
' config:',
|
|
130
|
+
` welcomeNoticeVersion: ${WELCOME_NOTICE_ENTRY.config.welcomeNoticeVersion}`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
if (lines.length === 0) return { ok: true, entries: 0 }
|
|
134
|
+
const prefix = text.trim().length === 0 ? '' : (text.endsWith('\n') ? text : text + '\n')
|
|
135
|
+
const comment = text.includes('# fang: fork is production-only')
|
|
136
|
+
? '' : '# fang: fork is production-only (plugin install / settings disabled)\n'
|
|
137
|
+
fs.writeFileSync(file, `${prefix}${comment}${lines.join('\n')}\n`, 'utf8')
|
|
138
|
+
return { ok: true, entries: ids.length }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 解除 fork 生产盘锁(只删 disabled:true 的锁定行,不碰其他配置)。
|
|
143
|
+
* 元宝运行态 essence-runtime 是配置实例,升级后需要清掉历史 provision 写入的锁。
|
|
144
|
+
*/
|
|
145
|
+
export function clearForkConfigLock(profileDir, options = {}) {
|
|
146
|
+
const file = path.join(String(profileDir), 'cordis.patch.yml')
|
|
147
|
+
let text = ''
|
|
148
|
+
try { text = fs.readFileSync(file, 'utf8') } catch { return { ok: true, removed: 0 } }
|
|
149
|
+
const ids = options.ids ?? FORK_LOCKED_ENTRY_IDS
|
|
150
|
+
const jsonEntries = jsonPatchEntries(text)
|
|
151
|
+
if (jsonEntries !== null) {
|
|
152
|
+
const wanted = new Set(ids)
|
|
153
|
+
const kept = jsonEntries.filter((entry) => !(entry && typeof entry === 'object' && wanted.has(entry.id) && entry.disabled === true))
|
|
154
|
+
if (kept.length === jsonEntries.length) return { ok: true, removed: 0 }
|
|
155
|
+
fs.writeFileSync(file, `${JSON.stringify(kept, null, 2)}\n`, 'utf8')
|
|
156
|
+
return { ok: true, removed: jsonEntries.length - kept.length }
|
|
157
|
+
}
|
|
158
|
+
let removed = 0
|
|
159
|
+
for (const id of ids) {
|
|
160
|
+
const escaped = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
161
|
+
const re = new RegExp(`(^|\\n)\\s*-\\s*id:\\s*${escaped}\\s*\\n\\s*disabled:\\s*true\\s*(?=\\n|$)`, 'g')
|
|
162
|
+
text = text.replace(re, (match, lead) => { removed += 1; return lead })
|
|
163
|
+
}
|
|
164
|
+
if (removed > 0) fs.writeFileSync(file, text, 'utf8')
|
|
165
|
+
return { ok: true, removed }
|
|
166
|
+
}
|