@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.5

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 (147) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,304 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // fork-seed — 派生分身的默认第三方插件种子(v3.1.12)。
5
+ //
6
+ // 新机器/新用户的干净分身模板只有 base + web-app + fang;这里按“安装不激活 /
7
+ // 安装并激活”两组清单,把默认第三方插件装进**每个新分身自己的 profile**:
8
+ // * install = 只写入 dependencies 并安装 node_modules,不加入 profile bundles;
9
+ // * activate = 额外加入 profile 的 dsh.profile.bundles,DSH 启动时就会加载。
10
+ // 安装缓存在 <forkHomes>/.plugin-seed/seed-<fingerprint>,用 dsh 自带 pnpm 完成;
11
+ // 模块树通过 hardlink 合并进分身 `node_modules`,不替换原有的 mother 镜像条目。
12
+ import { spawn } from 'node:child_process'
13
+ import crypto from 'node:crypto'
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+
17
+ import { ensureDir, exists, hardlinkTree, isLink, removeTree, writeJsonAtomic } from '../kernel/index.mjs'
18
+
19
+ /** 用户给定的默认清单(2026-11-11)。新增/调整只改这里,再同步 docs/CHANGELOG。 */
20
+ export const DEFAULT_FORK_SEED = Object.freeze({
21
+ enabled: true,
22
+ install: Object.freeze([
23
+ '@twinsearth/dsh-2xray',
24
+ '@michengai/dsh-agency-agents',
25
+ 'dsh-better-sidebar',
26
+ 'dsh-context',
27
+ '@liustack/modlens',
28
+ '@liustack/modsearch',
29
+ ]),
30
+ activate: Object.freeze([
31
+ 'dshmarket',
32
+ '@twinsearth/dsh-im2a-plugin',
33
+ '@openviking/dsh-memory-plugin',
34
+ '@twinsearth/w2m-dsh-plugin',
35
+ ]),
36
+ })
37
+
38
+ /** npm 包名(可带 scope),防止配置把路径/参数注入 pnpm 命令。 */
39
+ const PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i
40
+
41
+ /** 把用户配置归一成 { enabled, install, activate, packages }(去重、过滤非法名)。 */
42
+ export function normalizeForkSeed(input = {}) {
43
+ const raw = input && typeof input === 'object' ? input : {}
44
+ const enabled = raw.enabled !== false
45
+ const clean = (list) => [...new Set((Array.isArray(list) ? list : []).map((item) => String(item ?? '').trim()).filter((name) => PACKAGE_NAME_RE.test(name)))]
46
+ const install = clean(raw.install ?? DEFAULT_FORK_SEED.install)
47
+ const activate = clean(raw.activate ?? DEFAULT_FORK_SEED.activate)
48
+ const packages = [...install, ...activate.filter((name) => !install.includes(name))]
49
+ return { enabled, install, activate, packages }
50
+ }
51
+
52
+ /** 种子指纹:配置变化即换缓存目录(排序,顺序不同但集合相同不换缓存)。 */
53
+ export function forkSeedFingerprint(seed) {
54
+ const norm = normalizeForkSeed(seed)
55
+ const canonical = { packages: [...norm.packages].sort(), activate: [...norm.activate].sort() }
56
+ return crypto.createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16)
57
+ }
58
+
59
+ function readJson(file, fallback = null) {
60
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
61
+ }
62
+
63
+ function packageDirName(name) {
64
+ return name.split('/')
65
+ }
66
+
67
+ export function seedPackageDir(modulesDir, name) {
68
+ return path.join(String(modulesDir ?? ''), ...packageDirName(name))
69
+ }
70
+
71
+ function readInstalledVersion(modulesDir, name) {
72
+ const pkg = readJson(path.join(seedPackageDir(modulesDir, name), 'package.json'), null)
73
+ return pkg && typeof pkg.version === 'string' ? pkg.version : ''
74
+ }
75
+
76
+ /**
77
+ * 把种子模块合并进分身 `node_modules`:
78
+ * * 普通包:整目录 hardlink 覆盖;
79
+ * * scope:把目标 scope(可能是 junction / 真实目录)先展开成真实目录,再覆盖 seed 里的子包。
80
+ * 不删除 seed 之外的目标条目(mother 镜像要继续可用)。
81
+ */
82
+ export function mergeSeedModules(seedModulesDir, targetModulesDir) {
83
+ const source = String(seedModulesDir ?? '')
84
+ const target = String(targetModulesDir ?? '')
85
+ if (!source || !exists(source)) return { ok: false, error: 'seed-modules-missing' }
86
+ if (!target || !exists(target)) return { ok: false, error: 'target-modules-missing' }
87
+ const copied = []
88
+ const scopes = []
89
+ const warnings = []
90
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
91
+ const name = entry.name
92
+ if (name.startsWith('.') || !entry.isDirectory()) continue
93
+ const from = path.join(source, name)
94
+ const to = path.join(target, name)
95
+ try {
96
+ if (name.startsWith('@')) {
97
+ mergeScope(from, to)
98
+ scopes.push(name)
99
+ } else {
100
+ removeTree(to)
101
+ hardlinkTree(from, to)
102
+ copied.push(name)
103
+ }
104
+ } catch (error) {
105
+ warnings.push(`${name}: ${String(error?.message ?? error)}`)
106
+ }
107
+ }
108
+ return { ok: warnings.length === 0, copied, scopes, warnings }
109
+ }
110
+
111
+ function mergeScope(seedScope, targetScope) {
112
+ const staging = `${targetScope}.fang-seed-merge`
113
+ removeTree(staging)
114
+ ensureDir(staging)
115
+ // 1) 保留目标 scope 既有子包(可能是指向 mother 模块树的 junction)。
116
+ if (exists(targetScope) || isLink(targetScope)) {
117
+ for (const child of fs.readdirSync(targetScope, { withFileTypes: true })) {
118
+ const from = path.join(targetScope, child.name)
119
+ const to = path.join(staging, child.name)
120
+ try {
121
+ if (child.isSymbolicLink()) {
122
+ const resolved = fs.realpathSync(from)
123
+ hardlinkTree(resolved, to)
124
+ } else if (child.isDirectory()) hardlinkTree(from, to)
125
+ } catch { /* keep the rest */ }
126
+ }
127
+ }
128
+ // 2) seed 子包覆盖同名项。
129
+ for (const child of fs.readdirSync(seedScope, { withFileTypes: true })) {
130
+ if (!child.isDirectory()) continue
131
+ const from = path.join(seedScope, child.name)
132
+ const to = path.join(staging, child.name)
133
+ removeTree(to)
134
+ hardlinkTree(from, to)
135
+ }
136
+ // 3) 原子替换 scope 目录。
137
+ removeTree(targetScope)
138
+ try { fs.renameSync(staging, targetScope) } catch {
139
+ removeTree(staging)
140
+ hardlinkTree(seedScope, targetScope)
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 把种子写进分身 profile 的 package.json:
146
+ * dependencies += 所有种子包(已有 spec 不覆盖);
147
+ * dsh.profile.bundles += activate 列表(放在 fang 之后,去重)。
148
+ * @returns {{packageJson:object, changed:boolean, addedDependencies:string[], addedBundles:string[]}}
149
+ */
150
+ export function forkSeedProfilePatch(profilePkg, seed, versions = {}) {
151
+ const norm = normalizeForkSeed(seed)
152
+ const pkg = profilePkg && typeof profilePkg === 'object' ? JSON.parse(JSON.stringify(profilePkg)) : {}
153
+ const dependencies = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {}
154
+ const addedDependencies = []
155
+ for (const name of norm.packages) {
156
+ if (typeof dependencies[name] === 'string' && dependencies[name].trim()) continue
157
+ const version = String(versions[name] ?? '').trim()
158
+ dependencies[name] = version ? `^${version}` : '*'
159
+ addedDependencies.push(name)
160
+ }
161
+ if (Object.keys(dependencies).length > 0) pkg.dependencies = dependencies
162
+ pkg.dsh = pkg.dsh && typeof pkg.dsh === 'object' ? pkg.dsh : {}
163
+ pkg.dsh.profile = pkg.dsh.profile && typeof pkg.dsh.profile === 'object' ? pkg.dsh.profile : {}
164
+ const bundles = Array.isArray(pkg.dsh.profile.bundles) ? [...pkg.dsh.profile.bundles] : []
165
+ const addedBundles = []
166
+ for (const name of norm.activate) {
167
+ if (bundles.includes(name)) continue
168
+ bundles.push(name)
169
+ addedBundles.push(name)
170
+ }
171
+ pkg.dsh.profile.bundles = bundles
172
+ return { packageJson: pkg, changed: addedDependencies.length > 0 || addedBundles.length > 0, addedDependencies, addedBundles }
173
+ }
174
+
175
+ const SEED_MARKER = '.fang-fork-seed.json'
176
+
177
+ /** 从 dsh CLI 路径推导 DSH 自带 pnpm 入口(.../runtime/cli/bin/dsh.cmd → .../runtime/primary-runtime/dependencies/pnpm/bin/pnpm.mjs)。 */
178
+ export function resolvePnpmEntry(cliPath) {
179
+ const cli = String(cliPath ?? '').trim()
180
+ if (!cli) return null
181
+ const cliDir = path.dirname(path.resolve(cli))
182
+ const candidates = [
183
+ path.resolve(cliDir, '..', '..', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'),
184
+ path.resolve(cliDir, '..', '..', '..', 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'),
185
+ ]
186
+ return candidates.find((candidate) => exists(candidate)) ?? null
187
+ }
188
+
189
+ /**
190
+ * 默认安装器:在一个**普通 pnpm 项目**里安装种子(不初始化 DSH profile,避免被默认
191
+ * dsh-llm 之类无法解析的基线依赖拖死)。只装插件包本身,peer 依赖交给宿主解析。
192
+ */
193
+ export async function installSeedWithPnpm({ cliPath, seedHome, packages, timeoutMs = 300000 }) {
194
+ const pnpmEntry = resolvePnpmEntry(cliPath)
195
+ if (!pnpmEntry) return { ok: false, error: 'pnpm-not-found', detail: `cliPath=${cliPath}` }
196
+ const seedDir = path.join(seedHome, 'seed')
197
+ ensureDir(seedDir)
198
+ const dependencies = Object.fromEntries([...packages].sort().map((name) => [name, 'latest']))
199
+ writeJsonAtomic(path.join(seedDir, 'package.json'), { name: 'fang-fork-seed', private: true, dependencies })
200
+ fs.writeFileSync(path.join(seedDir, 'pnpm-workspace.yaml'), [
201
+ 'packages:',
202
+ ' - .',
203
+ '',
204
+ 'nodeLinker: hoisted',
205
+ 'autoInstallPeers: false',
206
+ 'strictPeerDependencies: false',
207
+ 'minimumReleaseAge: 0',
208
+ '',
209
+ ].join('\n'), 'utf8')
210
+ const nodeBin = process.execPath
211
+ const args = [pnpmEntry, 'install', '--ignore-scripts', '--config.confirmModulesPurge=false']
212
+ const result = await new Promise((resolve) => {
213
+ let output = ''
214
+ let child = null
215
+ let timer = null
216
+ const done = (value) => { if (timer) clearTimeout(timer); resolve(value) }
217
+ try {
218
+ child = spawn(nodeBin, args, {
219
+ cwd: seedDir,
220
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
221
+ windowsHide: true,
222
+ stdio: ['ignore', 'pipe', 'pipe'],
223
+ })
224
+ } catch (error) {
225
+ done({ ok: false, error: 'spawn-failed', detail: String(error?.message ?? error) })
226
+ return
227
+ }
228
+ const append = (chunk) => { output = (output + String(chunk)).slice(-8000) }
229
+ child.stdout?.on('data', append)
230
+ child.stderr?.on('data', append)
231
+ timer = setTimeout(() => { try { child.kill() } catch { /* ignore */ } done({ ok: false, error: 'seed-install-timeout', detail: output.slice(-1200) }) }, Math.max(1000, Number(timeoutMs) || 300000))
232
+ child.on('error', (error) => done({ ok: false, error: 'spawn-failed', detail: String(error?.message ?? error) }))
233
+ child.on('exit', (code) => done(code === 0 ? { ok: true, detail: output.slice(-1200) } : { ok: false, error: 'seed-install-failed', code, detail: output.slice(-2400) }))
234
+ })
235
+ return { ...result, modulesDir: path.join(seedDir, 'node_modules') }
236
+ }
237
+
238
+ /**
239
+ * 给一个分身 profile 装备默认插件种子。幂等:同一指纹已应用则直接返回。
240
+ * @returns {Promise<{ok:boolean, skipped?:boolean, cached?:boolean, packages?:string[], activate?:string[],
241
+ * versions?:Record<string,string>, merged?:object, error?:string, detail?:string}>}
242
+ */
243
+ export async function applyForkSeedToProfile({ seed, profileDir, profileModulesDir, seedRoot, cliPath, timeoutMs = 300000, install = installSeedWithPnpm } = {}) {
244
+ const norm = normalizeForkSeed(seed)
245
+ if (!norm.enabled || norm.packages.length === 0) return { ok: true, skipped: true }
246
+ const profile = String(profileDir ?? '')
247
+ if (!profile || !exists(path.join(profile, 'package.json'))) return { ok: false, error: 'profile-package-missing' }
248
+ if (!cliPath) return { ok: false, error: 'cli-path-missing' }
249
+ const fingerprint = forkSeedFingerprint(norm)
250
+ const markerFile = path.join(profile, SEED_MARKER)
251
+ const marker = readJson(markerFile, null)
252
+ const modulesDir = String(profileModulesDir ?? path.join(profile, 'node_modules'))
253
+ const seedHome = path.join(String(seedRoot ?? path.join(path.dirname(profile), '.plugin-seed')), `seed-${fingerprint}`)
254
+ const seedModules = path.join(seedHome, 'seed', 'node_modules')
255
+
256
+ const cached = marker && marker.fingerprint === fingerprint
257
+ ? norm.packages.every((name) => exists(seedPackageDir(seedModules, name)) && exists(seedPackageDir(modulesDir, name)))
258
+ : false
259
+ if (cached) {
260
+ // v3.1.12: every spawn replays the clean template over the profile config, which
261
+ // wipes the seeded dependencies/bundles. The seed modules and marker survive, so
262
+ // re-apply the cheap profile patch on the cached path (no install, no module copy).
263
+ const cachedPkg = readJson(path.join(profile, 'package.json'), null)
264
+ if (!cachedPkg) return { ok: false, error: 'profile-package-invalid' }
265
+ const patched = forkSeedProfilePatch(cachedPkg, norm, marker.versions ?? {})
266
+ if (patched.changed) writeJsonAtomic(path.join(profile, 'package.json'), patched.packageJson)
267
+ return {
268
+ ok: true, cached: true, packages: norm.packages, activate: norm.activate, versions: marker.versions ?? {},
269
+ patched: { addedDependencies: patched.addedDependencies, addedBundles: patched.addedBundles },
270
+ }
271
+ }
272
+
273
+ if (!norm.packages.every((name) => exists(seedPackageDir(seedModules, name)))) {
274
+ const installResult = await install({ cliPath, seedHome, packages: norm.packages, timeoutMs })
275
+ if (!installResult?.ok) return { ok: false, error: installResult?.error ?? 'seed-install-failed', detail: installResult?.detail ?? '' }
276
+ }
277
+ const unknown = norm.packages.filter((name) => !exists(seedPackageDir(seedModules, name)))
278
+ if (unknown.length > 0) return { ok: false, error: 'seed-packages-missing', detail: unknown.join(',') }
279
+ const versions = Object.fromEntries(norm.packages.map((name) => [name, readInstalledVersion(seedModules, name)]).filter(([, version]) => version))
280
+
281
+ const profilePkg = readJson(path.join(profile, 'package.json'), null)
282
+ if (!profilePkg) return { ok: false, error: 'profile-package-invalid' }
283
+ const patched = forkSeedProfilePatch(profilePkg, norm, versions)
284
+ if (patched.changed) writeJsonAtomic(path.join(profile, 'package.json'), patched.packageJson)
285
+
286
+ const merged = mergeSeedModules(seedModules, modulesDir)
287
+ if (!merged.ok) {
288
+ return {
289
+ ok: false, error: 'seed-modules-merge-failed', detail: (merged.warnings ?? []).join('; '),
290
+ packages: norm.packages, versions, merged,
291
+ }
292
+ }
293
+ writeJsonAtomic(markerFile, {
294
+ fingerprint,
295
+ packages: norm.packages,
296
+ activate: norm.activate,
297
+ versions,
298
+ appliedAt: Date.now(),
299
+ merged: { copied: merged.copied.length, scopes: merged.scopes.length },
300
+ })
301
+ return { ok: true, packages: norm.packages, activate: norm.activate, versions, merged, patched: { addedDependencies: patched.addedDependencies, addedBundles: patched.addedBundles } }
302
+ }
303
+
304
+ export { SEED_MARKER }
@@ -0,0 +1,183 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // v3.1.35 — 分身会话数约束(1..3)与默认空会话。
5
+ //
6
+ // 规格(用户 2026-11-13):
7
+ // 「默认一个分身 Web 端的分身会话,默认不能为 0 至少有 1 个(空会话),
8
+ // 不超过 3 个,可以有多个分支。」
9
+ //
10
+ // 于是:
11
+ // * 下限 1:分身 Web 里永远至少有一个会话可用;空白派生时自动补一个空会话;
12
+ // * 上限 3:超过 3 个时把较旧的移入 quarantine-sessions/(不删除);
13
+ // * 分支不受此限:分支是同一会话的不同走向,不是新的会话。
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import zlib from 'node:zlib'
17
+ import { randomUUID } from 'node:crypto'
18
+ import { encodeWorkspaceKey } from './session-format.mjs'
19
+
20
+ // 复用并对外暴露,便于调用方与测试统一。
21
+ export { encodeWorkspaceKey }
22
+
23
+ export const FORK_SESSION_MIN = 1
24
+ export const FORK_SESSION_MAX = 3
25
+
26
+ const SESSION_FILE = 'session.v4.jsonl.zstd'
27
+ const DEFAULT_PRESET = 'standard'
28
+ const DEFAULT_PERMISSION = 'workspace-write'
29
+ const DEFAULT_SANDBOX = 'workspace-write'
30
+ const DEFAULT_APPROVAL = 'ask'
31
+
32
+ /** The minimal legal DSH session log (verified against a real 397-byte empty session). */
33
+ export function buildEmptySessionFrames({ sessionId, createdAt, cwd }) {
34
+ const id = String(sessionId ?? '')
35
+ const at = Number(createdAt) || Date.now()
36
+ const workspace = String(cwd ?? '')
37
+ return [
38
+ JSON.stringify({
39
+ type: 'session', version: 4, id, createdAt: at, cwd: workspace,
40
+ isSeeded: false, delegationDepth: 0, agentPreset: DEFAULT_PRESET,
41
+ }),
42
+ JSON.stringify({ type: 'permission/preset', seq: 0, time: at + 24, data: { preset: DEFAULT_PERMISSION } }),
43
+ JSON.stringify({ type: 'sandbox/mode', seq: 1, time: at + 40, data: { mode: DEFAULT_SANDBOX } }),
44
+ JSON.stringify({ type: 'approval/policy', seq: 2, time: at + 41, data: { policy: DEFAULT_APPROVAL } }),
45
+ JSON.stringify({ type: 'session/end-seed', seq: 3, time: at, data: {} }),
46
+ ]
47
+ }
48
+
49
+ export function encodeSessionLog(frames, { compress = true } = {}) {
50
+ const text = frames.join('\n') + '\n'
51
+ const buffer = Buffer.from(text, 'utf8')
52
+ if (!compress) return buffer
53
+ if (typeof zlib.zstdCompressSync !== 'function') {
54
+ throw new Error('zstd-unavailable: this Node build has no zlib.zstdCompressSync')
55
+ }
56
+ return zlib.zstdCompressSync(buffer)
57
+ }
58
+
59
+ /** Scan a fork home's own sessions: one entry per session directory. */
60
+ export function scanForkSessions(homeDir) {
61
+ const sessionsRoot = path.join(String(homeDir), 'sessions')
62
+ const out = []
63
+ let workspaceDirs = []
64
+ try { workspaceDirs = fs.readdirSync(sessionsRoot, { withFileTypes: true }).filter((e) => e.isDirectory()) } catch { return out }
65
+ for (const workspace of workspaceDirs) {
66
+ const workspaceDir = path.join(sessionsRoot, workspace.name)
67
+ let sessionDirs = []
68
+ try { sessionDirs = fs.readdirSync(workspaceDir, { withFileTypes: true }).filter((e) => e.isDirectory()) } catch { continue }
69
+ for (const session of sessionDirs) {
70
+ const file = path.join(workspaceDir, session.name, SESSION_FILE)
71
+ let mtimeMs = 0
72
+ let bytes = 0
73
+ try {
74
+ const stat = fs.statSync(file)
75
+ mtimeMs = stat.mtimeMs
76
+ bytes = stat.size
77
+ } catch { continue }
78
+ out.push({ workspaceKey: workspace.name, sessionId: session.name, file, mtimeMs, bytes })
79
+ }
80
+ }
81
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs)
82
+ return out
83
+ }
84
+
85
+ function readWorkspaceIndex(homeDir) {
86
+ const file = path.join(String(homeDir), 'storages', 'workspace.json')
87
+ let doc = null
88
+ try { doc = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { doc = null }
89
+ if (!doc || typeof doc !== 'object') {
90
+ doc = {
91
+ unit: { name: 'workspace', version: 2 },
92
+ global: { initialized: true, defaultWorkspaceId: null, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] },
93
+ tables: { workspaces: {} },
94
+ }
95
+ }
96
+ doc.tables = doc.tables && typeof doc.tables === 'object' ? doc.tables : {}
97
+ doc.tables.workspaces = doc.tables.workspaces && typeof doc.tables.workspaces === 'object' ? doc.tables.workspaces : {}
98
+ doc.global = doc.global && typeof doc.global === 'object'
99
+ ? doc.global
100
+ : { initialized: true, defaultWorkspaceId: null, workspaceIds: [], archivedSessionIds: [], pinnedSessionIds: [] }
101
+ doc.global.workspaceIds = Array.isArray(doc.global.workspaceIds) ? doc.global.workspaceIds : []
102
+ return { file, doc }
103
+ }
104
+
105
+ function writeWorkspaceIndex(file, doc) {
106
+ fs.mkdirSync(path.dirname(file), { recursive: true })
107
+ const tmp = file + '.tmp'
108
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8')
109
+ fs.renameSync(tmp, file)
110
+ }
111
+
112
+ /**
113
+ * Ensure the fork has at least FORK_SESSION_MIN sessions (default: one empty session).
114
+ * Returns { ok, created, sessionId, workspaceKey } — idempotent when a session already exists.
115
+ */
116
+ export function ensureForkEmptySession(homeDir, { workspacePath = '', workspaceTitle = '', now = Date.now() } = {}) {
117
+ const existing = scanForkSessions(homeDir)
118
+ if (existing.length >= FORK_SESSION_MIN) {
119
+ return { ok: true, created: false, sessionId: existing[0].sessionId, workspaceKey: existing[0].workspaceKey }
120
+ }
121
+ const cwd = String(workspacePath ?? '').trim() || process.cwd()
122
+ const title = String(workspaceTitle ?? '').trim() || path.basename(cwd) || '新会话'
123
+ const workspaceKey = encodeWorkspaceKey(cwd)
124
+ const sessionId = 'session-' + randomUUID()
125
+ const dir = path.join(String(homeDir), 'sessions', workspaceKey, sessionId)
126
+ fs.mkdirSync(dir, { recursive: true })
127
+ const frames = buildEmptySessionFrames({ sessionId, createdAt: now, cwd })
128
+ fs.writeFileSync(path.join(dir, SESSION_FILE), encodeSessionLog(frames))
129
+
130
+ const { file, doc } = readWorkspaceIndex(homeDir)
131
+ // 与 DSH 自身一致:workspaceId 由 workspace 路径稳定派生。
132
+ const workspaceId = 'ws-' + Buffer.from(workspaceKey).toString('hex').slice(0, 12)
133
+ const previous = doc.tables.workspaces[workspaceId]
134
+ const sessionIds = Array.isArray(previous?.sessionIds) ? previous.sessionIds.filter((id) => typeof id === 'string') : []
135
+ if (!sessionIds.includes(sessionId)) sessionIds.push(sessionId)
136
+ doc.tables.workspaces[workspaceId] = {
137
+ path: cwd,
138
+ title,
139
+ sessionIds,
140
+ createdAt: previous?.createdAt ?? new Date(now).toISOString(),
141
+ updatedAt: new Date(now).toISOString(),
142
+ }
143
+ if (!doc.global.workspaceIds.includes(workspaceId)) doc.global.workspaceIds.push(workspaceId)
144
+ if (!doc.global.defaultWorkspaceId) doc.global.defaultWorkspaceId = workspaceId
145
+ doc.global.initialized = true
146
+ writeWorkspaceIndex(file, doc)
147
+
148
+ return { ok: true, created: true, sessionId, workspaceKey }
149
+ }
150
+
151
+ /**
152
+ * Enforce the upper bound: keep the newest FORK_SESSION_MAX sessions, move the rest to
153
+ * quarantine-sessions/<stamp>/… (never delete). Branch sessions are exempt.
154
+ */
155
+ export function enforceForkSessionCap(homeDir, { keepSessionIds = [], max = FORK_SESSION_MAX, now = Date.now() } = {}) {
156
+ const keep = new Set((Array.isArray(keepSessionIds) ? keepSessionIds : []).map(String))
157
+ const sessions = scanForkSessions(homeDir).filter((session) => !keep.has(session.sessionId))
158
+ const overflow = sessions.slice(Math.max(0, max))
159
+ if (overflow.length === 0) return { ok: true, kept: Math.min(sessions.length, max), quarantined: [] }
160
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, '-')
161
+ const quarantined = []
162
+ for (const session of overflow) {
163
+ const target = path.join(String(homeDir), 'quarantine-sessions', stamp, session.workspaceKey, session.sessionId)
164
+ try {
165
+ fs.mkdirSync(path.dirname(target), { recursive: true })
166
+ fs.renameSync(path.dirname(session.file), target)
167
+ quarantined.push({ workspaceKey: session.workspaceKey, sessionId: session.sessionId, movedTo: target, at: now })
168
+ } catch { /* 移动失败则保留原地,不删除 */ }
169
+ }
170
+ return { ok: true, kept: Math.min(sessions.length, max), quarantined }
171
+ }
172
+
173
+ /** Feedback for the panel: how many sessions, and whether the cap is violated. */
174
+ export function summarizeForkSessions(homeDir) {
175
+ const list = scanForkSessions(homeDir)
176
+ return {
177
+ count: list.length,
178
+ min: FORK_SESSION_MIN,
179
+ max: FORK_SESSION_MAX,
180
+ belowFloor: list.length < FORK_SESSION_MIN,
181
+ violation: list.length > FORK_SESSION_MAX,
182
+ }
183
+ }
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // fork-supervision-settings — 分身看护的持久化开关(UI 与 watchdog 共用)。
5
+ // <dataDir>/fork-supervision.json
6
+ // { version:1, autostartForks:boolean, supervise:boolean, updatedAt:number }
7
+ //
8
+ // autostartForks:watchdog 启动时是否请求 /api/forks/autostart
9
+ // supervise :forker 是否在分身崩溃后按退避重启(运行时可切换)
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+
13
+ const DEFAULTS = Object.freeze({ autostartForks: true, supervise: false })
14
+ const FILE_NAME = 'fork-supervision.json'
15
+
16
+ export function supervisionSettingsFile(dataDir) {
17
+ return path.join(String(dataDir ?? ''), FILE_NAME)
18
+ }
19
+
20
+ function normalize(raw) {
21
+ return {
22
+ version: 1,
23
+ autostartForks: typeof raw?.autostartForks === 'boolean' ? raw.autostartForks : DEFAULTS.autostartForks,
24
+ supervise: typeof raw?.supervise === 'boolean' ? raw.supervise : DEFAULTS.supervise,
25
+ updatedAt: Number.isFinite(Number(raw?.updatedAt)) ? Number(raw.updatedAt) : 0,
26
+ }
27
+ }
28
+
29
+ export function readForkSupervisionSettings(dataDir) {
30
+ const file = supervisionSettingsFile(dataDir)
31
+ try {
32
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
33
+ return { ...normalize(raw), file, exists: true }
34
+ } catch {
35
+ return { ...normalize(null), file, exists: false }
36
+ }
37
+ }
38
+
39
+ export function writeForkSupervisionSettings(dataDir, patch = {}) {
40
+ const dir = String(dataDir ?? '')
41
+ if (!dir) return { ok: false, error: 'data-dir-required' }
42
+ const current = readForkSupervisionSettings(dir)
43
+ const next = normalize({
44
+ autostartForks: typeof patch.autostartForks === 'boolean' ? patch.autostartForks : current.autostartForks,
45
+ supervise: typeof patch.supervise === 'boolean' ? patch.supervise : current.supervise,
46
+ updatedAt: Date.now(),
47
+ })
48
+ try {
49
+ fs.mkdirSync(dir, { recursive: true })
50
+ const file = supervisionSettingsFile(dir)
51
+ const tmp = `${file}.tmp-${process.pid}`
52
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
53
+ fs.renameSync(tmp, file)
54
+ return { ok: true, ...next, file }
55
+ } catch (error) {
56
+ return { ok: false, error: 'write-failed', detail: String(error?.message ?? error) }
57
+ }
58
+ }
@@ -0,0 +1,122 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // fork-supervisor — pure/codable pieces shared by fang-forker and its tests:
4
+ // restart backoff, orphan reconciliation, per-fork meta.json, and a loopback health probe.
5
+ // No process spawning here; the forker keeps that responsibility.
6
+
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import http from 'node:http'
10
+
11
+ export const SUPERVISION_DEFAULTS = Object.freeze({
12
+ maxRestarts: 3,
13
+ baseMs: 1000,
14
+ maxMs: 30000,
15
+ jitter: 0.2,
16
+ healthPath: '/',
17
+ healthTimeoutMs: 1500,
18
+ })
19
+
20
+ export const META_FILE = 'fang-meta.json'
21
+ export const META_VERSION = 1
22
+
23
+ /** Exponential backoff with jitter; deterministic when `random` is injected. */
24
+ export function computeBackoff(attempt, options = {}) {
25
+ const baseMs = Math.max(1, Number(options.baseMs ?? SUPERVISION_DEFAULTS.baseMs) || SUPERVISION_DEFAULTS.baseMs)
26
+ const maxMs = Math.max(baseMs, Number(options.maxMs ?? SUPERVISION_DEFAULTS.maxMs) || SUPERVISION_DEFAULTS.maxMs)
27
+ const jitter = Math.min(0.9, Math.max(0, Number(options.jitter ?? SUPERVISION_DEFAULTS.jitter) || 0))
28
+ const random = typeof options.random === 'function' ? options.random : Math.random
29
+ const n = Math.max(0, Math.floor(Number(attempt) || 0))
30
+ const exp = Math.min(maxMs, baseMs * 2 ** n)
31
+ const delta = exp * jitter
32
+ return Math.max(0, Math.floor(exp + (random() * 2 - 1) * delta))
33
+ }
34
+
35
+ /**
36
+ * Decide what supervision does after an exit.
37
+ * Only crashes of a fork that had actually become ready are restarted; a failed first
38
+ * spawn is reported to the orchestrator instead of loop-spawning.
39
+ * @returns {{action:'restart'|'give-up'|'ignore', reason:string, attempt:number, delayMs:number}}
40
+ */
41
+ export function planRestart(exitInfo, record = {}, policy = {}) {
42
+ const intentional = record.intentionalStop === true
43
+ const ready = record.ready === true
44
+ const kind = String(exitInfo?.kind ?? 'crash')
45
+ if (intentional) return { action: 'ignore', reason: 'intentional-stop', attempt: 0, delayMs: 0 }
46
+ if (!ready) return { action: 'give-up', reason: 'never-ready', attempt: 0, delayMs: 0 }
47
+ if (kind === 'normal') return { action: 'ignore', reason: 'normal-exit', attempt: 0, delayMs: 0 }
48
+ const maxRestarts = Math.max(0, Number(policy.maxRestarts ?? SUPERVISION_DEFAULTS.maxRestarts) || 0)
49
+ const attempt = Math.max(0, Number(record.restartCount ?? 0) || 0)
50
+ if (attempt >= maxRestarts) return { action: 'give-up', reason: 'max-restarts', attempt, delayMs: 0 }
51
+ return {
52
+ action: 'restart',
53
+ reason: kind === 'restart-request' ? 'restart-request' : 'crash',
54
+ attempt,
55
+ delayMs: computeBackoff(attempt, policy),
56
+ }
57
+ }
58
+
59
+ /** Reconcile persisted records: alive pids keep running; dead ones become crashed; foreign pids are flagged. */
60
+ export function reconcileOrphans(records = [], isAlive = () => false) {
61
+ const updates = []
62
+ for (const rec of Array.isArray(records) ? records : []) {
63
+ const pid = Number(rec?.pid)
64
+ const liveStatus = ['provisioning', 'ready', 'running', 'starting'].includes(String(rec?.status ?? ''))
65
+ if (!liveStatus) continue
66
+ if (!Number.isInteger(pid) || pid <= 0) {
67
+ updates.push({ forkId: rec.forkId, action: 'mark-crashed', reason: 'no-pid' })
68
+ continue
69
+ }
70
+ if (isAlive(pid)) updates.push({ forkId: rec.forkId, action: 'keep', reason: 'pid-alive' })
71
+ else updates.push({ forkId: rec.forkId, action: 'mark-crashed', reason: 'pid-dead', pid })
72
+ }
73
+ return updates
74
+ }
75
+
76
+ export function metaPath(homeDir) {
77
+ return path.join(String(homeDir ?? ''), META_FILE)
78
+ }
79
+
80
+ /** Atomic per-fork meta (mode 0600). Never throws on read; write returns false on failure. */
81
+ export function writeForkMeta(homeDir, meta) {
82
+ if (!homeDir) return false
83
+ const file = metaPath(homeDir)
84
+ const doc = { version: META_VERSION, at: new Date().toISOString(), ...meta }
85
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
86
+ try {
87
+ fs.mkdirSync(path.dirname(file), { recursive: true })
88
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), { mode: 0o600 })
89
+ fs.renameSync(tmp, file)
90
+ return true
91
+ } catch {
92
+ try { fs.unlinkSync(tmp) } catch { /* ignore */ }
93
+ return false
94
+ }
95
+ }
96
+
97
+ export function readForkMeta(homeDir) {
98
+ try {
99
+ const doc = JSON.parse(fs.readFileSync(metaPath(homeDir), 'utf8'))
100
+ return doc && typeof doc === 'object' ? doc : null
101
+ } catch {
102
+ return null
103
+ }
104
+ }
105
+
106
+ /** Loopback probe: any HTTP response means the process is listening (connection refused means not). */
107
+ export function probeHttpAlive(port, options = {}) {
108
+ const n = Math.floor(Number(port))
109
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return Promise.resolve(false)
110
+ const host = options.host ?? '127.0.0.1'
111
+ const pathName = options.path ?? SUPERVISION_DEFAULTS.healthPath
112
+ const timeoutMs = Math.max(50, Number(options.timeoutMs ?? SUPERVISION_DEFAULTS.healthTimeoutMs) || SUPERVISION_DEFAULTS.healthTimeoutMs)
113
+ return new Promise((resolve) => {
114
+ const req = http.request({ host, port: n, path: pathName, method: 'GET', timeout: timeoutMs }, (res) => {
115
+ res.resume()
116
+ resolve(true)
117
+ })
118
+ req.once('error', () => resolve(false))
119
+ req.once('timeout', () => { req.destroy(); resolve(false) })
120
+ req.end()
121
+ })
122
+ }